mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat(updater): surface download progress in the update banner
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
/// 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<String, String> {
|
||||
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()),
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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<UpdateCheckResult> {
|
||||
try {
|
||||
@@ -32,10 +38,28 @@ export async function checkForUpdate(serverUrl: string): Promise<UpdateCheckResu
|
||||
}
|
||||
}
|
||||
|
||||
/** Download and install a pending update, then relaunch the app. */
|
||||
export async function downloadAndInstallUpdate(serverUrl: string): Promise<void> {
|
||||
/**
|
||||
* 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<void> {
|
||||
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<DownloadProgress>("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();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void>(() => {}); // 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.");
|
||||
});
|
||||
});
|
||||
@@ -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.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user