[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

@@ -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}
/>

View File

@@ -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>

View File

@@ -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>
);
}