[client/ui] Replace update event fan-out with typed UpdateState API

The auto-update feature was driven by two narrow Wails events
(netbird:update:available and :progress) plus a SystemEvent-metadata
iteration on the React side. Both surfaces had to know the daemon
metadata schema (new_version_available, enforced, progress_window),
and the frontend had no pull endpoint to seed its state on mount.

Extract the state machine into a new client/ui/updater package, mirroring
how i18n and preferences are split between domain logic and a thin
services facade. The package owns the State type, the metadata-key
parsing, the mutex-guarded Holder, and the single netbird:update:state
event. services.Update keeps the daemon RPCs (Trigger, GetInstallerResult,
Quit) and gains GetState as a Wails pull endpoint.

Tray-side update behaviour moves out of tray.go into a dedicated
trayUpdater (tray_update.go): owns its menu item, OS notification,
click handler, and the /update window opener triggered by the
daemon's progress_window:show. tray.go drops three callbacks and four
fields, and reads hasUpdate through the updater.

Frontend ClientVersionContext now seeds from Update.GetState() and
subscribes to netbird:update:state; the status.events iteration and
metadata-key string literals are gone. UpdateAvailableBanner renders
only for the enforced && !installing branch and labels its action
"Install now"; UpdateVersionCard splits the install vs. download
branches by Enforced so the disabled flow routes to GitHub.
This commit is contained in:
Zoltan Papp
2026-05-15 13:21:35 +02:00
parent 1ebb507cbb
commit 9d8eb76746
9 changed files with 498 additions and 211 deletions

View File

