[client] refactor auto update workflow (#5448)

Auto-update logic moved out of the UI into a dedicated updatemanager.Manager service that runs in the connection layer. The
UI no longer polls or checks for updates independently.
The update manager supports three modes driven by the management server's auto-update policy:
No policy set by mgm: checks GitHub for the latest version and notifies the user (previous behavior, now centralized)
mgm enforces update: the "About" menu triggers installation directly instead of just downloading the file — user still initiates the action
mgm forces update: installation proceeds automatically without user interaction
updateManager lifecycle is now owned by daemon, giving the daemon server direct control via a new TriggerUpdate RPC
Introduces EngineServices struct to group external service dependencies passed to NewEngine, reducing its argument count from 11 to 4
This commit is contained in:
Zoltan Papp
2026-03-13 17:01:28 +01:00
committed by GitHub
parent 2e1aa497d2
commit fe9b844511
84 changed files with 1210 additions and 626 deletions
+30 -14
View File
@@ -34,7 +34,6 @@ import (
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
protobuf "google.golang.org/protobuf/proto"
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/internal"
@@ -308,10 +307,11 @@ type serviceClient struct {
sshJWTCacheTTL int
connected bool
update *version.Update
daemonVersion string
updateIndicationLock sync.Mutex
isUpdateIconActive bool
isEnforcedUpdate bool
lastNotifiedVersion string
settingsEnabled bool
profilesEnabled bool
showNetworks bool
@@ -367,7 +367,6 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient {
showAdvancedSettings: args.showSettings,
showNetworks: args.showNetworks,
update: version.NewUpdateAndStart("nb/client-ui"),
}
s.eventHandler = newEventHandler(s)
@@ -828,7 +827,7 @@ func (s *serviceClient) handleSSOLogin(ctx context.Context, loginResp *proto.Log
return nil
}
func (s *serviceClient) menuUpClick(ctx context.Context, wannaAutoUpdate bool) error {
func (s *serviceClient) menuUpClick(ctx context.Context) error {
systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting)
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
@@ -850,9 +849,7 @@ func (s *serviceClient) menuUpClick(ctx context.Context, wannaAutoUpdate bool) e
return nil
}
if _, err := s.conn.Up(s.ctx, &proto.UpRequest{
AutoUpdate: protobuf.Bool(wannaAutoUpdate),
}); err != nil {
if _, err := s.conn.Up(s.ctx, &proto.UpRequest{}); err != nil {
return fmt.Errorf("start connection: %w", err)
}
@@ -933,13 +930,13 @@ func (s *serviceClient) updateStatus() error {
systrayIconState = false
}
// the updater struct notify by the upgrades available only, but if meanwhile the daemon has successfully
// updated must reset the mUpdate visibility state
// if the daemon version changed (e.g. after a successful update), reset the update indication
if s.daemonVersion != status.DaemonVersion {
s.mUpdate.Hide()
if s.daemonVersion != "" {
s.mUpdate.Hide()
s.isUpdateIconActive = false
}
s.daemonVersion = status.DaemonVersion
s.isUpdateIconActive = s.update.SetDaemonVersion(status.DaemonVersion)
if !s.isUpdateIconActive {
if systrayIconState {
systray.SetTemplateIcon(iconConnectedMacOS, s.icConnected)
@@ -1091,7 +1088,6 @@ func (s *serviceClient) onTrayReady() {
// update exit node menu in case service is already connected
go s.updateExitNodes()
s.update.SetOnUpdateListener(s.onUpdateAvailable)
go func() {
s.getSrvConfig()
time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon
@@ -1135,6 +1131,13 @@ func (s *serviceClient) onTrayReady() {
}
}
})
s.eventManager.AddHandler(func(event *proto.SystemEvent) {
if newVersion, ok := event.Metadata["new_version_available"]; ok {
_, enforced := event.Metadata["enforced"]
log.Infof("received new_version_available event: version=%s enforced=%v", newVersion, enforced)
s.onUpdateAvailable(newVersion, enforced)
}
})
go s.eventManager.Start(s.ctx)
go s.eventHandler.listen(s.ctx)
@@ -1507,10 +1510,18 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
return &config
}
func (s *serviceClient) onUpdateAvailable() {
func (s *serviceClient) onUpdateAvailable(newVersion string, enforced bool) {
s.updateIndicationLock.Lock()
defer s.updateIndicationLock.Unlock()
s.isEnforcedUpdate = enforced
if enforced {
s.mUpdate.SetTitle("Install version " + newVersion)
} else {
s.lastNotifiedVersion = ""
s.mUpdate.SetTitle("Download latest version")
}
s.mUpdate.Show()
s.isUpdateIconActive = true
@@ -1519,6 +1530,11 @@ func (s *serviceClient) onUpdateAvailable() {
} else {
systray.SetTemplateIcon(iconUpdateDisconnectedMacOS, s.icUpdateDisconnected)
}
if enforced && s.lastNotifiedVersion != newVersion {
s.lastNotifiedVersion = newVersion
s.app.SendNotification(fyne.NewNotification("Update available", "A new version "+newVersion+" is ready to install"))
}
}
// onSessionExpire sends a notification to the user when the session expires.
+1 -6
View File
@@ -107,12 +107,7 @@ func (e *Manager) handleEvent(event *proto.SystemEvent) {
handlers := slices.Clone(e.handlers)
e.mu.Unlock()
// critical events are always shown
if !enabled && event.Severity != proto.SystemEvent_CRITICAL {
return
}
if event.UserMessage != "" {
if event.UserMessage != "" && (enabled || event.Severity == proto.SystemEvent_CRITICAL) {
title := e.getEventTitle(event)
body := event.UserMessage
id := event.Metadata["id"]
+36 -3
View File
@@ -82,7 +82,7 @@ func (h *eventHandler) handleConnectClick() {
go func() {
defer connectCancel()
if err := h.client.menuUpClick(connectCtx, true); err != nil {
if err := h.client.menuUpClick(connectCtx); err != nil {
st, ok := status.FromError(err)
if errors.Is(err, context.Canceled) || (ok && st.Code() == codes.Canceled) {
log.Debugf("connect operation cancelled by user")
@@ -211,9 +211,42 @@ func (h *eventHandler) handleGitHubClick() {
}
func (h *eventHandler) handleUpdateClick() {
if err := openURL(version.DownloadUrl()); err != nil {
log.Errorf("failed to open download URL: %v", err)
h.client.updateIndicationLock.Lock()
enforced := h.client.isEnforcedUpdate
h.client.updateIndicationLock.Unlock()
if !enforced {
if err := openURL(version.DownloadUrl()); err != nil {
log.Errorf("failed to open download URL: %v", err)
}
return
}
// prevent blocking against a busy server
h.client.mUpdate.Disable()
go func() {
defer h.client.mUpdate.Enable()
conn, err := h.client.getSrvClient(defaultFailTimeout)
if err != nil {
log.Errorf("failed to get service client for update: %v", err)
_ = openURL(version.DownloadUrl())
return
}
resp, err := conn.TriggerUpdate(h.client.ctx, &proto.TriggerUpdateRequest{})
if err != nil {
log.Errorf("TriggerUpdate failed: %v", err)
_ = openURL(version.DownloadUrl())
return
}
if !resp.Success {
log.Errorf("TriggerUpdate failed: %s", resp.ErrorMsg)
_ = openURL(version.DownloadUrl())
return
}
log.Infof("update triggered via daemon")
}()
}
func (h *eventHandler) handleNetworksClick() {
+3 -3
View File
@@ -397,7 +397,7 @@ type profileMenu struct {
logoutSubItem *subItem
profilesState []Profile
downClickCallback func() error
upClickCallback func(context.Context, bool) error
upClickCallback func(context.Context) error
getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error)
loadSettingsCallback func()
app fyne.App
@@ -411,7 +411,7 @@ type newProfileMenuArgs struct {
profileMenuItem *systray.MenuItem
emailMenuItem *systray.MenuItem
downClickCallback func() error
upClickCallback func(context.Context, bool) error
upClickCallback func(context.Context) error
getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error)
loadSettingsCallback func()
app fyne.App
@@ -579,7 +579,7 @@ func (p *profileMenu) refresh() {
connectCtx, connectCancel := context.WithCancel(p.ctx)
p.serviceClient.connectCancel = connectCancel
if err := p.upClickCallback(connectCtx, false); err != nil {
if err := p.upClickCallback(connectCtx); err != nil {
log.Errorf("failed to handle up click after switching profile: %v", err)
}
+1 -1
View File
@@ -267,7 +267,7 @@ func (s *serviceClient) showQuickActionsUI() {
connCmd := connectCommand{
connectClient: func() error {
return s.menuUpClick(s.ctx, false)
return s.menuUpClick(s.ctx)
},
}