diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index e16d26e..58625f0 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -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 { + public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise { 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; diff --git a/src/main/main.ts b/src/main/main.ts index 0599275..d773a03 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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(); diff --git a/src/main/update.ts b/src/main/update.ts index 89843eb..b641f49 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -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 { +async function downloadFile(url: string, targetPath: string, onProgress?: UpdateProgressCallback): Promise { 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 { 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); const target = fs.createWriteStream(targetPath); @@ -448,8 +489,10 @@ async function downloadFile(url: string, targetPath: string): Promise { 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 { 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 { }); } -async function downloadWithRetries(url: string, targetPath: string): Promise { +async function downloadWithRetries(url: string, targetPath: string, onProgress?: UpdateProgressCallback): Promise { 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 { +async function downloadFromCandidates(candidates: string[], targetPath: string, onProgress?: UpdateProgressCallback): Promise { 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 { +export async function installLatestUpdate( + repo: string, + prechecked?: UpdateCheckResult, + onProgress?: UpdateProgressCallback +): Promise { 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; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 13a114a..6991f3c 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -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); + }; } }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1db39aa..82182de 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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(emptySnapshot); const [appVersion, setAppVersion] = useState(""); const [tab, setTab] = useState("collector"); const [statusToast, setStatusToast] = useState(""); + const [updateInstallProgress, setUpdateInstallProgress] = useState(null); const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); @@ -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 => { await performQuickAction(async () => { + setUpdateInstallProgress(null); const result = await window.rd.checkUpdates(); await handleUpdateResult(result, "manual"); }, (error) => { @@ -1153,13 +1201,11 @@ export function App(): ReactElement {

Multi Debrid Downloader{appVersion ? ` - v${appVersion}` : ""}

-
-
{snapshot.speedText}
-
{snapshot.etaText}
- {snapshot.reconnectSeconds > 0 && ( + {snapshot.reconnectSeconds > 0 && ( +
Reconnect: {snapshot.reconnectSeconds}s
- )} -
+
+ )}
@@ -1219,6 +1265,7 @@ export function App(): ReactElement { +
{snapshot.speedText} | {snapshot.etaText}
{collectorTabs.map((ct) => (
@@ -1342,19 +1389,26 @@ export function App(): ReactElement {

Einstellungen

Kompakt, schnell auffindbar und direkt speicherbar.
-
- - - +
+
+ + + +
+ {updateInstallProgress && ( +
+ {formatUpdateInstallProgress(updateInstallProgress)} +
+ )}
diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 0251cd1..284c0cc 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 4717593..3f71776 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 7273f63..3ae4b87 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -6,6 +6,7 @@ import type { StartConflictResolutionResult, UiSnapshot, UpdateCheckResult, + UpdateInstallProgress, UpdateInstallResult } from "./types"; @@ -36,4 +37,5 @@ export interface ElectronApi { pickContainers: () => Promise; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void; + onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; } diff --git a/src/shared/types.ts b/src/shared/types.ts index 203fe51..fbcc1a2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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"; diff --git a/tests/update.test.ts b/tests/update.test.ts index 4cfcd92..2ef0165 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -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 => { + 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", () => {