Add update install progress feedback and collector metrics line
This commit is contained in:
parent
cb1e4bb0c1
commit
508977e70b
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user