@@ -9,7 +9,6 @@ import (
"sort"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
@@ -86,14 +85,12 @@ type Tray struct {
profileEmailItem *application.MenuItem
settingsItem *application.MenuItem
debugItem *application.MenuItem
updateItem *application.MenuItem
daemonVersionItem *application.MenuItem
updater *trayUpdater
mu sync.Mutex
connected bool
hasUpdate bool
updateVersion string
updateEnforced bool
exitNodes []string
lastStatus string
lastDaemonVersion string
@@ -121,6 +118,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// the right locale — no English flash followed by a re-paint.
loc: svc.Localizer,
}
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() })
t.tray = app.SystemTray.New()
t.applyIcon()
t.tray.SetTooltip(t.loc.T("tray.tooltip"))
@@ -138,8 +136,6 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
app.Event.On(services.EventStatus, t.onStatusEvent)
app.Event.On(services.EventSystem, t.onSystemEvent)
app.Event.On(services.EventUpdateAvailable, t.onUpdateAvailable)
app.Event.On(services.EventUpdateProgress, t.onUpdateProgress)
// Defer the first profile load until the macOS/GTK/Win32 menu impl is
// live — Menu.Update() short-circuits while app.running is false, and
// AppKit's main queue isn't ready earlier either (see d23ef34 InvokeSync
@@ -170,18 +166,15 @@ func (t *Tray) applyLanguage() {
}
// reapplyMenuState walks cached state and re-applies the visibility,
// enablement and label mutations that applyStatus / onUpdateAvailable
// would have performed since the last menu rebuild. Required after
// buildMenu because that constructor returns items in their default
// (disconnected, no-update) shape.
// enablement and label mutations that applyStatus would have performed
// since the last menu rebuild. Required after buildMenu because that
// constructor returns items in their default (disconnected) shape. The
// update menu item is re-applied by trayUpdater.applyLanguage.
func (t *Tray) reapplyMenuState() {
t.mu.Lock()
connected := t.connected
lastStatus := t.lastStatus
daemonVersion := t.lastDaemonVersion
hasUpdate := t.hasUpdate
updateVersion := t.updateVersion
updateEnforced := t.updateEnforced
exitNodes := append([]string(nil), t.exitNodes...)
t.mu.Unlock()
@@ -216,13 +209,8 @@ func (t *Tray) reapplyMenuState() {
if daemonVersion != "" && t.daemonVersionItem != nil {
t.daemonVersionItem.SetLabel(t.loc.T("tray.menu.daemonVersion", "version", daemonVersion))
}
if hasUpdate && t.updateItem != nil {
if updateEnforced {
t.updateItem.SetLabel(t.loc.T("tray.menu.installVersion", "version", updateVersion))
} else {
t.updateItem.SetLabel(t.loc.T("tray.menu.downloadLatest"))
}
t.updateItem.SetHidden(false)
if t.updater != nil {
t.updater.applyLanguage()
}
if len(exitNodes) > 0 {
t.rebuildExitNodes(exitNodes)
@@ -310,12 +298,14 @@ func (t *Tray) buildMenu() *application.Menu {
// Status snapshot and is updated in applyStatus.
about.Add(t.loc.T("tray.menu.guiVersion", "version", version.NetbirdVersion())).SetEnabled(false)
t.daemonVersionItem = about.Add(t.loc.T("tray.menu.daemonVersion", "version", t.loc.T("tray.menu.versionUnknown"))).SetEnabled(false)
// Hidden until the daemon emits EventUpdateAvailable. The label is
// rewritten in onUpdateAvailable: tray.menu.downloadLatest for opt-in,
// tray.menu.installVersion when the management server enforces the
// update.
t.updateItem = about.Add(t.loc.T("tray.menu.downloadLatest")).OnClick(func(*application.Context) { t.handleUpdate() })
t.updateItem.SetHidden(true)
// Update menu item is hidden until the daemon reports a new version
// (EventUpdateState with Available=true). trayUpdater rewrites the
// label between tray.menu.downloadLatest (opt-in) and
// tray.menu.installVersion (enforced) and drives the click.
updateItem := about.Add(t.loc.T("tray.menu.downloadLatest")).
OnClick(func(*application.Context) { t.updater.handleClick() })
updateItem.SetHidden(true)
t.updater.attach(updateItem)
menu.AddSeparator()
menu.Add(t.loc.T("tray.menu.quit")).OnClick(func(*application.Context) { t.app.Quit() })
@@ -396,8 +386,8 @@ func (t *Tray) onStatusEvent(ev *application.CustomEvent) {
// onSystemEvent fires an OS notification for daemon SystemEvents that carry
// a user-facing message, mirroring the legacy event.Manager behaviour: gated
// by the user's "Notifications" toggle, with CRITICAL events bypassing the
// gate. The narrowly-scoped EventUpdate* events are skipped here because
// onUpdateAvailable already produces a richer notification for them.
// gate. Update-related events are skipped here because trayUpdater produces
// its own richer notification when EventUpdateState fires.
func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
se, ok := ev.Data.(services.SystemEvent)
if !ok || se.UserMessage == "" {
@@ -406,6 +396,9 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
if _, isUpdate := se.Metadata["new_version_available"]; isUpdate {
return
}
if _, isProgress := se.Metadata["progress_window"]; isProgress {
return
}
// Management pairs ::/0 with 0.0.0.0/0 for exit-node default routes;
// the v4 partner already drives the user-facing toast, so the v6 one
// is suppressed to avoid a duplicate notification.
@@ -428,107 +421,6 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) {
t.notify(eventTitle(se), body, notifyIDEvent+se.ID)
}
// onUpdateAvailable runs when the daemon reports a new netbird version. It
// flips the tray's hasUpdate flag (icon swap), reveals the update menu
// item with the right label, and posts an OS notification.
// The notification is what the legacy Fyne UI used to alert the user.
func (t *Tray) onUpdateAvailable(ev *application.CustomEvent) {
upd, ok := ev.Data.(services.UpdateAvailable)
if !ok {
log.Warnf("update event payload not UpdateAvailable: %T", ev.Data)
return
}
log.Infof("tray onUpdateAvailable: version=%s enforced=%v", upd.Version, upd.Enforced)
t.mu.Lock()
t.hasUpdate = true
t.updateVersion = upd.Version
t.updateEnforced = upd.Enforced
t.mu.Unlock()
t.applyIcon()
if t.updateItem != nil {
// Match the Fyne wording: enforced updates name the version
// because the install starts on click; opt-in updates just
// route the user to the latest release.
if upd.Enforced {
t.updateItem.SetLabel(t.loc.T("tray.menu.installVersion", "version", upd.Version))
} else {
t.updateItem.SetLabel(t.loc.T("tray.menu.downloadLatest"))
}
t.updateItem.SetHidden(false)
}
body := t.loc.T("notify.update.body", "version", upd.Version)
if upd.Enforced {
body += t.loc.T("notify.update.enforcedSuffix")
}
if err := t.svc.Notifier.SendNotification(notifications.NotificationOptions{
ID: notifyIDUpdatePrefix + upd.Version,
Title: t.loc.T("notify.update.title"),
Body: body,
}); err != nil {
log.Debugf("send update notification: %v", err)
}
}
// handleUpdate runs when the user clicks the "Download latest version" /
// "Install version X" menu item. Enforced updates trigger the daemon's
// installer flow and surface the in-window /update progress page;
// opt-in updates just open the GitHub releases page in the browser.
func (t *Tray) handleUpdate() {
t.mu.Lock()
enforced := t.updateEnforced
updateVersion := t.updateVersion
t.mu.Unlock()
if !enforced {
_ = t.app.Browser.OpenURL(urlGitHubReleases)
return
}
// Surface the progress page first so the user sees the install
// kick off; the daemon then drives the rest via the InstallerResult
// RPC the /update page is polling.
if t.window != nil {
url := "/#/update"
if updateVersion != "" {
url += "?version=" + updateVersion
}
t.window.SetURL(url)
t.window.Show()
t.window.Focus()
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := t.svc.Update.Trigger(ctx); err != nil {
log.Errorf("trigger update: %v", err)
}
}()
}
// onUpdateProgress runs when the daemon enters the install phase of an
// enforced update. The Fyne UI used to spawn a separate process with the
// update window; here the window is already in-process, so we just route to
// the /update page and bring it forward.
func (t *Tray) onUpdateProgress(ev *application.CustomEvent) {
prog, ok := ev.Data.(services.UpdateProgress)
if !ok || prog.Action != "show" {
return
}
if t.window == nil {
return
}
url := "/#/update"
if prog.Version != "" {
url += "?version=" + prog.Version
}
t.window.SetURL(url)
t.window.Show()
t.window.Focus()
}
// applyStatus updates the tray icon, status label, exit-node submenu, and
// connect/disconnect enablement based on the latest daemon snapshot.
// Skips the icon refresh when none of the icon-relevant inputs
@@ -701,9 +593,12 @@ func statusIndicatorBitmap(status string) []byte {
func (t *Tray) applyIcon() {
t.mu.Lock()
connected := t.connected
hasUpdate := t.hasUpdate
statusLabel := t.lastStatus
t.mu.Unlock()
hasUpdate := false
if t.updater != nil {
hasUpdate = t.updater.hasUpdate()
}
log.Infof("tray applyIcon: connected=%v hasUpdate=%v status=%q goos=%s",
connected, hasUpdate, statusLabel, runtime.GOOS)
@@ -722,9 +617,12 @@ func (t *Tray) applyIcon() {
func (t *Tray) iconForState() (icon, dark []byte) {
t.mu.Lock()
connected := t.connected
hasUpdate := t.hasUpdate
statusLabel := t.lastStatus
t.mu.Unlock()
hasUpdate := false
if t.updater != nil {
hasUpdate = t.updater.hasUpdate()
}
connecting := strings.EqualFold(statusLabel, services.StatusConnecting)
errored := strings.EqualFold(statusLabel, statusError) ||