Add update install progress feedback and collector metrics line
This commit is contained in:
parent
cb1e4bb0c1
commit
508977e70b
@ -9,6 +9,7 @@ import {
|
|||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
|
UpdateInstallProgress,
|
||||||
UpdateInstallResult
|
UpdateInstallResult
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { importDlcContainers } from "./container";
|
import { importDlcContainers } from "./container";
|
||||||
@ -139,12 +140,12 @@ export class AppController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async installUpdate(): Promise<UpdateInstallResult> {
|
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
|
||||||
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
|
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
|
||||||
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
|
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
|
||||||
? this.lastUpdateCheck
|
? this.lastUpdateCheck
|
||||||
: undefined;
|
: undefined;
|
||||||
const result = await installLatestUpdate(this.settings.updateRepo, cached);
|
const result = await installLatestUpdate(this.settings.updateRepo, cached, onProgress);
|
||||||
if (result.started) {
|
if (result.started) {
|
||||||
this.lastUpdateCheck = null;
|
this.lastUpdateCheck = null;
|
||||||
this.lastUpdateCheckAt = 0;
|
this.lastUpdateCheckAt = 0;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
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 { AppController } from "./app-controller";
|
||||||
import { IPC_CHANNELS } from "../shared/ipc";
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
@ -224,7 +224,12 @@ function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
|
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
|
||||||
ipcMain.handle(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates());
|
ipcMain.handle(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates());
|
||||||
ipcMain.handle(IPC_CHANNELS.INSTALL_UPDATE, async () => {
|
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) {
|
if (result.started) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
app.quit();
|
app.quit();
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import { Readable } from "node:stream";
|
|||||||
import { pipeline } from "node:stream/promises";
|
import { pipeline } from "node:stream/promises";
|
||||||
import { ReadableStream as NodeReadableStream } from "node:stream/web";
|
import { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||||
import { APP_VERSION, DEFAULT_UPDATE_REPO } from "./constants";
|
import { APP_VERSION, DEFAULT_UPDATE_REPO } from "./constants";
|
||||||
import { UpdateCheckResult, UpdateInstallResult } from "../shared/types";
|
import { UpdateCheckResult, UpdateInstallProgress, UpdateInstallResult } from "../shared/types";
|
||||||
import { compactErrorText } from "./utils";
|
import { compactErrorText, humanSize } from "./utils";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
const RELEASE_FETCH_TIMEOUT_MS = 12000;
|
const RELEASE_FETCH_TIMEOUT_MS = 12000;
|
||||||
@ -28,6 +28,19 @@ type ReleaseAsset = {
|
|||||||
digest: string;
|
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 {
|
export function normalizeUpdateRepo(repo: string): string {
|
||||||
const raw = String(repo || "").trim();
|
const raw = String(repo || "").trim();
|
||||||
if (!raw) {
|
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;
|
const shutdownSignal = activeUpdateAbortController?.signal;
|
||||||
if (shutdownSignal?.aborted) {
|
if (shutdownSignal?.aborted) {
|
||||||
throw new Error("aborted:update_shutdown");
|
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})`);
|
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 });
|
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>);
|
const source = Readable.fromWeb(response.body as unknown as NodeReadableStream<Uint8Array>);
|
||||||
const target = fs.createWriteStream(targetPath);
|
const target = fs.createWriteStream(targetPath);
|
||||||
@ -448,8 +489,10 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
|
|||||||
idleTimer = setTimeout(onIdleTimeout, idleTimeoutMs);
|
idleTimer = setTimeout(onIdleTimeout, idleTimeoutMs);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSourceData = (): void => {
|
const onSourceData = (chunk: string | Buffer): void => {
|
||||||
|
downloadedBytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength;
|
||||||
resetIdleTimer();
|
resetIdleTimer();
|
||||||
|
emitDownloadProgress(false);
|
||||||
};
|
};
|
||||||
const onSourceDone = (): void => {
|
const onSourceDone = (): void => {
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
@ -488,6 +531,7 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
|
|||||||
target.off("close", onSourceDone);
|
target.off("close", onSourceDone);
|
||||||
target.off("error", onSourceDone);
|
target.off("error", onSourceDone);
|
||||||
}
|
}
|
||||||
|
emitDownloadProgress(true);
|
||||||
logger.info(`Update-Download abgeschlossen: ${targetPath}`);
|
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;
|
const shutdownSignal = activeUpdateAbortController?.signal;
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (let attempt = 1; attempt <= RETRIES_PER_CANDIDATE; attempt += 1) {
|
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");
|
throw new Error("aborted:update_shutdown");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await downloadFile(url, targetPath);
|
await downloadFile(url, targetPath, onProgress);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
@ -544,7 +588,7 @@ async function downloadWithRetries(url: string, targetPath: string): Promise<voi
|
|||||||
throw lastError;
|
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;
|
const shutdownSignal = activeUpdateAbortController?.signal;
|
||||||
let lastError: unknown = new Error("Update Download fehlgeschlagen");
|
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");
|
throw new Error("aborted:update_shutdown");
|
||||||
}
|
}
|
||||||
const candidate = candidates[index];
|
const candidate = candidates[index];
|
||||||
|
safeEmitProgress(onProgress, {
|
||||||
|
stage: "downloading",
|
||||||
|
percent: null,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
message: `Update-Download: Quelle ${index + 1}/${candidates.length}`
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await downloadWithRetries(candidate, targetPath);
|
await downloadWithRetries(candidate, targetPath, onProgress);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
@ -570,8 +621,19 @@ async function downloadFromCandidates(candidates: string[], targetPath: string):
|
|||||||
throw lastError;
|
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) {
|
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" };
|
return { started: false, message: "Update-Download läuft bereits" };
|
||||||
}
|
}
|
||||||
const updateAbortController = new AbortController();
|
const updateAbortController = new AbortController();
|
||||||
@ -583,9 +645,23 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
|
|||||||
: await checkGitHubUpdate(safeRepo);
|
: await checkGitHubUpdate(safeRepo);
|
||||||
|
|
||||||
if (check.error) {
|
if (check.error) {
|
||||||
|
safeEmitProgress(onProgress, {
|
||||||
|
stage: "error",
|
||||||
|
percent: null,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
message: check.error
|
||||||
|
});
|
||||||
return { started: false, message: check.error };
|
return { started: false, message: check.error };
|
||||||
}
|
}
|
||||||
if (!check.updateAvailable) {
|
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" };
|
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}`);
|
const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${process.pid}-${crypto.randomUUID()}-${fileName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
safeEmitProgress(onProgress, {
|
||||||
|
stage: "starting",
|
||||||
|
percent: 0,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
message: "Update wird vorbereitet"
|
||||||
|
});
|
||||||
if (updateAbortController.signal.aborted) {
|
if (updateAbortController.signal.aborted) {
|
||||||
throw new Error("aborted:update_shutdown");
|
throw new Error("aborted:update_shutdown");
|
||||||
}
|
}
|
||||||
await downloadFromCandidates(candidates, targetPath);
|
await downloadFromCandidates(candidates, targetPath, onProgress);
|
||||||
if (updateAbortController.signal.aborted) {
|
if (updateAbortController.signal.aborted) {
|
||||||
throw new Error("aborted:update_shutdown");
|
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 || ""));
|
await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || ""));
|
||||||
|
safeEmitProgress(onProgress, {
|
||||||
|
stage: "launching",
|
||||||
|
percent: 100,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
message: "Starte Update-Installer"
|
||||||
|
});
|
||||||
const child = spawn(targetPath, [], {
|
const child = spawn(targetPath, [], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: "ignore"
|
stdio: "ignore"
|
||||||
@ -633,6 +730,13 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
|
|||||||
logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
|
logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
|
||||||
});
|
});
|
||||||
child.unref();
|
child.unref();
|
||||||
|
safeEmitProgress(onProgress, {
|
||||||
|
stage: "done",
|
||||||
|
percent: 100,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: null,
|
||||||
|
message: "Update-Installer gestartet"
|
||||||
|
});
|
||||||
return { started: true, message: "Update-Installer gestartet" };
|
return { started: true, message: "Update-Installer gestartet" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
@ -642,7 +746,15 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
|
|||||||
}
|
}
|
||||||
const releaseUrl = String(effectiveCheck.releaseUrl || "").trim();
|
const releaseUrl = String(effectiveCheck.releaseUrl || "").trim();
|
||||||
const hint = releaseUrl ? ` – Manuell: ${releaseUrl}` : "";
|
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 {
|
} finally {
|
||||||
if (activeUpdateAbortController === updateAbortController) {
|
if (activeUpdateAbortController === updateAbortController) {
|
||||||
activeUpdateAbortController = null;
|
activeUpdateAbortController = null;
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
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 { IPC_CHANNELS } from "../shared/ipc";
|
||||||
import { ElectronApi } from "../shared/preload-api";
|
import { ElectronApi } from "../shared/preload-api";
|
||||||
|
|
||||||
@ -44,6 +53,13 @@ const api: ElectronApi = {
|
|||||||
return () => {
|
return () => {
|
||||||
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
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);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,8 @@ import type {
|
|||||||
PackageEntry,
|
PackageEntry,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
UpdateCheckResult
|
UpdateCheckResult,
|
||||||
|
UpdateInstallProgress
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
||||||
type Tab = "collector" | "downloads" | "settings";
|
type Tab = "collector" | "downloads" | "settings";
|
||||||
@ -149,11 +150,34 @@ function parseMbpsInput(value: string): number | null {
|
|||||||
return parsed;
|
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 {
|
export function App(): ReactElement {
|
||||||
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
||||||
const [appVersion, setAppVersion] = useState("");
|
const [appVersion, setAppVersion] = useState("");
|
||||||
const [tab, setTab] = useState<Tab>("collector");
|
const [tab, setTab] = useState<Tab>("collector");
|
||||||
const [statusToast, setStatusToast] = useState("");
|
const [statusToast, setStatusToast] = useState("");
|
||||||
|
const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null);
|
||||||
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
||||||
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
|
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
|
||||||
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
||||||
@ -266,6 +290,7 @@ export function App(): ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
let unsubClipboard: (() => 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.getVersion().then((v) => { if (mountedRef.current) { setAppVersion(v); } }).catch(() => undefined);
|
||||||
void window.rd.getSnapshot().then((state) => {
|
void window.rd.getSnapshot().then((state) => {
|
||||||
if (!mountedRef.current) {
|
if (!mountedRef.current) {
|
||||||
@ -327,6 +352,12 @@ export function App(): ReactElement {
|
|||||||
return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t);
|
return prev.map((t) => t.id === active.id ? { ...t, text: newText } : t);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
unsubUpdateInstallProgress = window.rd.onUpdateInstallProgress((progress) => {
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUpdateInstallProgress(progress);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
|
if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); }
|
||||||
@ -349,6 +380,7 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
if (unsubscribe) { unsubscribe(); }
|
if (unsubscribe) { unsubscribe(); }
|
||||||
if (unsubClipboard) { unsubClipboard(); }
|
if (unsubClipboard) { unsubClipboard(); }
|
||||||
|
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
|
||||||
};
|
};
|
||||||
}, [clearImportQueueFocusListener]);
|
}, [clearImportQueueFocusListener]);
|
||||||
|
|
||||||
@ -535,6 +567,7 @@ export function App(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!result.updateAvailable) {
|
if (!result.updateAvailable) {
|
||||||
|
setUpdateInstallProgress(null);
|
||||||
if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); }
|
if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -547,11 +580,25 @@ export function App(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); 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();
|
const install = await window.rd.installUpdate();
|
||||||
if (!mountedRef.current) {
|
if (!mountedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (install.started) { showToast("Updater gestartet - App wird geschlossen", 2600); 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);
|
showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -567,6 +614,7 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const onCheckUpdates = async (): Promise<void> => {
|
const onCheckUpdates = async (): Promise<void> => {
|
||||||
await performQuickAction(async () => {
|
await performQuickAction(async () => {
|
||||||
|
setUpdateInstallProgress(null);
|
||||||
const result = await window.rd.checkUpdates();
|
const result = await window.rd.checkUpdates();
|
||||||
await handleUpdateResult(result, "manual");
|
await handleUpdateResult(result, "manual");
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
@ -1153,13 +1201,11 @@ export function App(): ReactElement {
|
|||||||
<div className="title-block">
|
<div className="title-block">
|
||||||
<h1>Multi Debrid Downloader{appVersion ? ` - v${appVersion}` : ""}</h1>
|
<h1>Multi Debrid Downloader{appVersion ? ` - v${appVersion}` : ""}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="metrics">
|
{snapshot.reconnectSeconds > 0 && (
|
||||||
<div>{snapshot.speedText}</div>
|
<div className="metrics">
|
||||||
<div>{snapshot.etaText}</div>
|
|
||||||
{snapshot.reconnectSeconds > 0 && (
|
|
||||||
<div className="reconnect-badge">Reconnect: {snapshot.reconnectSeconds}s</div>
|
<div className="reconnect-badge">Reconnect: {snapshot.reconnectSeconds}s</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="control-strip">
|
<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>
|
<button className="btn accent" disabled={actionBusy} onClick={onAddLinks}>Zur Queue hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="collector-metrics">{snapshot.speedText} | {snapshot.etaText}</div>
|
||||||
<div className="collector-tabs">
|
<div className="collector-tabs">
|
||||||
{collectorTabs.map((ct) => (
|
{collectorTabs.map((ct) => (
|
||||||
<div key={ct.id} className={`collector-tab${ct.id === activeCollectorTab ? " active" : ""}`}>
|
<div key={ct.id} className={`collector-tab${ct.id === activeCollectorTab ? " active" : ""}`}>
|
||||||
@ -1342,19 +1389,26 @@ export function App(): ReactElement {
|
|||||||
<h3>Einstellungen</h3>
|
<h3>Einstellungen</h3>
|
||||||
<span>Kompakt, schnell auffindbar und direkt speicherbar.</span>
|
<span>Kompakt, schnell auffindbar und direkt speicherbar.</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-toolbar-actions">
|
<div className="settings-toolbar-actions-wrap">
|
||||||
<button className="btn" disabled={actionBusy} onClick={onCheckUpdates}>Updates prüfen</button>
|
<div className="settings-toolbar-actions">
|
||||||
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
|
<button className="btn" disabled={actionBusy} onClick={onCheckUpdates}>Updates prüfen</button>
|
||||||
const next = settingsDraft.theme === "dark" ? "light" : "dark";
|
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
|
||||||
settingsDraftRevisionRef.current += 1;
|
const next = settingsDraft.theme === "dark" ? "light" : "dark";
|
||||||
settingsDirtyRef.current = true;
|
settingsDraftRevisionRef.current += 1;
|
||||||
setSettingsDirty(true);
|
settingsDirtyRef.current = true;
|
||||||
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
|
setSettingsDirty(true);
|
||||||
applyTheme(next as AppTheme);
|
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
|
||||||
}}>
|
applyTheme(next as AppTheme);
|
||||||
{settingsDraft.theme === "dark" ? "Light Mode" : "Dark Mode"}
|
}}>
|
||||||
</button>
|
{settingsDraft.theme === "dark" ? "Light Mode" : "Dark Mode"}
|
||||||
<button className="btn accent" disabled={actionBusy} onClick={onSaveSettings}>Einstellungen speichern</button>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@ -311,6 +311,12 @@ body,
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collector-metrics {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.collector-tabs {
|
.collector-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -412,6 +418,34 @@ body,
|
|||||||
align-items: center;
|
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 {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@ -748,6 +782,11 @@ td {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-toolbar-actions-wrap {
|
||||||
|
width: 100%;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-two,
|
.grid-two,
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export const IPC_CHANNELS = {
|
|||||||
GET_VERSION: "app:get-version",
|
GET_VERSION: "app:get-version",
|
||||||
CHECK_UPDATES: "app:check-updates",
|
CHECK_UPDATES: "app:check-updates",
|
||||||
INSTALL_UPDATE: "app:install-update",
|
INSTALL_UPDATE: "app:install-update",
|
||||||
|
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
||||||
OPEN_EXTERNAL: "app:open-external",
|
OPEN_EXTERNAL: "app:open-external",
|
||||||
UPDATE_SETTINGS: "app:update-settings",
|
UPDATE_SETTINGS: "app:update-settings",
|
||||||
ADD_LINKS: "queue:add-links",
|
ADD_LINKS: "queue:add-links",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type {
|
|||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
|
UpdateInstallProgress,
|
||||||
UpdateInstallResult
|
UpdateInstallResult
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@ -36,4 +37,5 @@ export interface ElectronApi {
|
|||||||
pickContainers: () => Promise<string[]>;
|
pickContainers: () => Promise<string[]>;
|
||||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||||
|
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -212,6 +212,14 @@ export interface UpdateInstallResult {
|
|||||||
message: string;
|
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 {
|
export interface ParsedHashEntry {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
algorithm: "crc32" | "md5" | "sha1";
|
algorithm: "crc32" | "md5" | "sha1";
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import crypto from "node:crypto";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
|
import { checkGitHubUpdate, installLatestUpdate, isRemoteNewer, normalizeUpdateRepo, parseVersionParts } from "../src/main/update";
|
||||||
import { APP_VERSION } from "../src/main/constants";
|
import { APP_VERSION } from "../src/main/constants";
|
||||||
import { UpdateCheckResult } from "../src/shared/types";
|
import { UpdateCheckResult, UpdateInstallProgress } from "../src/shared/types";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@ -286,6 +286,48 @@ describe("update", () => {
|
|||||||
expect(result.started).toBe(false);
|
expect(result.started).toBe(false);
|
||||||
expect(result.message).toMatch(/integrit|sha256|mismatch/i);
|
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", () => {
|
describe("normalizeUpdateRepo extended", () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user