Add update install progress feedback and collector metrics line

This commit is contained in:
Sucukdeluxe 2026-03-01 03:33:18 +01:00
parent cb1e4bb0c1
commit 508977e70b
10 changed files with 317 additions and 37 deletions

View File

@ -9,6 +9,7 @@ import {
StartConflictResolutionResult,
UiSnapshot,
UpdateCheckResult,
UpdateInstallProgress,
UpdateInstallResult
} from "../shared/types";
import { importDlcContainers } from "./container";
@ -139,12 +140,12 @@ export class AppController {
return result;
}
public async installUpdate(): Promise<UpdateInstallResult> {
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
? this.lastUpdateCheck
: undefined;
const result = await installLatestUpdate(this.settings.updateRepo, cached);
const result = await installLatestUpdate(this.settings.updateRepo, cached, onProgress);
if (result.started) {
this.lastUpdateCheck = null;
this.lastUpdateCheckAt = 0;

View File

@ -1,6 +1,6 @@
import path from "node:path";
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
import { AddLinksPayload, AppSettings } from "../shared/types";
import { AddLinksPayload, AppSettings, UpdateInstallProgress } from "../shared/types";
import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc";
import { logger } from "./logger";
@ -224,7 +224,12 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
ipcMain.handle(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates());
ipcMain.handle(IPC_CHANNELS.INSTALL_UPDATE, async () => {
const result = await controller.installUpdate();
const result = await controller.installUpdate((progress: UpdateInstallProgress) => {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, progress);
});
if (result.started) {
setTimeout(() => {
app.quit();

View File

@ -7,8 +7,8 @@ import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { ReadableStream as NodeReadableStream } from "node:stream/web";
import { APP_VERSION, DEFAULT_UPDATE_REPO } from "./constants";
import { UpdateCheckResult, UpdateInstallResult } from "../shared/types";
import { compactErrorText } from "./utils";
import { UpdateCheckResult, UpdateInstallProgress, UpdateInstallResult } from "../shared/types";
import { compactErrorText, humanSize } from "./utils";
import { logger } from "./logger";
const RELEASE_FETCH_TIMEOUT_MS = 12000;
@ -28,6 +28,19 @@ type ReleaseAsset = {
digest: string;
};
type UpdateProgressCallback = (progress: UpdateInstallProgress) => void;
function safeEmitProgress(onProgress: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
if (!onProgress) {
return;
}
try {
onProgress(progress);
} catch {
// ignore renderer callback errors
}
}
export function normalizeUpdateRepo(repo: string): string {
const raw = String(repo || "").trim();
if (!raw) {
@ -401,7 +414,7 @@ export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult
}
}
async function downloadFile(url: string, targetPath: string): Promise<void> {
async function downloadFile(url: string, targetPath: string, onProgress?: UpdateProgressCallback): Promise<void> {
const shutdownSignal = activeUpdateAbortController?.signal;
if (shutdownSignal?.aborted) {
throw new Error("aborted:update_shutdown");
@ -424,6 +437,34 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`);
}
const totalBytesRaw = Number(response.headers.get("content-length") || NaN);
const totalBytes = Number.isFinite(totalBytesRaw) && totalBytesRaw > 0
? Math.max(0, Math.floor(totalBytesRaw))
: null;
let downloadedBytes = 0;
let lastProgressAt = 0;
const emitDownloadProgress = (force: boolean): void => {
const now = Date.now();
if (!force && now - lastProgressAt < 160) {
return;
}
lastProgressAt = now;
const percent = totalBytes && totalBytes > 0
? Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100)))
: null;
const message = totalBytes && percent !== null
? `Update wird heruntergeladen: ${percent}% (${humanSize(downloadedBytes)} / ${humanSize(totalBytes)})`
: `Update wird heruntergeladen (${humanSize(downloadedBytes)})`;
safeEmitProgress(onProgress, {
stage: "downloading",
percent,
downloadedBytes,
totalBytes,
message
});
};
emitDownloadProgress(true);
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>);
const target = fs.createWriteStream(targetPath);
@ -448,8 +489,10 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
idleTimer = setTimeout(onIdleTimeout, idleTimeoutMs);
};
const onSourceData = (): void => {
const onSourceData = (chunk: string | Buffer): void => {
downloadedBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength;
resetIdleTimer();
emitDownloadProgress(false);
};
const onSourceDone = (): void => {
clearIdleTimer();
@ -488,6 +531,7 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
target.off("close", onSourceDone);
target.off("error", onSourceDone);
}
emitDownloadProgress(true);
logger.info(`Update-Download abgeschlossen: ${targetPath}`);
}
@ -516,7 +560,7 @@ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
});
}
async function downloadWithRetries(url: string, targetPath: string): Promise<void> {
async function downloadWithRetries(url: string, targetPath: string, onProgress?: UpdateProgressCallback): Promise<void> {
const shutdownSignal = activeUpdateAbortController?.signal;
let lastError: unknown;
for (let attempt = 1; attempt <= RETRIES_PER_CANDIDATE; attempt += 1) {
@ -524,7 +568,7 @@ async function downloadWithRetries(url: string, targetPath: string): Promise<voi
throw new Error("aborted:update_shutdown");
}
try {
await downloadFile(url, targetPath);
await downloadFile(url, targetPath, onProgress);
return;
} catch (error) {
lastError = error;
@ -544,7 +588,7 @@ async function downloadWithRetries(url: string, targetPath: string): Promise<voi
throw lastError;
}
async function downloadFromCandidates(candidates: string[], targetPath: string): Promise<void> {
async function downloadFromCandidates(candidates: string[], targetPath: string, onProgress?: UpdateProgressCallback): Promise<void> {
const shutdownSignal = activeUpdateAbortController?.signal;
let lastError: unknown = new Error("Update Download fehlgeschlagen");
@ -554,8 +598,15 @@ async function downloadFromCandidates(candidates: string[], targetPath: string):
throw new Error("aborted:update_shutdown");
}
const candidate = candidates[index];
safeEmitProgress(onProgress, {
stage: "downloading",
percent: null,
downloadedBytes: 0,
totalBytes: null,
message: `Update-Download: Quelle ${index + 1}/${candidates.length}`
});
try {
await downloadWithRetries(candidate, targetPath);
await downloadWithRetries(candidate, targetPath, onProgress);
return;
} catch (error) {
lastError = error;
@ -570,8 +621,19 @@ async function downloadFromCandidates(candidates: string[], targetPath: string):
throw lastError;
}
export async function installLatestUpdate(repo: string, prechecked?: UpdateCheckResult): Promise<UpdateInstallResult> {
export async function installLatestUpdate(
repo: string,
prechecked?: UpdateCheckResult,
onProgress?: UpdateProgressCallback
): Promise<UpdateInstallResult> {
if (activeUpdateAbortController && !activeUpdateAbortController.signal.aborted) {
safeEmitProgress(onProgress, {
stage: "error",
percent: null,
downloadedBytes: 0,
totalBytes: null,
message: "Update-Download läuft bereits"
});
return { started: false, message: "Update-Download läuft bereits" };
}
const updateAbortController = new AbortController();
@ -583,9 +645,23 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
: await checkGitHubUpdate(safeRepo);
if (check.error) {
safeEmitProgress(onProgress, {
stage: "error",
percent: null,
downloadedBytes: 0,
totalBytes: null,
message: check.error
});
return { started: false, message: check.error };
}
if (!check.updateAvailable) {
safeEmitProgress(onProgress, {
stage: "error",
percent: null,
downloadedBytes: 0,
totalBytes: null,
message: "Kein neues Update verfügbar"
});
return { started: false, message: "Kein neues Update verfügbar" };
}
@ -617,14 +693,35 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${process.pid}-${crypto.randomUUID()}-${fileName}`);
try {
safeEmitProgress(onProgress, {
stage: "starting",
percent: 0,
downloadedBytes: 0,
totalBytes: null,
message: "Update wird vorbereitet"
});
if (updateAbortController.signal.aborted) {
throw new Error("aborted:update_shutdown");
}
await downloadFromCandidates(candidates, targetPath);
await downloadFromCandidates(candidates, targetPath, onProgress);
if (updateAbortController.signal.aborted) {
throw new Error("aborted:update_shutdown");
}
safeEmitProgress(onProgress, {
stage: "verifying",
percent: 100,
downloadedBytes: 0,
totalBytes: null,
message: "Prüfe Installer-Integrität"
});
await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || ""));
safeEmitProgress(onProgress, {
stage: "launching",
percent: 100,
downloadedBytes: 0,
totalBytes: null,
message: "Starte Update-Installer"
});
const child = spawn(targetPath, [], {
detached: true,
stdio: "ignore"
@ -633,6 +730,13 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
});
child.unref();
safeEmitProgress(onProgress, {
stage: "done",
percent: 100,
downloadedBytes: 0,
totalBytes: null,
message: "Update-Installer gestartet"
});
return { started: true, message: "Update-Installer gestartet" };
} catch (error) {
try {
@ -642,7 +746,15 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
}
const releaseUrl = String(effectiveCheck.releaseUrl || "").trim();
const hint = releaseUrl ? ` Manuell: ${releaseUrl}` : "";
return { started: false, message: `${compactErrorText(error)}${hint}` };
const message = `${compactErrorText(error)}${hint}`;
safeEmitProgress(onProgress, {
stage: "error",
percent: null,
downloadedBytes: 0,
totalBytes: null,
message
});
return { started: false, message };
} finally {
if (activeUpdateAbortController === updateAbortController) {
activeUpdateAbortController = null;

View File

@ -1,5 +1,14 @@
import { contextBridge, ipcRenderer } from "electron";
import { AddLinksPayload, AppSettings, DuplicatePolicy, StartConflictEntry, StartConflictResolutionResult, UiSnapshot, UpdateCheckResult } from "../shared/types";
import {
AddLinksPayload,
AppSettings,
DuplicatePolicy,
StartConflictEntry,
StartConflictResolutionResult,
UiSnapshot,
UpdateCheckResult,
UpdateInstallProgress
} from "../shared/types";
import { IPC_CHANNELS } from "../shared/ipc";
import { ElectronApi } from "../shared/preload-api";
@ -44,6 +53,13 @@ const api: ElectronApi = {
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
};
},
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => {
const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress);
ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
return () => {
ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
};
}
};

View File

@ -11,7 +11,8 @@ import type {
PackageEntry,
StartConflictEntry,
UiSnapshot,
UpdateCheckResult
UpdateCheckResult,
UpdateInstallProgress
} from "../shared/types";
type Tab = "collector" | "downloads" | "settings";
@ -149,11 +150,34 @@ function parseMbpsInput(value: string): number | null {
return parsed;
}
function formatUpdateInstallProgress(progress: UpdateInstallProgress): string {
if (progress.stage === "downloading") {
if (progress.totalBytes && progress.totalBytes > 0 && progress.percent !== null) {
return `Update-Download: ${progress.percent}% (${humanSize(progress.downloadedBytes)} / ${humanSize(progress.totalBytes)})`;
}
return `Update-Download: ${humanSize(progress.downloadedBytes)}`;
}
if (progress.stage === "starting") {
return "Update wird vorbereitet...";
}
if (progress.stage === "verifying") {
return "Download fertig | Prüfe Integrität...";
}
if (progress.stage === "launching") {
return "Starte Installer...";
}
if (progress.stage === "done") {
return "Installer gestartet";
}
return `Update-Fehler: ${progress.message}`;
}
export function App(): ReactElement {
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
const [appVersion, setAppVersion] = useState("");
const [tab, setTab] = useState<Tab>("collector");
const [statusToast, setStatusToast] = useState("");
const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null);
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
@ -266,6 +290,7 @@ export function App(): ReactElement {
useEffect(() => {
let unsubscribe: (() => void) | null = null;
let unsubClipboard: (() => void) | null = null;
let unsubUpdateInstallProgress: (() => void) | null = null;
void window.rd.getVersion().then((v) => { if (mountedRef.current) { setAppVersion(v); } }).catch(() => undefined);
void window.rd.getSnapshot().then((state) => {
if (!mountedRef.current) {
@ -327,6 +352,12 @@ export function App(): ReactElement {
return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t);
});
});
unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => {
if (!mountedRef.current) {
return;
}
setUpdateInstallProgress(progress);
});
return () => {
mountedRef.current = false;
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
@ -349,6 +380,7 @@ export function App(): ReactElement {
}
if (unsubscribe) { unsubscribe(); }
if (unsubClipboard) { unsubClipboard(); }
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
};
}, [clearImportQueueFocusListener]);
@ -535,6 +567,7 @@ export function App(): ReactElement {
return;
}
if (!result.updateAvailable) {
setUpdateInstallProgress(null);
if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); }
return;
}
@ -547,11 +580,25 @@ export function App(): ReactElement {
return;
}
if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; }
setUpdateInstallProgress({
stage: "starting",
percent: 0,
downloadedBytes: 0,
totalBytes: null,
message: "Update wird vorbereitet"
});
const install = await window.rd.installUpdate();
if (!mountedRef.current) {
return;
}
if (install.started) { showToast("Updater gestartet - App wird geschlossen", 2600); return; }
setUpdateInstallProgress({
stage: "error",
percent: null,
downloadedBytes: 0,
totalBytes: null,
message: install.message
});
showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200);
};
@ -567,6 +614,7 @@ export function App(): ReactElement {
const onCheckUpdates = async (): Promise<void> => {
await performQuickAction(async () => {
setUpdateInstallProgress(null);
const result = await window.rd.checkUpdates();
await handleUpdateResult(result, "manual");
}, (error) => {
@ -1153,13 +1201,11 @@ export function App(): ReactElement {
<div className="title-block">
<h1>Multi Debrid Downloader{appVersion ? ` - v${appVersion}` : ""}</h1>
</div>
<div className="metrics">
<div>{snapshot.speedText}</div>
<div>{snapshot.etaText}</div>
{snapshot.reconnectSeconds > 0 && (
<div className="metrics">
<div className="reconnect-badge">Reconnect: {snapshot.reconnectSeconds}s</div>
)}
</div>
)}
</header>
<section className="control-strip">
@ -1219,6 +1265,7 @@ export function App(): ReactElement {
<button className="btn accent" disabled={actionBusy} onClick={onAddLinks}>Zur Queue hinzufügen</button>
</div>
</div>
<div className="collector-metrics">{snapshot.speedText} | {snapshot.etaText}</div>
<div className="collector-tabs">
{collectorTabs.map((ct) => (
<div key={ct.id} className={`collector-tab${ct.id === activeCollectorTab ? " active" : ""}`}>
@ -1342,6 +1389,7 @@ export function App(): ReactElement {
<h3>Einstellungen</h3>
<span>Kompakt, schnell auffindbar und direkt speicherbar.</span>
</div>
<div className="settings-toolbar-actions-wrap">
<div className="settings-toolbar-actions">
<button className="btn" disabled={actionBusy} onClick={onCheckUpdates}>Updates prüfen</button>
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
@ -1356,6 +1404,12 @@ export function App(): ReactElement {
</button>
<button className="btn accent" disabled={actionBusy} onClick={onSaveSettings}>Einstellungen speichern</button>
</div>
{updateInstallProgress && (
<div className={`update-install-progress update-install-progress-${updateInstallProgress.stage}`}>
{formatUpdateInstallProgress(updateInstallProgress)}
</div>
)}
</div>
</article>
<section className="settings-grid">

View File

@ -311,6 +311,12 @@ body,
flex-wrap: wrap;
}
.collector-metrics {
color: var(--muted);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.collector-tabs {
display: flex;
align-items: center;
@ -412,6 +418,34 @@ body,
align-items: center;
}
.settings-toolbar-actions-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.update-install-progress {
color: var(--muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.update-install-progress-downloading,
.update-install-progress-verifying,
.update-install-progress-launching,
.update-install-progress-starting {
color: color-mix(in srgb, var(--accent) 75%, var(--text));
}
.update-install-progress-done {
color: color-mix(in srgb, var(--accent) 65%, var(--text));
}
.update-install-progress-error {
color: color-mix(in srgb, var(--danger) 65%, var(--text));
}
.settings-grid {
display: grid;
gap: 10px;
@ -748,6 +782,11 @@ td {
width: 100%;
}
.settings-toolbar-actions-wrap {
width: 100%;
align-items: flex-start;
}
.grid-two,
.settings-grid {
grid-template-columns: 1fr;

View File

@ -3,6 +3,7 @@ export const IPC_CHANNELS = {
GET_VERSION: "app:get-version",
CHECK_UPDATES: "app:check-updates",
INSTALL_UPDATE: "app:install-update",
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
OPEN_EXTERNAL: "app:open-external",
UPDATE_SETTINGS: "app:update-settings",
ADD_LINKS: "queue:add-links",

View File

@ -6,6 +6,7 @@ import type {
StartConflictResolutionResult,
UiSnapshot,
UpdateCheckResult,
UpdateInstallProgress,
UpdateInstallResult
} from "./types";
@ -36,4 +37,5 @@ export interface ElectronApi {
pickContainers: () => Promise<string[]>;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
}

View File

@ -212,6 +212,14 @@ export interface UpdateInstallResult {
message: string;
}
export interface UpdateInstallProgress {
stage: "starting" | "downloading" | "verifying" | "launching" | "done" | "error";
percent: number | null;
downloadedBytes: number;
totalBytes: number | null;
message: string;
}
export interface ParsedHashEntry {
fileName: string;
algorithm: "crc32" | "md5" | "sha1";

View File

@ -3,7 +3,7 @@ import crypto from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
import { APP_VERSION } from "../src/main/constants";
import { UpdateCheckResult } from "../src/shared/types";
import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types";
const originalFetch = globalThis.fetch;
@ -286,6 +286,48 @@ describe("update", () => {
expect(result.started).toBe(false);
expect(result.message).toMatch(/integrit|sha256|mismatch/i);
});
it("emits install progress events while downloading and launching update", async () => {
const executablePayload = fs.readFileSync(process.execPath);
const digest = sha256Hex(executablePayload);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("progress-setup.exe")) {
return new Response(executablePayload, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(executablePayload.length)
}
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "v9.9.9",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/progress-setup.exe",
setupAssetName: "setup.exe",
setupAssetDigest: `sha256:${digest}`
};
const progressEvents: UpdateInstallProgress[] = [];
const result = await installLatestUpdate("owner/repo", prechecked, (progress) => {
progressEvents.push(progress);
});
expect(result.started).toBe(true);
expect(progressEvents.some((entry) => entry.stage === "starting")).toBe(true);
expect(progressEvents.some((entry) => entry.stage === "downloading")).toBe(true);
expect(progressEvents.some((entry) => entry.stage === "verifying")).toBe(true);
expect(progressEvents.some((entry) => entry.stage === "launching")).toBe(true);
expect(progressEvents.some((entry) => entry.stage === "done")).toBe(true);
});
});
describe("normalizeUpdateRepo extended", () => {