[client, management] auto-update (#4732)

This commit is contained in:
Zoltan Papp
2025-12-19 19:57:39 +01:00
committed by GitHub
parent 537151e0f3
commit 011cc81678
87 changed files with 9720 additions and 716 deletions

View File

@@ -34,6 +34,7 @@ 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"
@@ -43,7 +44,6 @@ import (
"github.com/netbirdio/netbird/client/ui/desktop"
"github.com/netbirdio/netbird/client/ui/event"
"github.com/netbirdio/netbird/client/ui/process"
"github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/version"
@@ -87,22 +87,24 @@ func main() {
// Create the service client (this also builds the settings or networks UI if requested).
client := newServiceClient(&newServiceClientArgs{
addr: flags.daemonAddr,
logFile: logFile,
app: a,
showSettings: flags.showSettings,
showNetworks: flags.showNetworks,
showLoginURL: flags.showLoginURL,
showDebug: flags.showDebug,
showProfiles: flags.showProfiles,
showQuickActions: flags.showQuickActions,
addr: flags.daemonAddr,
logFile: logFile,
app: a,
showSettings: flags.showSettings,
showNetworks: flags.showNetworks,
showLoginURL: flags.showLoginURL,
showDebug: flags.showDebug,
showProfiles: flags.showProfiles,
showQuickActions: flags.showQuickActions,
showUpdate: flags.showUpdate,
showUpdateVersion: flags.showUpdateVersion,
})
// Watch for theme/settings changes to update the icon.
go watchSettingsChanges(a, client)
// Run in window mode if any UI flag was set.
if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions {
if flags.showSettings || flags.showNetworks || flags.showDebug || flags.showLoginURL || flags.showProfiles || flags.showQuickActions || flags.showUpdate {
a.Run()
return
}
@@ -128,15 +130,17 @@ func main() {
}
type cliFlags struct {
daemonAddr string
showSettings bool
showNetworks bool
showProfiles bool
showDebug bool
showLoginURL bool
showQuickActions bool
errorMsg string
saveLogsInFile bool
daemonAddr string
showSettings bool
showNetworks bool
showProfiles bool
showDebug bool
showLoginURL bool
showQuickActions bool
errorMsg string
saveLogsInFile bool
showUpdate bool
showUpdateVersion string
}
// parseFlags reads and returns all needed command-line flags.
@@ -156,6 +160,8 @@ func parseFlags() *cliFlags {
flag.StringVar(&flags.errorMsg, "error-msg", "", "displays an error message window")
flag.BoolVar(&flags.saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir()))
flag.BoolVar(&flags.showLoginURL, "login-url", false, "show login URL in a popup window")
flag.BoolVar(&flags.showUpdate, "update", false, "show update progress window")
flag.StringVar(&flags.showUpdateVersion, "update-version", "", "version to update to")
flag.Parse()
return &flags
}
@@ -319,6 +325,8 @@ type serviceClient struct {
mExitNodeDeselectAll *systray.MenuItem
logFile string
wLoginURL fyne.Window
wUpdateProgress fyne.Window
updateContextCancel context.CancelFunc
connectCancel context.CancelFunc
}
@@ -329,15 +337,17 @@ type menuHandler struct {
}
type newServiceClientArgs struct {
addr string
logFile string
app fyne.App
showSettings bool
showNetworks bool
showDebug bool
showLoginURL bool
showProfiles bool
showQuickActions bool
addr string
logFile string
app fyne.App
showSettings bool
showNetworks bool
showDebug bool
showLoginURL bool
showProfiles bool
showQuickActions bool
showUpdate bool
showUpdateVersion string
}
// newServiceClient instance constructor
@@ -355,7 +365,7 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient {
showAdvancedSettings: args.showSettings,
showNetworks: args.showNetworks,
update: version.NewUpdate("nb/client-ui"),
update: version.NewUpdateAndStart("nb/client-ui"),
}
s.eventHandler = newEventHandler(s)
@@ -375,6 +385,8 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient {
s.showProfilesUI()
case args.showQuickActions:
s.showQuickActionsUI()
case args.showUpdate:
s.showUpdateProgress(ctx, args.showUpdateVersion)
}
return s
@@ -814,7 +826,7 @@ func (s *serviceClient) handleSSOLogin(ctx context.Context, loginResp *proto.Log
return nil
}
func (s *serviceClient) menuUpClick(ctx context.Context) error {
func (s *serviceClient) menuUpClick(ctx context.Context, wannaAutoUpdate bool) error {
systray.SetTemplateIcon(iconConnectingMacOS, s.icConnecting)
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
@@ -836,7 +848,9 @@ func (s *serviceClient) menuUpClick(ctx context.Context) error {
return nil
}
if _, err := conn.Up(ctx, &proto.UpRequest{}); err != nil {
if _, err := s.conn.Up(s.ctx, &proto.UpRequest{
AutoUpdate: protobuf.Bool(wannaAutoUpdate),
}); err != nil {
return fmt.Errorf("start connection: %w", err)
}
@@ -1097,6 +1111,26 @@ func (s *serviceClient) onTrayReady() {
s.updateExitNodes()
}
})
s.eventManager.AddHandler(func(event *proto.SystemEvent) {
// todo use new Category
if windowAction, ok := event.Metadata["progress_window"]; ok {
targetVersion, ok := event.Metadata["version"]
if !ok {
targetVersion = "unknown"
}
log.Debugf("window action: %v", windowAction)
if windowAction == "show" {
if s.updateContextCancel != nil {
s.updateContextCancel()
s.updateContextCancel = nil
}
subCtx, cancel := context.WithCancel(s.ctx)
go s.eventHandler.runSelfCommand(subCtx, "update", "--update-version", targetVersion)
s.updateContextCancel = cancel
}
}
})
go s.eventManager.Start(s.ctx)
go s.eventHandler.listen(s.ctx)

View File

@@ -80,7 +80,7 @@ func (h *eventHandler) handleConnectClick() {
go func() {
defer connectCancel()
if err := h.client.menuUpClick(connectCtx); err != nil {
if err := h.client.menuUpClick(connectCtx, true); 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")
@@ -185,7 +185,7 @@ func (h *eventHandler) handleAdvancedSettingsClick() {
go func() {
defer h.client.mAdvancedSettings.Enable()
defer h.client.getSrvConfig()
h.runSelfCommand(h.client.ctx, "settings", "true")
h.runSelfCommand(h.client.ctx, "settings")
}()
}
@@ -193,7 +193,7 @@ func (h *eventHandler) handleCreateDebugBundleClick() {
h.client.mCreateDebugBundle.Disable()
go func() {
defer h.client.mCreateDebugBundle.Enable()
h.runSelfCommand(h.client.ctx, "debug", "true")
h.runSelfCommand(h.client.ctx, "debug")
}()
}
@@ -217,7 +217,7 @@ func (h *eventHandler) handleNetworksClick() {
h.client.mNetworks.Disable()
go func() {
defer h.client.mNetworks.Enable()
h.runSelfCommand(h.client.ctx, "networks", "true")
h.runSelfCommand(h.client.ctx, "networks")
}()
}
@@ -237,17 +237,21 @@ func (h *eventHandler) updateConfigWithErr() error {
return nil
}
func (h *eventHandler) runSelfCommand(ctx context.Context, command, arg string) {
func (h *eventHandler) runSelfCommand(ctx context.Context, command string, args ...string) {
proc, err := os.Executable()
if err != nil {
log.Errorf("error getting executable path: %v", err)
return
}
cmd := exec.CommandContext(ctx, proc,
fmt.Sprintf("--%s=%s", command, arg),
// Build the full command arguments
cmdArgs := []string{
fmt.Sprintf("--%s=true", command),
fmt.Sprintf("--daemon-addr=%s", h.client.addr),
)
}
cmdArgs = append(cmdArgs, args...)
cmd := exec.CommandContext(ctx, proc, cmdArgs...)
if out := h.client.attachOutput(cmd); out != nil {
defer func() {
@@ -257,17 +261,17 @@ func (h *eventHandler) runSelfCommand(ctx context.Context, command, arg string)
}()
}
log.Printf("running command: %s --%s=%s --daemon-addr=%s", proc, command, arg, h.client.addr)
log.Printf("running command: %s", cmd.String())
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
log.Printf("command '%s %s' failed with exit code %d", command, arg, exitErr.ExitCode())
log.Printf("command '%s' failed with exit code %d", cmd.String(), exitErr.ExitCode())
}
return
}
log.Printf("command '%s %s' completed successfully", command, arg)
log.Printf("command '%s' completed successfully", cmd.String())
}
func (h *eventHandler) logout(ctx context.Context) error {

View File

@@ -397,7 +397,7 @@ type profileMenu struct {
logoutSubItem *subItem
profilesState []Profile
downClickCallback func() error
upClickCallback func(context.Context) error
upClickCallback func(context.Context, bool) 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) error
upClickCallback func(context.Context, bool) 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); err != nil {
if err := p.upClickCallback(connectCtx, false); err != nil {
log.Errorf("failed to handle up click after switching profile: %v", err)
}

View File

@@ -267,7 +267,7 @@ func (s *serviceClient) showQuickActionsUI() {
connCmd := connectCommand{
connectClient: func() error {
return s.menuUpClick(s.ctx)
return s.menuUpClick(s.ctx, false)
},
}

140
client/ui/update.go Normal file
View File

@@ -0,0 +1,140 @@
//go:build !(linux && 386)
package main
import (
"context"
"errors"
"fmt"
"strings"
"time"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
func (s *serviceClient) showUpdateProgress(ctx context.Context, version string) {
log.Infof("show installer progress window: %s", version)
s.wUpdateProgress = s.app.NewWindow("Automatically updating client")
statusLabel := widget.NewLabel("Updating...")
infoLabel := widget.NewLabel(fmt.Sprintf("Your client version is older than the auto-update version set in Management.\nUpdating client to: %s.", version))
content := container.NewVBox(infoLabel, statusLabel)
s.wUpdateProgress.SetContent(content)
s.wUpdateProgress.CenterOnScreen()
s.wUpdateProgress.SetFixedSize(true)
s.wUpdateProgress.SetCloseIntercept(func() {
// this is empty to lock window until result known
})
s.wUpdateProgress.RequestFocus()
s.wUpdateProgress.Show()
updateWindowCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
// Initialize dot updater
updateText := dotUpdater()
// Channel to receive the result from RPC call
resultErrCh := make(chan error, 1)
resultOkCh := make(chan struct{}, 1)
// Start RPC call in background
go func() {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
log.Infof("backend not reachable, upgrade in progress: %v", err)
close(resultOkCh)
return
}
resp, err := conn.GetInstallerResult(updateWindowCtx, &proto.InstallerResultRequest{})
if err != nil {
log.Infof("backend stopped responding, upgrade in progress: %v", err)
close(resultOkCh)
return
}
if !resp.Success {
resultErrCh <- mapInstallError(resp.ErrorMsg)
return
}
// Success
close(resultOkCh)
}()
// Update UI with dots and wait for result
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
defer cancel()
// allow closing update window after 10 sec
timerResetCloseInterceptor := time.NewTimer(10 * time.Second)
defer timerResetCloseInterceptor.Stop()
for {
select {
case <-updateWindowCtx.Done():
s.showInstallerResult(statusLabel, updateWindowCtx.Err())
return
case err := <-resultErrCh:
s.showInstallerResult(statusLabel, err)
return
case <-resultOkCh:
log.Info("backend exited, upgrade in progress, closing all UI")
killParentUIProcess()
s.app.Quit()
return
case <-ticker.C:
statusLabel.SetText(updateText())
case <-timerResetCloseInterceptor.C:
s.wUpdateProgress.SetCloseIntercept(nil)
}
}
}()
}
func (s *serviceClient) showInstallerResult(statusLabel *widget.Label, err error) {
s.wUpdateProgress.SetCloseIntercept(nil)
switch {
case errors.Is(err, context.DeadlineExceeded):
log.Warn("update watcher timed out")
statusLabel.SetText("Update timed out. Please try again.")
case errors.Is(err, context.Canceled):
log.Info("update watcher canceled")
statusLabel.SetText("Update canceled.")
case err != nil:
log.Errorf("update failed: %v", err)
statusLabel.SetText("Update failed: " + err.Error())
default:
s.wUpdateProgress.Close()
}
}
// dotUpdater returns a closure that cycles through dots for a loading animation.
func dotUpdater() func() string {
dotCount := 0
return func() string {
dotCount = (dotCount + 1) % 4
return fmt.Sprintf("%s%s", "Updating", strings.Repeat(".", dotCount))
}
}
func mapInstallError(msg string) error {
msg = strings.ToLower(strings.TrimSpace(msg))
switch {
case strings.Contains(msg, "deadline exceeded"), strings.Contains(msg, "timeout"):
return context.DeadlineExceeded
case strings.Contains(msg, "canceled"), strings.Contains(msg, "cancelled"):
return context.Canceled
case msg == "":
return errors.New("unknown update error")
default:
return errors.New(msg)
}
}

View File

@@ -0,0 +1,7 @@
//go:build !windows && !(linux && 386)
package main
func killParentUIProcess() {
// No-op on non-Windows platforms
}

View File

@@ -0,0 +1,44 @@
//go:build windows
package main
import (
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
nbprocess "github.com/netbirdio/netbird/client/ui/process"
)
// killParentUIProcess finds and kills the parent systray UI process on Windows.
// This is a workaround in case the MSI installer fails to properly terminate the UI process.
// The installer should handle this via util:CloseApplication with TerminateProcess, but this
// provides an additional safety mechanism to ensure the UI is closed before the upgrade proceeds.
func killParentUIProcess() {
pid, running, err := nbprocess.IsAnotherProcessRunning()
if err != nil {
log.Warnf("failed to check for parent UI process: %v", err)
return
}
if !running {
log.Debug("no parent UI process found to kill")
return
}
log.Infof("killing parent UI process (PID: %d)", pid)
// Open the process with terminate rights
handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE, false, uint32(pid))
if err != nil {
log.Warnf("failed to open parent process %d: %v", pid, err)
return
}
defer func() {
_ = windows.CloseHandle(handle)
}()
// Terminate the process with exit code 0
if err := windows.TerminateProcess(handle, 0); err != nil {
log.Warnf("failed to terminate parent process %d: %v", pid, err)
}
}