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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user