From 9d8eb76746fc3233aab95075f097bfb9bf2872fc Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Fri, 15 May 2026 13:21:35 +0200 Subject: [PATCH] [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. --- .../auto-update/ClientVersionContext.tsx | 88 ++++++-- .../auto-update/UpdateAvailableBanner.tsx | 14 +- .../modules/auto-update/UpdateVersionCard.tsx | 20 +- client/ui/main.go | 12 +- client/ui/services/peers.go | 54 +---- client/ui/services/update.go | 21 +- client/ui/tray.go | 162 +++---------- client/ui/tray_update.go | 212 ++++++++++++++++++ client/ui/updater/state.go | 126 +++++++++++ 9 files changed, 498 insertions(+), 211 deletions(-) create mode 100644 client/ui/tray_update.go create mode 100644 client/ui/updater/state.go diff --git a/client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx b/client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx index 005c517a6..b96afd577 100644 --- a/client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx +++ b/client/ui/frontend/src/modules/auto-update/ClientVersionContext.tsx @@ -1,12 +1,23 @@ -import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { Events } from "@wailsio/runtime"; import { Update as UpdateSvc } from "@bindings/services"; -import { useStatus } from "@/hooks/useStatus"; +import type { State as UpdateState } from "@bindings/updater/models.js"; import { UpdateAvailableBanner } from "@/modules/auto-update/UpdateAvailableBanner"; import { UpdatingOverlay } from "@/modules/auto-update/UpdatingOverlay"; type ClientVersionContextValue = { updateAvailable: boolean; updateVersion: string | null; + enforced: boolean; + installing: boolean; triggerUpdate: () => void; updating: boolean; updateError: string | null; @@ -14,8 +25,9 @@ type ClientVersionContextValue = { }; // Dev toggles — flip to preview UI states without triggering real flows. -const FORCE_UPDATE_AVAILABLE = true; +const FORCE_UPDATE_AVAILABLE = false; const FORCE_UPDATING = false; +const FORCE_ENFORCED = true; const FORCE_VERSION = "0.65.0"; // Hide all "update available" UI (header trigger, settings badge, banner) // regardless of what the daemon reports. @@ -42,6 +54,15 @@ const forcedErrorMessage = (): string | null => { } }; +const EVENT_UPDATE_STATE = "netbird:update:state"; + +const emptyState: UpdateState = { + available: false, + version: "", + enforced: false, + installing: false, +}; + const ClientVersionContext = createContext(null); export const useClientVersion = () => { @@ -53,19 +74,46 @@ export const useClientVersion = () => { }; export const ClientVersionProvider = ({ children }: { children: ReactNode }) => { - const { status } = useStatus(); + const [state, setState] = useState(emptyState); const [updating, setUpdating] = useState(false); const [updateError, setUpdateError] = useState(null); - const updateVersion = useMemo(() => { - if (HIDE_UPDATE_AVAILABLE) return null; - if (FORCE_UPDATE_AVAILABLE || FORCE_UPDATING) return FORCE_VERSION; - return ( - (status?.events ?? []) - .map((e) => e.metadata?.["new_version_available"]) - .find((v): v is string => Boolean(v)) ?? null - ); - }, [status]); + // Pull the current state once on mount so a banner / overlay that + // re-renders later in the session still has the right baseline, then + // subscribe to the push channel for live updates. + useEffect(() => { + let cancelled = false; + UpdateSvc.GetState() + .then((s) => { + if (cancelled || !s) return; + setState(s); + }) + .catch(() => { + /* daemon unreachable — leave defaults */ + }); + const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => { + if (ev?.data) setState(ev.data); + }); + return () => { + cancelled = true; + off?.(); + }; + }, []); + + // Merge the live state with dev overrides. The overrides win so designers + // can preview any branch without involving the daemon. + const effective = useMemo(() => { + if (HIDE_UPDATE_AVAILABLE) return emptyState; + if (FORCE_UPDATE_AVAILABLE || FORCE_UPDATING) { + return { + available: true, + version: FORCE_VERSION, + enforced: FORCE_ENFORCED, + installing: FORCE_UPDATING, + }; + } + return state; + }, [state]); const triggerUpdate = useCallback(() => { setUpdateError(null); @@ -85,25 +133,29 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) => const dismissUpdateError = useCallback(() => setUpdateError(null), []); + const showOverlay = updating || effective.installing || updateError || FORCE_ERROR; + const value = useMemo( () => ({ - updateAvailable: Boolean(updateVersion), - updateVersion, + updateAvailable: effective.available, + updateVersion: effective.version || null, + enforced: effective.enforced, + installing: effective.installing, triggerUpdate, updating, updateError, dismissUpdateError, }), - [updateVersion, triggerUpdate, updating, updateError, dismissUpdateError], + [effective, triggerUpdate, updating, updateError, dismissUpdateError], ); return ( {children} - {(updating || updateError || FORCE_UPDATING || FORCE_ERROR) && ( + {showOverlay && ( diff --git a/client/ui/frontend/src/modules/auto-update/UpdateAvailableBanner.tsx b/client/ui/frontend/src/modules/auto-update/UpdateAvailableBanner.tsx index e7fd327cd..816596b7a 100644 --- a/client/ui/frontend/src/modules/auto-update/UpdateAvailableBanner.tsx +++ b/client/ui/frontend/src/modules/auto-update/UpdateAvailableBanner.tsx @@ -3,13 +3,17 @@ import { Button } from "@/components/Button"; import { useClientVersion } from "@/modules/auto-update/ClientVersionContext"; import { cn } from "@/lib/cn"; -// TODO: Shown only when management has auto updates enabled + there are updates available + force updates is disabled +// Shown only when management has auto-update enabled (enforced=true) and the +// daemon has not yet started the installer (installing=false). The +// download-only branch (enforced=false) routes the user to GitHub via the +// tray menu instead; the force-install branch (installing=true) takes over +// with the full-screen UpdatingOverlay. export const UpdateAvailableBanner = () => { - const { updateVersion, triggerUpdate } = useClientVersion(); + const { updateVersion, enforced, installing, triggerUpdate } = useClientVersion(); const [dismissed, setDismissed] = useState(false); if (import.meta.env.DEV) return null; - if (!updateVersion || dismissed) return null; + if (!updateVersion || !enforced || installing || dismissed) return null; return (
{ )} >

- NetBird will update when you restart the app. + NetBird {updateVersion} is ready to install.

diff --git a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx index ac5edc96f..6348724dd 100644 --- a/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx +++ b/client/ui/frontend/src/modules/auto-update/UpdateVersionCard.tsx @@ -4,6 +4,8 @@ import { Button } from "@/components/Button"; import { useClientVersion } from "@/modules/auto-update/ClientVersionContext"; import { cn } from "@/lib/cn"; +const GITHUB_RELEASES = "https://github.com/netbirdio/netbird/releases/latest"; + function openUrl(url: string) { void Browser.OpenURL(url).catch(() => window.open(url, "_blank")); } @@ -18,7 +20,7 @@ function formatLastChecked(date: Date) { } export function UpdateVersionCard() { - const { updateVersion, triggerUpdate } = useClientVersion(); + const { updateVersion, enforced, triggerUpdate } = useClientVersion(); if (updateVersion) { return ( @@ -31,9 +33,19 @@ export function UpdateVersionCard() { What's new? - + {enforced ? ( + + ) : ( + + )} ); } diff --git a/client/ui/main.go b/client/ui/main.go index da05da920..2270be8c0 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/client/ui/i18n" "github.com/netbirdio/netbird/client/ui/preferences" "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/client/ui/updater" "github.com/netbirdio/netbird/util" ) @@ -58,8 +59,7 @@ func (s *stringList) Set(v string) error { func init() { application.RegisterEvent[services.Status](services.EventStatus) application.RegisterEvent[services.SystemEvent](services.EventSystem) - application.RegisterEvent[services.UpdateAvailable](services.EventUpdateAvailable) - application.RegisterEvent[services.UpdateProgress](services.EventUpdateProgress) + application.RegisterEvent[updater.State](updater.EventStateChanged) application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged) } @@ -123,8 +123,12 @@ func main() { connection := services.NewConnection(conn) settings := services.NewSettings(conn) profiles := services.NewProfiles(conn) - peers := services.NewPeers(conn, app.Event) - update := services.NewUpdate(conn) + // updater.Holder owns the typed update State. Peers feeds the daemon + // SubscribeEvents stream into it; the Update service is a thin + // Wails-bound facade over the holder plus the install RPCs. + updaterHolder := updater.NewHolder(app.Event) + update := services.NewUpdate(conn, updaterHolder) + peers := services.NewPeers(conn, app.Event, updaterHolder) notifier := notifications.New() profileSwitcher := services.NewProfileSwitcher(profiles, connection, peers) diff --git a/client/ui/services/peers.go b/client/ui/services/peers.go index bba67502b..cbc7c35a7 100644 --- a/client/ui/services/peers.go +++ b/client/ui/services/peers.go @@ -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(), diff --git a/client/ui/services/update.go b/client/ui/services/update.go index 6e66e1112..a99617e9b 100644 --- a/client/ui/services/update.go +++ b/client/ui/services/update.go @@ -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 diff --git a/client/ui/tray.go b/client/ui/tray.go index 80be67e4a..53b0bde7e 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -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) || diff --git a/client/ui/tray_update.go b/client/ui/tray_update.go new file mode 100644 index 000000000..a4860683e --- /dev/null +++ b/client/ui/tray_update.go @@ -0,0 +1,212 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "context" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/netbirdio/netbird/client/ui/services" + "github.com/netbirdio/netbird/client/ui/updater" +) + +// trayUpdater owns every piece of tray UI that reacts to the auto-update +// feature: the "Download latest / Install version X" menu item, the +// EventUpdateState subscription, the click that either opens the GitHub +// releases page or triggers the in-window installer flow, the OS +// notification for a freshly announced version, and the bring-window-forward +// call when the daemon enters force-install. Composed inside Tray; never +// used standalone. +type trayUpdater struct { + app *application.App + window *application.WebviewWindow + update *services.Update + notifier *notifications.NotificationService + loc *Localizer + // onIconChange is invoked whenever the "update available" flag + // transitions, so the tray can repaint its icon (the small badge + // overlay differs between has-update / no-update). + onIconChange func() + + mu sync.Mutex + item *application.MenuItem + state updater.State + notifiedVersion string // last version we surfaced as an OS notification + progressWindowOpen bool // last installing value we acted on +} + +func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func()) *trayUpdater { + u := &trayUpdater{ + app: app, + window: window, + update: update, + notifier: notifier, + loc: loc, + onIconChange: onIconChange, + } + app.Event.On(updater.EventStateChanged, u.onStateEvent) + // Seed from the cached state so we don't miss an event that fired + // before NewTray finished wiring (Peers.Watch starts after tray + // construction today, but treat that as an implementation detail). + u.state = update.GetState() + return u +} + +// attach (re)binds the menu item the tray builds for us. Called every time +// Tray.buildMenu runs — initial menu construction and language switches. +// The menu item's OnClick handler is owned by the caller; this method only +// configures label and visibility from the cached state. +func (u *trayUpdater) attach(item *application.MenuItem) { + u.mu.Lock() + u.item = item + state := u.state + u.mu.Unlock() + u.refreshMenuItem(state) +} + +// hasUpdate reports whether the tray should paint the "update available" +// icon variant. Read by Tray.iconForState during applyIcon. +func (u *trayUpdater) hasUpdate() bool { + u.mu.Lock() + defer u.mu.Unlock() + return u.state.Available +} + +// applyLanguage re-renders the menu item label from the cached state, used +// after Tray.applyLanguage rebuilds the menu with a fresh locale. +func (u *trayUpdater) applyLanguage() { + u.mu.Lock() + state := u.state + u.mu.Unlock() + u.refreshMenuItem(state) +} + +// handleClick runs when the user clicks the tray update entry. Branch 1 +// (Enforced=false) opens the GitHub releases page in the browser; Branch 2 +// (Enforced=true) surfaces the in-window /update progress page and asks +// the daemon to start the installer. +func (u *trayUpdater) handleClick() { + u.mu.Lock() + state := u.state + u.mu.Unlock() + + if !state.Enforced { + _ = u.app.Browser.OpenURL(urlGitHubReleases) + return + } + + u.openProgressWindow(state.Version) + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := u.update.Trigger(ctx); err != nil { + log.Errorf("trigger update: %v", err) + } + }() +} + +func (u *trayUpdater) onStateEvent(ev *application.CustomEvent) { + st, ok := ev.Data.(updater.State) + if !ok { + log.Warnf("update state event payload not UpdateState: %T", ev.Data) + return + } + u.applyState(st) +} + +// applyState diffs the incoming UpdateState against the cached copy and +// drives every side effect: icon repaint, menu label/visibility, OS +// notification on a newly-announced version, /update window on install +// entry. +func (u *trayUpdater) applyState(st updater.State) { + u.mu.Lock() + prev := u.state + u.state = st + + sendNotify := st.Available && st.Version != "" && st.Version != u.notifiedVersion + if sendNotify { + u.notifiedVersion = st.Version + } + + showWindow := st.Installing && !u.progressWindowOpen + if showWindow { + u.progressWindowOpen = true + } else if !st.Installing { + u.progressWindowOpen = false + } + u.mu.Unlock() + + u.refreshMenuItem(st) + if prev.Available != st.Available && u.onIconChange != nil { + u.onIconChange() + } + if sendNotify { + u.sendUpdateNotification(st) + } + if showWindow { + u.openProgressWindow(st.Version) + } +} + +// refreshMenuItem updates the menu item's label and visibility from the +// given state. Called from applyState (event-driven), attach (menu rebuild) +// and applyLanguage (locale switch) — all three converge on the same shape. +func (u *trayUpdater) refreshMenuItem(st updater.State) { + u.mu.Lock() + item := u.item + u.mu.Unlock() + if item == nil { + return + } + + if !st.Available { + item.SetHidden(true) + return + } + if st.Enforced { + item.SetLabel(u.loc.T("tray.menu.installVersion", "version", st.Version)) + } else { + item.SetLabel(u.loc.T("tray.menu.downloadLatest")) + } + item.SetHidden(false) +} + +func (u *trayUpdater) sendUpdateNotification(st updater.State) { + if u.notifier == nil { + return + } + body := u.loc.T("notify.update.body", "version", st.Version) + if st.Enforced { + body += u.loc.T("notify.update.enforcedSuffix") + } + if err := u.notifier.SendNotification(notifications.NotificationOptions{ + ID: notifyIDUpdatePrefix + st.Version, + Title: u.loc.T("notify.update.title"), + Body: body, + }); err != nil { + log.Debugf("send update notification: %v", err) + } +} + +// openProgressWindow points the main window at the /update progress page +// and brings it forward. Used both when the user clicks an enforced-update +// menu entry (Branch 2) and when the daemon flips Installing to true on +// its own (Branch 3, force install). +func (u *trayUpdater) openProgressWindow(version string) { + if u.window == nil { + return + } + url := "/#/update" + if version != "" { + url += "?version=" + version + } + u.window.SetURL(url) + u.window.Show() + u.window.Focus() +} diff --git a/client/ui/updater/state.go b/client/ui/updater/state.go new file mode 100644 index 000000000..66a70cc08 --- /dev/null +++ b/client/ui/updater/state.go @@ -0,0 +1,126 @@ +//go:build !android && !ios && !freebsd && !js + +// Package updater carries the auto-update domain: the typed State the UI +// renders, the daemon-SystemEvent metadata schema, and the Holder that +// caches the latest state and broadcasts changes. Mirrors the layout of +// client/ui/i18n and client/ui/preferences — no Wails dependency, just an +// optional Emitter interface so callers can pass either the Wails event +// processor or a fake in tests. +package updater + +import ( + "sync" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/proto" +) + +// EventStateChanged is the single Wails event the frontend and tray +// subscribe to. The payload is the full State snapshot, so consumers +// never need to combine multiple events to know what to render. +const EventStateChanged = "netbird:update:state" + +// State is the typed snapshot of the daemon's update situation, covering +// the three branches the UI cares about: +// +// - Disabled / opt-in: Available=true, Enforced=false, Installing=false. +// Tray shows "Download latest", frontend shows a "Get installer" hint +// pointing at GitHub. +// - Enforced, user-driven: Available=true, Enforced=true, Installing=false. +// Tray shows "Install version X", frontend shows the install banner. +// - Forced, daemon already installing: Available=true, Enforced=true, +// Installing=true. Both surfaces show the install-in-progress UI. +// +// Installing is driven only by the daemon's progress_window:show event; +// a UI-side Update.Trigger() does not flip it. The frontend tracks its own +// "Trigger() in flight" state for the enforced flow. +type State struct { + Available bool `json:"available"` + Version string `json:"version"` + Enforced bool `json:"enforced"` + Installing bool `json:"installing"` +} + +// Emitter is the dependency Holder needs to broadcast changes. The Wails +// app.Event processor satisfies this; tests pass nil or a fake. Same shape +// the preferences package uses, intentionally — both are "broadcast to the +// frontend" hooks with no other contract. +type Emitter interface { + Emit(name string, data ...any) bool +} + +// Holder caches the latest update State and broadcasts changes. Fed by +// services.Peers, which forwards every daemon SystemEvent here via +// OnSystemEvent. The state is read by the Wails-bound services.Update +// facade (Get) and pushed to subscribers via the Emitter. +type Holder struct { + emitter Emitter + + mu sync.Mutex + state State +} + +// NewHolder constructs an empty-state Holder. The emitter is optional — +// pass nil in tests to skip the broadcast. +func NewHolder(emitter Emitter) *Holder { + return &Holder{emitter: emitter} +} + +// Get returns a copy of the cached State. Used by the Wails facade so the +// frontend can pull the current value on mount before its push subscription +// has anything to deliver. +func (h *Holder) Get() State { + h.mu.Lock() + defer h.mu.Unlock() + return h.state +} + +// OnSystemEvent inspects the daemon's SystemEvent metadata for the three +// update-related keys (new_version_available, enforced, progress_window +// plus version) and folds the result into the cached state. Emits +// EventStateChanged only when the state actually changed, so subscribers +// do not see redundant pushes when the daemon repeats a snapshot. +// +// The metadata schema is owned here and nowhere else — neither Peers nor +// the tray nor the frontend reaches into ev.Metadata directly. +func (h *Holder) OnSystemEvent(ev *proto.SystemEvent) { + md := ev.GetMetadata() + if len(md) == 0 { + return + } + + h.mu.Lock() + changed := false + if v, ok := md["new_version_available"]; ok { + _, enforced := md["enforced"] + if !h.state.Available || h.state.Version != v || h.state.Enforced != enforced { + h.state.Available = true + h.state.Version = v + h.state.Enforced = enforced + changed = true + } + } + if md["progress_window"] == "show" { + if !h.state.Installing { + h.state.Installing = true + changed = true + } + if v, ok := md["version"]; ok && v != "" && h.state.Version != v { + h.state.Version = v + h.state.Available = true + changed = true + } + } + snap := h.state + h.mu.Unlock() + + if !changed { + return + } + log.Infof("update state: available=%v version=%q enforced=%v installing=%v", + snap.Available, snap.Version, snap.Enforced, snap.Installing) + if h.emitter != nil { + h.emitter.Emit(EventStateChanged, snap) + } +}