From ca91d28561e5edccb15092e5c9db68a8a9248800 Mon Sep 17 00:00:00 2001 From: J3vb <192430104+J3vb@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:19:42 +0200 Subject: [PATCH] feat(updater): surface download progress in the update banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust download callback was a no-op, so "Downloading update…" looked hung for large binaries (settings-and-admin.md §5). download_and_install_update now accumulates received bytes and emits an `update-progress` event ({ received, total }) to the webview. downloadAndInstallUpdate(serverUrl, onProgress) listens for it and UpdateNotifier renders a percentage when the total is known, falling back to bytes (MB) until Content-Length arrives. Rust change is minimal and CI-gated only (not built locally per policy). Adds TS tests for the formatter and the banner wiring. Co-Authored-By: Claude Fable 5 --- .../src-tauri/src/update_commands.rs | 28 ++++- .../src/components/UpdateNotifier.ts | 24 +++- Client/tauri-client/src/lib/updater.ts | 30 ++++- .../tests/unit/update-notifier.test.ts | 107 ++++++++++++++++++ docs/architecture/ux/settings-and-admin.md | 13 ++- 5 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 Client/tauri-client/tests/unit/update-notifier.test.ts diff --git a/Client/tauri-client/src-tauri/src/update_commands.rs b/Client/tauri-client/src-tauri/src/update_commands.rs index 932dd325..5d7904d2 100644 --- a/Client/tauri-client/src-tauri/src/update_commands.rs +++ b/Client/tauri-client/src-tauri/src/update_commands.rs @@ -1,6 +1,6 @@ use std::sync::Arc; use serde::Serialize; -use tauri::AppHandle; +use tauri::{AppHandle, Emitter}; use tauri_plugin_updater::UpdaterExt; use crate::livekit_proxy::{cert_store_key, load_stored_fingerprint, PinnedVerifier}; @@ -12,6 +12,15 @@ pub struct UpdateCheckResult { pub body: Option, } +/// Download progress, emitted to the webview as `update-progress` so the banner +/// can show a percentage/bytes instead of looking hung. `total` is None until +/// the server sends a Content-Length. +#[derive(Clone, Serialize)] +struct DownloadProgress { + received: u64, + total: Option, +} + /// Extract the host (with port if non-443) from an https:// URL for cert store lookup. fn extract_host_for_cert_store(server_url: &str) -> Result { let parsed = url::Url::parse(server_url) @@ -174,9 +183,20 @@ pub async fn download_and_install_update( match update { Some(u) => { - u.download_and_install(|_chunk_len, _total| {}, || {}) - .await - .map_err(|e| format!("download/install failed: {e}"))?; + // Accumulate downloaded bytes and emit progress to the webview. + // A failed emit must never abort the install, hence `let _ =`. + let progress_app = app.clone(); + let mut received: u64 = 0; + u.download_and_install( + move |chunk_len, total| { + received += chunk_len as u64; + let _ = + progress_app.emit("update-progress", DownloadProgress { received, total }); + }, + || {}, + ) + .await + .map_err(|e| format!("download/install failed: {e}"))?; Ok(()) } None => Err("no update available".into()), diff --git a/Client/tauri-client/src/components/UpdateNotifier.ts b/Client/tauri-client/src/components/UpdateNotifier.ts index 0fad5058..85b667cf 100644 --- a/Client/tauri-client/src/components/UpdateNotifier.ts +++ b/Client/tauri-client/src/components/UpdateNotifier.ts @@ -4,6 +4,7 @@ import { createElement, appendChildren } from "@lib/dom"; import { createLogger } from "@lib/logger"; import { checkForUpdate, downloadAndInstallUpdate } from "@lib/updater"; +import type { DownloadProgress } from "@lib/updater"; import type { MountableComponent } from "@lib/safe-render"; const log = createLogger("update-notifier"); @@ -12,6 +13,19 @@ export interface UpdateNotifierOptions { readonly serverUrl: string; } +/** + * Human-readable download status. Shows a percentage when the total size is + * known, otherwise the bytes received so the banner never looks hung. + */ +export function formatDownloadProgress(p: DownloadProgress): string { + if (p.total !== null && p.total > 0) { + const pct = Math.min(100, Math.max(0, Math.round((p.received / p.total) * 100))); + return `Downloading update… ${pct}%`; + } + const mb = (p.received / (1024 * 1024)).toFixed(1); + return `Downloading update… ${mb} MB`; +} + export function createUpdateNotifier(options: UpdateNotifierOptions): MountableComponent { const { serverUrl } = options; let container: Element | null = null; @@ -66,15 +80,13 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC // Replace banner content with progress indicator while (banner.firstChild) banner.removeChild(banner.firstChild); - const progress = createElement( - "span", - { class: "update-banner-text" }, - "Downloading update...", - ); + const progress = createElement("span", { class: "update-banner-text" }, "Downloading update…"); banner.appendChild(progress); try { - await downloadAndInstallUpdate(serverUrl); + await downloadAndInstallUpdate(serverUrl, (p) => { + progress.textContent = formatDownloadProgress(p); + }); // App will relaunch — this code won't execute after relaunch() } catch (err) { log.error("Update install failed", { error: String(err) }); diff --git a/Client/tauri-client/src/lib/updater.ts b/Client/tauri-client/src/lib/updater.ts index 17c46f64..8cc8a262 100644 --- a/Client/tauri-client/src/lib/updater.ts +++ b/Client/tauri-client/src/lib/updater.ts @@ -14,6 +14,12 @@ export interface UpdateCheckResult { readonly body: string | null; } +/** Download progress reported by the Rust updater during install. */ +export interface DownloadProgress { + readonly received: number; + readonly total: number | null; +} + /** Check if a newer client version is available on the connected server. */ export async function checkForUpdate(serverUrl: string): Promise { try { @@ -32,10 +38,28 @@ export async function checkForUpdate(serverUrl: string): Promise { +/** + * Download and install a pending update, then relaunch the app. + * `onProgress`, when given, is fed the Rust updater's `update-progress` events + * so the caller can show download progress instead of a hung spinner. + */ +export async function downloadAndInstallUpdate( + serverUrl: string, + onProgress?: (progress: DownloadProgress) => void, +): Promise { log.info("Downloading and installing update..."); - await invoke("download_and_install_update", { serverUrl }); + let unlisten: (() => void) | undefined; + if (onProgress !== undefined) { + const { listen } = await import("@tauri-apps/api/event"); + unlisten = await listen("update-progress", (event) => { + onProgress({ received: event.payload.received, total: event.payload.total ?? null }); + }); + } + try { + await invoke("download_and_install_update", { serverUrl }); + } finally { + unlisten?.(); + } log.info("Update installed, relaunching..."); await relaunch(); } diff --git a/Client/tauri-client/tests/unit/update-notifier.test.ts b/Client/tauri-client/tests/unit/update-notifier.test.ts new file mode 100644 index 00000000..52001351 --- /dev/null +++ b/Client/tauri-client/tests/unit/update-notifier.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const { mockCheckForUpdate, mockDownloadAndInstall } = vi.hoisted(() => ({ + mockCheckForUpdate: vi.fn(), + mockDownloadAndInstall: vi.fn(), +})); + +vi.mock("@lib/logger", () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); + +vi.mock("@lib/updater", () => ({ + checkForUpdate: mockCheckForUpdate, + downloadAndInstallUpdate: mockDownloadAndInstall, +})); + +import { createUpdateNotifier, formatDownloadProgress } from "../../src/components/UpdateNotifier"; +import type { DownloadProgress } from "../../src/lib/updater"; + +// --------------------------------------------------------------------------- +// Pure formatter +// --------------------------------------------------------------------------- + +describe("formatDownloadProgress", () => { + it("shows a percentage when the total size is known", () => { + expect(formatDownloadProgress({ received: 50, total: 100 })).toBe("Downloading update… 50%"); + }); + + it("clamps the percentage to 0..100", () => { + expect(formatDownloadProgress({ received: 250, total: 100 })).toBe("Downloading update… 100%"); + }); + + it("falls back to bytes (MB) when the total is unknown", () => { + expect(formatDownloadProgress({ received: 5 * 1024 * 1024, total: null })).toBe( + "Downloading update… 5.0 MB", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Banner rendering +// --------------------------------------------------------------------------- + +describe("createUpdateNotifier download progress", () => { + let host: HTMLElement; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + host = document.createElement("div"); + document.body.appendChild(host); + }); + + afterEach(() => { + vi.useRealTimers(); + host.remove(); + }); + + async function mountWithAvailableUpdate(): Promise { + mockCheckForUpdate.mockResolvedValue({ available: true, version: "1.2.0", body: "" }); + const notifier = createUpdateNotifier({ serverUrl: "https://s.example" }); + notifier.mount(host); + await vi.advanceTimersByTimeAsync(3000); // fire the delayed check + resolve + } + + function bannerText(): string | null | undefined { + return host.querySelector(".update-banner-text")?.textContent; + } + + it("updates the banner from the updater progress callback", async () => { + let onProgress: ((p: DownloadProgress) => void) | undefined; + mockDownloadAndInstall.mockImplementation((_url: string, cb: (p: DownloadProgress) => void) => { + onProgress = cb; + return new Promise(() => {}); // never resolves — stays "downloading" + }); + + await mountWithAvailableUpdate(); + (host.querySelector(".update-banner-install") as HTMLButtonElement).click(); + + // Initial state before any progress event. + expect(bannerText()).toBe("Downloading update…"); + expect(mockDownloadAndInstall).toHaveBeenCalledWith("https://s.example", expect.any(Function)); + + onProgress!({ received: 25, total: 100 }); + expect(bannerText()).toBe("Downloading update… 25%"); + + onProgress!({ received: 2 * 1024 * 1024, total: null }); + expect(bannerText()).toBe("Downloading update… 2.0 MB"); + }); + + it("shows a failure message when the download rejects", async () => { + mockDownloadAndInstall.mockRejectedValue(new Error("boom")); + + await mountWithAvailableUpdate(); + (host.querySelector(".update-banner-install") as HTMLButtonElement).click(); + + // Let the rejected install promise settle and the catch run. + await Promise.resolve(); + await Promise.resolve(); + + expect(bannerText()).toBe("Update failed. Please try again later."); + }); +}); diff --git a/docs/architecture/ux/settings-and-admin.md b/docs/architecture/ux/settings-and-admin.md index 27d413ae..4d18cc1a 100644 --- a/docs/architecture/ux/settings-and-admin.md +++ b/docs/architecture/ux/settings-and-admin.md @@ -171,14 +171,17 @@ sequenceDiagram |-------|--------------| | checking | Silent (no UI until a result) | | available | Non-modal banner with version + Update Now / Later (already `UpdateNotifier.ts:30-62`) | -| downloading | Banner "Downloading update…" | +| downloading | Banner "Downloading update… N%" (or "… N.N MB" until Content-Length is known) | | applied | App relaunches automatically | | failed | "Update failed. Please try again later." + Dismiss | -> **⚠ Current gap — no download progress.** The download callback is a no-op -> (`update_commands.rs:177`), so "Downloading update…" has no percentage. For a -> large binary this looks hung. Target: surface a progress indicator (percentage -> or indeterminate-with-bytes) by wiring the plugin's progress callback. +> **✅ Wired — download progress.** The Rust download callback +> (`download_and_install_update` in `update_commands.rs`) accumulates received +> bytes and emits an `update-progress` event (`{ received, total }`) to the +> webview. `downloadAndInstallUpdate(serverUrl, onProgress)` (`updater.ts`) listens +> for it and forwards to `UpdateNotifier`, whose `formatDownloadProgress` renders a +> percentage when `total` is known and falls back to bytes (MB) otherwise, so the +> banner never looks hung. (Rust change is minimal and CI-gated only.) ---