From 5b70989e3eb32c982fa831417dddc420db3dafc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Thu, 7 May 2026 10:35:18 +0200 Subject: [PATCH] [client/ui-wails] Make /update page faithful to the legacy auto-update dialog Adds the missing info line ("Your client version is older than the auto-update version set in Management. Updating client to: .") and replaces the spinner with the legacy 1-second dot animation (Updating./.../...). Terminal-state wording now matches the Fyne UI exactly: 15 min timeout, canceled, and "Update failed: ". Ports mapInstallError from client/ui/update.go so daemon errors that embed "deadline exceeded" / "canceled" hit the right branch instead of falling through as a generic failure. Detects the daemon dropping mid-upgrade (the legacy success signal): if GetInstallerResult fails for 5s straight, call the new Update.Quit service method to exit, mirroring app.Quit() in showInstallerResult. --- .../client/ui-wails/services/update.ts | 11 ++ client/ui-wails/frontend/src/pages/Update.tsx | 136 ++++++++++++++---- client/ui-wails/services/update.go | 15 ++ 3 files changed, 131 insertions(+), 31 deletions(-) diff --git a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts index 2f4a04289..728d3aad0 100644 --- a/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts +++ b/client/ui-wails/frontend/bindings/github.com/netbirdio/netbird/client/ui-wails/services/update.ts @@ -20,6 +20,17 @@ export function GetInstallerResult(): $CancellablePromise<$models.UpdateResult> }); } +/** + * Quit asks the host application to exit. The /update page calls this once + * the daemon-side installer has reported success, mirroring the legacy + * Fyne UI's app.Quit() in showInstallerResult. Schedules the actual exit + * off the calling goroutine so the JS-side caller's response can return + * before the runtime tears down. + */ +export function Quit(): $CancellablePromise { + return $Call.ByID(27817640); +} + export function Trigger(): $CancellablePromise<$models.UpdateResult> { return $Call.ByID(2415339649).then(($result: any) => { return $$createType0($result); diff --git a/client/ui-wails/frontend/src/pages/Update.tsx b/client/ui-wails/frontend/src/pages/Update.tsx index 04d9eb245..6f5c55056 100644 --- a/client/ui-wails/frontend/src/pages/Update.tsx +++ b/client/ui-wails/frontend/src/pages/Update.tsx @@ -1,61 +1,135 @@ -import { useEffect, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { Update as UpdateSvc } from "../../bindings/github.com/netbirdio/netbird/client/ui-wails/services"; const TIMEOUT_MS = 15 * 60 * 1000; +const POLL_INTERVAL_MS = 2000; +// How long the daemon is allowed to be unreachable before we treat it as +// "daemon went down for the upgrade, treat as success and quit". Mirrors +// the legacy Fyne UI's branch in client/ui/update.go where a connection +// failure during polling is taken as the success signal. +const DAEMON_DOWN_GRACE_MS = 5000; + +type Phase = + | { kind: "running"; dots: number } + | { kind: "timeout" } + | { kind: "canceled" } + | { kind: "failed"; message: string }; export default function Update() { - const [done, setDone] = useState(false); - const [error, setError] = useState(null); + const [phase, setPhase] = useState({ kind: "running", dots: 1 }); + const phaseRef = useRef(phase); + phaseRef.current = phase; + + const version = new URLSearchParams( + window.location.hash.split("?")[1] ?? "", + ).get("version"); useEffect(() => { let cancelled = false; - UpdateSvc.Trigger().catch((e) => !cancelled && setError(String(e))); - const start = Date.now(); - const timer = setInterval(async () => { + let firstUnreachableAt: number | null = null; + + UpdateSvc.Trigger().catch(() => { + // The daemon may already be down (installer launched, daemon shutting + // down). Don't treat as failure here; the poll loop's daemon-down + // detection handles it. + }); + + const dotTimer = setInterval(() => { + if (cancelled) return; + setPhase((p) => + p.kind === "running" ? { kind: "running", dots: (p.dots % 3) + 1 } : p, + ); + }, 1000); + + const pollTimer = setInterval(async () => { + if (cancelled) return; + if (phaseRef.current.kind !== "running") return; + if (Date.now() - start > TIMEOUT_MS) { - setError("Update timed out."); - clearInterval(timer); + clearInterval(pollTimer); + clearInterval(dotTimer); + setPhase({ kind: "timeout" }); return; } + try { const r = await UpdateSvc.GetInstallerResult(); + firstUnreachableAt = null; if (r.success) { - setDone(true); - clearInterval(timer); - } else if (r.errorMsg) { - setError(r.errorMsg); - clearInterval(timer); + clearInterval(pollTimer); + clearInterval(dotTimer); + UpdateSvc.Quit(); + return; + } + if (r.errorMsg) { + clearInterval(pollTimer); + clearInterval(dotTimer); + setPhase(mapInstallError(r.errorMsg)); } } catch { - // installer not finished yet + // RPC failed. The daemon often goes away mid-upgrade — treat a + // sustained outage as success and quit, matching the legacy UI. + const now = Date.now(); + if (firstUnreachableAt === null) { + firstUnreachableAt = now; + } else if (now - firstUnreachableAt >= DAEMON_DOWN_GRACE_MS) { + clearInterval(pollTimer); + clearInterval(dotTimer); + UpdateSvc.Quit(); + } } - }, 2000); + }, POLL_INTERVAL_MS); return () => { cancelled = true; - clearInterval(timer); + clearInterval(dotTimer); + clearInterval(pollTimer); }; }, []); + const versionLine = version + ? `Updating client to: ${version}.` + : "Updating client."; + return (
-
- {done ? ( -

Update complete

- ) : error ? ( -

{error}

- ) : ( - <> - -

Updating…

-

- Please don't close this window. -

- - )} +
+

+ {`Your client version is older than the auto-update version set in Management.\n${versionLine}`} +

+

{statusText(phase)}

); } + +function statusText(p: Phase): string { + switch (p.kind) { + case "running": + return "Updating" + ".".repeat(p.dots); + case "timeout": + return "Update timed out. Please try again."; + case "canceled": + return "Update canceled."; + case "failed": + return "Update failed: " + p.message; + } +} + +// Mirrors mapInstallError in client/ui/update.go. The daemon's installer +// surfaces error strings rather than typed errors, so the UI sniffs the +// message to decide whether to show the timeout/canceled wording. +function mapInstallError(msg: string): Phase { + const m = msg.trim().toLowerCase(); + if (m === "") { + return { kind: "failed", message: "unknown update error" }; + } + if (m.includes("deadline exceeded") || m.includes("timeout")) { + return { kind: "timeout" }; + } + if (m.includes("canceled") || m.includes("cancelled")) { + return { kind: "canceled" }; + } + return { kind: "failed", message: msg }; +} diff --git a/client/ui-wails/services/update.go b/client/ui-wails/services/update.go index e7f9ad9c9..6e66e1112 100644 --- a/client/ui-wails/services/update.go +++ b/client/ui-wails/services/update.go @@ -4,6 +4,9 @@ package services import ( "context" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" "github.com/netbirdio/netbird/client/proto" ) @@ -24,6 +27,18 @@ func NewUpdate(conn DaemonConn) *Update { return &Update{conn: conn} } +// Quit asks the host application to exit. The /update page calls this once +// the daemon-side installer has reported success, mirroring the legacy +// Fyne UI's app.Quit() in showInstallerResult. Schedules the actual exit +// off the calling goroutine so the JS-side caller's response can return +// before the runtime tears down. +func (s *Update) Quit() { + go func() { + time.Sleep(100 * time.Millisecond) + application.Get().Quit() + }() +} + func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) { cli, err := s.conn.Client() if err != nil {