[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

@@ -15,6 +15,7 @@ import (
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/updater"
)
const (
@@ -22,15 +23,10 @@ const (
// is captured (from a poll or a stream-driven refresh).
EventStatus = "netbird:status"
// EventSystem is emitted for each SubscribeEvents message (DNS, network,
// auth, connectivity categories).
// auth, connectivity categories). Auto-update SystemEvents are also
// forwarded here to updater.Holder.OnSystemEvent so the typed update
// state can be maintained without a second daemon subscription.
EventSystem = "netbird:event"
// EventUpdateAvailable fires when the daemon detects a new version. The
// metadata's enforced flag is propagated as part of the payload.
EventUpdateAvailable = "netbird:update:available"
// EventUpdateProgress fires when the daemon is about to start (or has
// started) installing an update — Mode 2 enforced flow. The UI opens the
// progress window in response.
EventUpdateProgress = "netbird:update:progress"
// StatusDaemonUnavailable is the synthetic Status the UI emits when the
// daemon's gRPC socket is unreachable (daemon not running, socket
@@ -55,18 +51,6 @@ type Emitter interface {
Emit(name string, data ...any) bool
}
// UpdateAvailable carries the new_version_available metadata.
type UpdateAvailable struct {
Version string `json:"version"`
Enforced bool `json:"enforced"`
}
// UpdateProgress carries the progress_window metadata.
type UpdateProgress struct {
Action string `json:"action"`
Version string `json:"version"`
}
// SystemEvent is the frontend-facing shape of a daemon SystemEvent.
type SystemEvent struct {
ID string `json:"id"`
@@ -153,6 +137,7 @@ type Status struct {
type Peers struct {
conn DaemonConn
emitter Emitter
updater *updater.Holder
mu sync.Mutex
cancel context.CancelFunc
@@ -163,8 +148,8 @@ type Peers struct {
switchInProgressUntil time.Time
}
func NewPeers(conn DaemonConn, emitter Emitter) *Peers {
return &Peers{conn: conn, emitter: emitter}
func NewPeers(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder) *Peers {
return &Peers{conn: conn, emitter: emitter, updater: updaterHolder}
}
// BeginProfileSwitch is called by ProfileSwitcher at the start of a switch
@@ -403,7 +388,9 @@ func (s *Peers) toastStreamLoop(ctx context.Context) {
se := systemEventFromProto(ev)
log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage)
s.emitter.Emit(EventSystem, se)
s.fanOutUpdateEvents(ev)
if s.updater != nil {
s.updater.OnSystemEvent(ev)
}
}
}
@@ -466,27 +453,6 @@ func statusFromProto(resp *proto.StatusResponse) Status {
return st
}
// fanOutUpdateEvents inspects the daemon SystemEvent for update-related
// metadata keys and re-emits them as dedicated Wails events. This lets the
// tray and React update window listen for a single, narrow event instead of
// re-checking metadata on every system event they receive.
func (s *Peers) fanOutUpdateEvents(ev *proto.SystemEvent) {
md := ev.GetMetadata()
if md == nil {
return
}
if v, ok := md["new_version_available"]; ok {
_, enforced := md["enforced"]
s.emitter.Emit(EventUpdateAvailable, UpdateAvailable{Version: v, Enforced: enforced})
}
if action, ok := md["progress_window"]; ok {
s.emitter.Emit(EventUpdateProgress, UpdateProgress{
Action: action,
Version: md["version"],
})
}
}
func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
out := SystemEvent{
ID: e.GetId(),

View File

@@ -9,6 +9,7 @@ import (
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/updater"
)
// UpdateResult mirrors TriggerUpdateResponse: Success false carries an error
@@ -18,13 +19,25 @@ type UpdateResult struct {
ErrorMsg string `json:"errorMsg"`
}
// Update groups the RPCs that drive the enforced-update install flow.
// Update is the Wails-bound facade over the daemon's update RPCs and the
// updater.Holder cached state. The state machine, metadata schema, and
// push event live in client/ui/updater — this file exists only to give
// the binding generator a service type with the context.Context-first
// signatures it expects.
type Update struct {
conn DaemonConn
conn DaemonConn
holder *updater.Holder
}
func NewUpdate(conn DaemonConn) *Update {
return &Update{conn: conn}
func NewUpdate(conn DaemonConn, holder *updater.Holder) *Update {
return &Update{conn: conn, holder: holder}
}
// GetState returns the latest update.State snapshot. The frontend calls
// this once on mount, then subscribes to updater.EventStateChanged for
// live updates.
func (s *Update) GetState() updater.State {
return s.holder.Get()
}
// Quit asks the host application to exit. The /update page calls this once