mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-16 13:49:58 +00:00
[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:
@@ -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<ClientVersionContextValue | null>(null);
|
||||
|
||||
export const useClientVersion = () => {
|
||||
@@ -53,19 +74,46 @@ export const useClientVersion = () => {
|
||||
};
|
||||
|
||||
export const ClientVersionProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { status } = useStatus();
|
||||
const [state, setState] = useState<UpdateState>(emptyState);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateError, setUpdateError] = useState<string | null>(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<UpdateState>(() => {
|
||||
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<ClientVersionContextValue>(
|
||||
() => ({
|
||||
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 (
|
||||
<ClientVersionContext.Provider value={value}>
|
||||
{children}
|
||||
<UpdateAvailableBanner />
|
||||
{(updating || updateError || FORCE_UPDATING || FORCE_ERROR) && (
|
||||
{showOverlay && (
|
||||
<UpdatingOverlay
|
||||
version={updateVersion}
|
||||
version={effective.version || null}
|
||||
error={updateError ?? forcedErrorMessage()}
|
||||
onDismiss={dismissUpdateError}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -22,14 +26,14 @@ export const UpdateAvailableBanner = () => {
|
||||
)}
|
||||
>
|
||||
<p className={"text-sm text-nb-gray-900 pr-4 pl-2 font-medium"}>
|
||||
NetBird will update when you restart the app.
|
||||
NetBird {updateVersion} is ready to install.
|
||||
</p>
|
||||
<div className={"flex gap-2"}>
|
||||
<Button variant={"subtle"} size={"xs"} onClick={() => setDismissed(true)}>
|
||||
Later
|
||||
</Button>
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
Restart now
|
||||
Install now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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?
|
||||
</Link>
|
||||
</div>
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
Restart Now
|
||||
</Button>
|
||||
{enforced ? (
|
||||
<Button variant={"primary"} size={"xs"} onClick={triggerUpdate}>
|
||||
Install now
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"xs"}
|
||||
onClick={() => openUrl(GITHUB_RELEASES)}
|
||||
>
|
||||
Get installer
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
212
client/ui/tray_update.go
Normal file
212
client/ui/tray_update.go
Normal file
@@ -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()
|
||||
}
|
||||
126
client/ui/updater/state.go
Normal file
126
client/ui/updater/state.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user