diff --git a/src/big-picture/src/pages/downloads/use-big-picture-downloads-page-data.ts b/src/big-picture/src/pages/downloads/use-big-picture-downloads-page-data.ts index 155de3af99..83451e4344 100644 --- a/src/big-picture/src/pages/downloads/use-big-picture-downloads-page-data.ts +++ b/src/big-picture/src/pages/downloads/use-big-picture-downloads-page-data.ts @@ -346,6 +346,8 @@ export function useBigPictureDownloadsPageData() { pauseOrResumeAction = "resume"; } else if (isExtracting) { statusLabel = "Extracting"; + } else if (lastPacket?.isReconnecting) { + statusLabel = "Reconnecting…"; } else if (lastPacket?.isCheckingFiles) { statusLabel = "Checking files"; } else if (lastPacket?.isDownloadingMetadata) { @@ -579,6 +581,7 @@ export function useBigPictureDownloadsPageData() { const shouldZeroSpeed = activeGame.download.extracting || + lastPacket?.isReconnecting || lastPacket?.isCheckingFiles || lastPacket?.isDownloadingMetadata || lastPacket?.gameId !== activeGame.id; @@ -603,6 +606,7 @@ export function useBigPictureDownloadsPageData() { activeGame, lastPacket?.downloadSpeed, lastPacket?.gameId, + lastPacket?.isReconnecting, lastPacket?.isCheckingFiles, lastPacket?.isDownloadingMetadata, lastPacket?.numPeers, diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 7f6cc3e002..ab3a15ec2e 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -154,6 +154,7 @@ "scan_games_detection_warning": "Detection relies on a community-maintained list of known games and may be outdated, so some games might not be found." }, "bottom_panel": { + "reconnecting": "Reconnecting {{title}}…", "no_downloads_in_progress": "No downloads in progress", "downloading_metadata": "Downloading {{title}} metadata…", "downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}", @@ -681,6 +682,7 @@ "loading": "Loading…" }, "downloads": { + "reconnecting": "Reconnecting…", "resume": "Resume", "pause": "Pause", "eta": "Completion {{eta}}", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 069c5f49f8..adcf169bb4 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -145,6 +145,7 @@ "scan_games_scan_again": "Escanear nuevamente" }, "bottom_panel": { + "reconnecting": "Reconectando {{title}}…", "no_downloads_in_progress": "Sin descargas en progreso", "downloading_metadata": "Descargando metadatos de {{title}}…", "downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}", @@ -640,6 +641,7 @@ "loading": "Cargando…" }, "downloads": { + "reconnecting": "Reconectando…", "resume": "Resumir", "pause": "Pausar", "eta": "Tiempo de finalizción {{eta}}", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index e5f30a9c69..29521b2ef0 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -145,6 +145,7 @@ "scan_games_scan_again": "Escanear Novamente" }, "bottom_panel": { + "reconnecting": "Reconectando {{title}}…", "no_downloads_in_progress": "Sem downloads em andamento", "downloading_metadata": "Baixando metadados de {{title}}…", "downloading": "Baixando {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}", @@ -590,6 +591,7 @@ "loading": "Carregando…" }, "downloads": { + "reconnecting": "Reconectando…", "resume": "Retomar", "pause": "Pausar", "eta": "Conclusão {{eta}}", diff --git a/src/locales/pt-PT/translation.json b/src/locales/pt-PT/translation.json index cfd4e66e48..345d9826ca 100644 --- a/src/locales/pt-PT/translation.json +++ b/src/locales/pt-PT/translation.json @@ -125,6 +125,7 @@ "scan_games_scan_again": "Escanear Novamente" }, "bottom_panel": { + "reconnecting": "A reconectar {{title}}…", "no_downloads_in_progress": "Sem transferências em andamento", "downloading_metadata": "A transferir metadados de {{title}}…", "downloading": "A transferir {{title}}… ({{percentage}} concluído) - Conclusão {{eta}} - {{speed}}", @@ -507,6 +508,7 @@ "loading": "A carregar…" }, "downloads": { + "reconnecting": "A reconectar…", "resume": "Continuar", "pause": "Colocar em pausa", "eta": "Conclusão {{eta}}", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 3e2d4993eb..f9564db567 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -154,6 +154,7 @@ "scan_games_detection_warning": "Обнаружение основано на поддерживаемом сообществом списке известных игр и может быть устаревшим, поэтому некоторые игры могут быть не найдены." }, "bottom_panel": { + "reconnecting": "Переподключение к {{title}}…", "no_downloads_in_progress": "Нет активных загрузок", "downloading_metadata": "Загрузка метаданных {{title}}…", "downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}", @@ -682,6 +683,7 @@ "loading": "Загрузка…" }, "downloads": { + "reconnecting": "Переподключение…", "resume": "Возобновить", "pause": "Приостановить", "eta": "Окончание {{eta}}", diff --git a/src/main/events/connectivity/index.ts b/src/main/events/connectivity/index.ts new file mode 100644 index 0000000000..df87e99f22 --- /dev/null +++ b/src/main/events/connectivity/index.ts @@ -0,0 +1 @@ +import "./update-network-status"; diff --git a/src/main/events/connectivity/update-network-status.ts b/src/main/events/connectivity/update-network-status.ts new file mode 100644 index 0000000000..9bdafd6caa --- /dev/null +++ b/src/main/events/connectivity/update-network-status.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { DownloadOrchestrator } from "@main/services"; + +const updateNetworkStatus = ( + _event: Electron.IpcMainInvokeEvent, + payload: { online: boolean; switched?: boolean } +) => { + DownloadOrchestrator.onNetworkStatusChanged(payload); +}; + +registerEvent("updateNetworkStatus", updateNetworkStatus); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index 093d8f2443..e928219acd 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -6,6 +6,7 @@ import "./autoupdater"; import "./big-picture"; import "./catalogue"; import "./cloud-save"; +import "./connectivity"; import "./download-sources"; import "./friends"; import "./hardware"; diff --git a/src/main/index.ts b/src/main/index.ts index 1957f5e192..95877367fa 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, net, protocol } from "electron"; +import { app, BrowserWindow, net, powerMonitor, protocol } from "electron"; import updater from "electron-updater"; import i18n from "i18next"; import path from "node:path"; @@ -10,6 +10,7 @@ import { WindowManager, Lock, PowerSaveBlockerManager, + DownloadOrchestrator, } from "@main/services"; import resources from "@locales"; import { PythonRPC } from "./services/python-rpc"; @@ -171,6 +172,13 @@ app.whenReady().then(async () => { WindowManager.createNotificationWindow(); WindowManager.createSystemTray(language || "en"); + powerMonitor.on("resume", () => { + DownloadOrchestrator.onNetworkStatusChanged({ + online: true, + switched: true, + }); + }); + if (deepLinkArg) { handleDeepLinkPath(deepLinkArg); } diff --git a/src/main/services/download-orchestrator.ts b/src/main/services/download-orchestrator.ts index 87166ce736..6d60a90f19 100644 --- a/src/main/services/download-orchestrator.ts +++ b/src/main/services/download-orchestrator.ts @@ -66,7 +66,87 @@ function withInsertedId(ids: string[], id: string, targetIndex?: number) { return nextIds; } +const NO_INTERNET_GRACE_MS = 15000; +const RECONNECT_DEBOUNCE_MS = 2000; + export class DownloadOrchestrator { + private static isOnline = true; + private static reconnectGraceTimer: NodeJS.Timeout | null = null; + private static lastReconnectAt = 0; + + static onNetworkStatusChanged(payload: { + online: boolean; + switched?: boolean; + }) { + const { online } = payload; + + if (!DownloadManager.isJsDownloadActive) { + this.isOnline = online; + this.clearReconnectGrace(); + return; + } + + if (!online) { + if (this.isOnline) { + logger.log( + "[DownloadOrchestrator] Connection lost during download; waiting for it to come back" + ); + } + this.isOnline = false; + DownloadManager.notifyReconnecting(true); + + if (!this.reconnectGraceTimer) { + this.reconnectGraceTimer = setTimeout(() => { + this.reconnectGraceTimer = null; + if (this.isOnline) return; + + if (!DownloadManager.isActiveDownloadReconnecting()) { + this.isOnline = true; + return; + } + + void this.stopActiveDownloadForNoNetwork(); + }, NO_INTERNET_GRACE_MS); + } + return; + } + + this.isOnline = true; + this.clearReconnectGrace(); + + const now = Date.now(); + if (now - this.lastReconnectAt < RECONNECT_DEBOUNCE_MS) return; + this.lastReconnectAt = now; + + logger.log( + "[DownloadOrchestrator] Connection available; resuming the active download" + ); + DownloadManager.reconnectActiveDownload(); + } + + private static clearReconnectGrace() { + if (this.reconnectGraceTimer) { + clearTimeout(this.reconnectGraceTimer); + this.reconnectGraceTimer = null; + } + } + + private static async stopActiveDownloadForNoNetwork() { + const downloadId = DownloadManager.getActiveDownloadId(); + if (!downloadId) return; + + const download = await downloadsSublevel.get(downloadId).catch(() => null); + if (!download) return; + + logger.log( + "[DownloadOrchestrator] No connection after the grace window; pausing the download" + ); + await this.pauseDownload(download, { + reason: "paused", + startNextQueued: false, + }); + } + private static async getAllDownloads() { return downloadsSublevel.values().all(); } diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 062753b4bb..fcf0833ab8 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -31,6 +31,10 @@ import { DEFAULT_DOWNLOAD_USER_AGENT, JsHttpDownloader, } from "./js-http-downloader"; +import { + clampProgress, + isRetryableHttpStatus, +} from "./js-http-downloader-helpers"; import { getDirectorySize } from "@main/events/helpers/get-directory-size"; import { getDownloadLayoutStateRecord, @@ -65,11 +69,44 @@ export class DownloadManager { private static isPreparingDownload = false; private static allDebridBatch: AllDebridBatchState | null = null; private static maxDownloadSpeedBytesPerSecond: number | null = null; + private static startGeneration = 0; public static hasActiveDownload() { return this.downloadingGameId !== null; } + public static get isJsDownloadActive(): boolean { + return ( + this.usingJsDownloader && + this.jsDownloader !== null && + this.downloadingGameId !== null + ); + } + + public static getActiveDownloadId(): string | null { + return this.downloadingGameId; + } + + public static notifyReconnecting(value: boolean): void { + if (this.usingJsDownloader && this.jsDownloader) { + this.jsDownloader.setReconnecting(value); + } + } + + public static reconnectActiveDownload(): void { + if (this.usingJsDownloader && this.jsDownloader) { + this.jsDownloader.reconnect(); + } + } + + public static isActiveDownloadReconnecting(): boolean { + return ( + this.usingJsDownloader && + this.jsDownloader !== null && + this.jsDownloader.getDownloadStatus()?.isReconnecting === true + ); + } + private static extractFilename( url: string, originalUrl?: string @@ -380,6 +417,8 @@ export class DownloadManager { } } + progress = clampProgress(progress); + const effectiveFileSize = fileSize > 0 ? fileSize : download.fileSize; const updatedDownload = { @@ -409,6 +448,7 @@ export class DownloadManager { ), isDownloadingMetadata: false, isCheckingFiles: false, + isReconnecting: status.isReconnecting, progress, gameId: downloadId, download: updatedDownload, @@ -907,6 +947,10 @@ export class DownloadManager { const isActiveDownload = downloadKey === this.downloadingGameId; if (isActiveDownload) { + // Invalidate any in-flight startDownload preparation for this slot so a + // late-resolving prepare cannot spawn a downloader after cancellation. + this.startGeneration += 1; + if (this.usingJsDownloader && this.jsDownloader) { logger.log("[DownloadManager] Cancelling JS download"); this.jsDownloader.cancelDownload(); @@ -1056,18 +1100,30 @@ export class DownloadManager { `downloaded=${dlStatus.bytesDownloaded} expected=${expectedSize}. ` + `The download URL may have returned an error page.` ); + const mismatchDownloadId = this.allDebridBatch?.downloadId; this.cleanupBatch(); + if (mismatchDownloadId) { + await this.handleRuntimeDownloadError( + mismatchDownloadId, + new Error( + "An AllDebrid file returned fewer bytes than expected. The link may have expired." + ) + ); + } return; } - batch.completedBytes += Math.max( - entry.size ?? 0, - dlStatus.bytesDownloaded - ); + const bankedBytes = + entry.size && entry.size > 0 ? entry.size : dlStatus.bytesDownloaded; + batch.completedBytes += bankedBytes; batch.currentIndex += 1; } catch (err) { logger.error("[DownloadManager] AllDebrid batch entry error:", err); + const failedDownloadId = this.allDebridBatch?.downloadId; this.cleanupBatch(); + if (failedDownloadId) { + await this.handleRuntimeDownloadError(failedDownloadId, err); + } return; } } @@ -1500,23 +1556,39 @@ export class DownloadManager { } } - private static async validateJsDownloadResponse(options: { - url: string; - headers?: Record; - }) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); - const headers: Record = { ...options.headers }; + private static buildPreflightHeaders( + base?: Record + ): Record { + const headers: Record = { ...base }; + const hasUserAgentHeader = Object.keys(headers).some( (key) => key.toLowerCase() === "user-agent" ); - if (!hasUserAgentHeader) { headers["User-Agent"] = DEFAULT_DOWNLOAD_USER_AGENT; } + const hasAcceptEncoding = Object.keys(headers).some( + (key) => key.toLowerCase() === "accept-encoding" + ); + if (!hasAcceptEncoding) { + headers["Accept-Encoding"] = "identity"; + } + + return headers; + } + + private static async runPreflightAttempt( + url: string, + headers: Record, + attempt: number, + maxAttempts: number + ): Promise<"done" | "retry"> { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + try { - const response = await fetch(options.url, { + const response = await fetch(url, { method: "GET", headers, signal: controller.signal, @@ -1530,6 +1602,20 @@ export class DownloadManager { await response.body?.cancel().catch(() => undefined); + if (isRetryableHttpStatus(response.status)) { + if (attempt < maxAttempts) { + logger.log( + `[DownloadManager] Preflight got transient HTTP ${response.status}; retrying (${attempt}/${maxAttempts})` + ); + return "retry"; + } + + logger.warn( + `[DownloadManager] Preflight still HTTP ${response.status} after ${maxAttempts} attempts; allowing the download to start and retry` + ); + return "done"; + } + if (response.status >= 400) { throw new Error( `The download link is not available (HTTP ${response.status}).` @@ -1544,6 +1630,8 @@ export class DownloadManager { "The download link returned a web page instead of a file. It may have expired or be invalid." ); } + + return "done"; } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new Error("Download URL validation timed out"); @@ -1555,6 +1643,30 @@ export class DownloadManager { } } + private static async validateJsDownloadResponse(options: { + url: string; + headers?: Record; + }) { + const headers = this.buildPreflightHeaders(options.headers); + const MAX_PREFLIGHT_ATTEMPTS = 3; + const PREFLIGHT_RETRY_BASE_DELAY_MS = 1000; + + for (let attempt = 1; attempt <= MAX_PREFLIGHT_ATTEMPTS; attempt++) { + const verdict = await this.runPreflightAttempt( + options.url, + headers, + attempt, + MAX_PREFLIGHT_ATTEMPTS + ); + + if (verdict === "done") return; + + await new Promise((resolve) => + setTimeout(resolve, PREFLIGHT_RETRY_BASE_DELAY_MS * attempt) + ); + } + } + static async startDownload(download: Download) { const isHttp = this.isHttpDownloader(download.downloader); const downloadId = levelKeys.game(download.shop, download.objectId); @@ -1562,7 +1674,10 @@ export class DownloadManager { if (isHttp) { logger.log("[DownloadManager] Using JS HTTP downloader"); - // Set preparing state immediately so UI knows download is starting + // Set preparing state immediately so UI knows download is starting. + // The generation token lets a concurrent cancel/restart for the same id + // invalidate this in-flight preparation before it spawns a downloader. + const myGeneration = ++this.startGeneration; this.downloadingGameId = downloadId; this.isPreparingDownload = true; this.usingJsDownloader = true; @@ -1579,7 +1694,7 @@ export class DownloadManager { throw new Error(DownloadError.NotCachedOnAllDebrid); } - this.allDebridBatch = { + const batchState: AllDebridBatchState = { downloadId, savePath: download.downloadPath, entries: entries.map((entry) => ({ @@ -1596,6 +1711,17 @@ export class DownloadManager { batchSpeed: 0, }; + if ( + this.downloadingGameId !== downloadId || + this.startGeneration !== myGeneration + ) { + logger.log( + "[DownloadManager] Download was superseded during preparation; aborting start" + ); + return; + } + + this.allDebridBatch = batchState; this.jsDownloader = new JsHttpDownloader(); this.jsDownloader.setMaxDownloadSpeedBytesPerSecond( this.maxDownloadSpeedBytesPerSecond @@ -1613,6 +1739,16 @@ export class DownloadManager { throw new Error("Failed to get download options for JS downloader"); } + if ( + this.downloadingGameId !== downloadId || + this.startGeneration !== myGeneration + ) { + logger.log( + "[DownloadManager] Download was superseded during preparation; aborting start" + ); + return; + } + this.jsDownloader = new JsHttpDownloader(); this.jsDownloader.setMaxDownloadSpeedBytesPerSecond( this.maxDownloadSpeedBytesPerSecond diff --git a/src/main/services/download/js-http-downloader-helpers.ts b/src/main/services/download/js-http-downloader-helpers.ts new file mode 100644 index 0000000000..3a94ccad4c --- /dev/null +++ b/src/main/services/download/js-http-downloader-helpers.ts @@ -0,0 +1,210 @@ +export const PROGRESS_RESET_THRESHOLD_BYTES = 16 * 1024 * 1024; +export const MAX_BUDGET_RESETS = 50; +export const MAX_RESTARTS_FROM_ZERO = 3; + +export const RETRYABLE_ERROR_CODES = new Set([ + "ECONNRESET", + "ETIMEDOUT", + "ECONNREFUSED", + "ENOTFOUND", + "ENETUNREACH", + "EHOSTUNREACH", + "EPIPE", + "EAI_AGAIN", + "ECONNABORTED", + "ESOCKETTIMEDOUT", + "ERR_STREAM_PREMATURE_CLOSE", + "UND_ERR_SOCKET", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_REQ_RETRY", +]); + +const RETRYABLE_MESSAGE_FRAGMENTS = [ + "network", + "socket", + "connection", + "timeout", + "aborted", + "econnreset", + "etimedout", + "fetch failed", +]; + +// Transient HTTP statuses worth retrying with backoff (rate limits, gateway and +// upstream hiccups). Permanent 4xx (400/401/403/404/410) stay fatal so a dead +// link fails fast instead of looping. +export const RETRYABLE_HTTP_STATUS = new Set([408, 429, 500, 502, 503, 504]); + +export function isRetryableHttpStatus(status: number): boolean { + return RETRYABLE_HTTP_STATUS.has(status); +} + +// Parse a Retry-After header (delta-seconds or HTTP-date) into milliseconds. +export function parseRetryAfterMs( + headerValue: string | null, + nowMs: number +): number | null { + if (!headerValue) return null; + + const trimmed = headerValue.trim(); + if (trimmed === "") return null; + + if (/^\d+$/.test(trimmed)) { + return Number.parseInt(trimmed, 10) * 1000; + } + + const dateMs = Date.parse(trimmed); + if (Number.isFinite(dateMs)) { + return Math.max(0, dateMs - nowMs); + } + + return null; +} + +export function isRetryableDownloadError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + + const retryableFlag = (err as { retryable?: unknown }).retryable; + if (retryableFlag === true) return true; + if (retryableFlag === false) return false; + + const nodeError = err as NodeJS.ErrnoException; + if (nodeError.code && RETRYABLE_ERROR_CODES.has(nodeError.code)) { + return true; + } + + const cause = nodeError.cause; + if (cause && typeof cause === "object") { + const causeCode = (cause as NodeJS.ErrnoException).code; + if ( + typeof causeCode === "string" && + (RETRYABLE_ERROR_CODES.has(causeCode) || causeCode.startsWith("UND_ERR_")) + ) { + return true; + } + } + + const message = err.message.toLowerCase().trim(); + if (message === "terminated") return true; + + return RETRYABLE_MESSAGE_FRAGMENTS.some((fragment) => + message.includes(fragment) + ); +} + +export function computeFileSize(input: { + status: number; + contentRange: string | null; + contentLength: string | null; + startByte: number; +}): number | null { + if (input.contentRange) { + const match = /bytes \d+-\d+\/(\d+)/.exec(input.contentRange); + if (match) { + const total = Number.parseInt(match[1], 10); + return Number.isFinite(total) ? total : null; + } + return null; + } + + if (!input.contentLength) return null; + + const length = Number.parseInt(input.contentLength, 10); + if (!Number.isFinite(length)) return null; + + return input.status === 206 ? input.startByte + length : length; +} + +export interface ResumeAction { + flags: "a" | "w"; + skipBytes: number; + restart: boolean; + rangeIgnored: boolean; +} + +export function resolveResumeAction(input: { + startByte: number; + status: number; + partialStart: number | null; +}): ResumeAction { + if (input.startByte <= 0) { + return { flags: "w", skipBytes: 0, restart: false, rangeIgnored: false }; + } + + // Server ignored the Range request and is resending the whole file. Keep the + // partial and discard the prefix we already hold; this converges even on + // hosts that never honor Range, without throwing away downloaded progress. + if (input.status === 200) { + return { + flags: "a", + skipBytes: input.startByte, + restart: false, + rangeIgnored: true, + }; + } + + // Partial content: align to the server's actual Content-Range start. + if (input.partialStart !== null) { + if (input.partialStart > input.startByte) { + // Server started ahead of our data; appending would leave a gap. + return { flags: "w", skipBytes: 0, restart: true, rangeIgnored: false }; + } + if (input.partialStart < input.startByte) { + // Server resent bytes we already hold; discard the overlap. + return { + flags: "a", + skipBytes: input.startByte - input.partialStart, + restart: false, + rangeIgnored: false, + }; + } + } + + return { flags: "a", skipBytes: 0, restart: false, rangeIgnored: false }; +} + +export function applySkip( + remainingToSkip: number, + chunkLength: number +): { newRemainingToSkip: number; writeOffset: number; shouldWrite: boolean } { + if (remainingToSkip <= 0) { + return { newRemainingToSkip: 0, writeOffset: 0, shouldWrite: true }; + } + if (chunkLength <= remainingToSkip) { + return { + newRemainingToSkip: remainingToSkip - chunkLength, + writeOffset: 0, + shouldWrite: false, + }; + } + return { + newRemainingToSkip: 0, + writeOffset: remainingToSkip, + shouldWrite: true, + }; +} + +export function shouldResetRetryBudget( + newBytesThisAttempt: number, + budgetResets: number, + thresholdBytes: number, + maxResets: number +): boolean { + return newBytesThisAttempt >= thresholdBytes && budgetResets < maxResets; +} + +export function stallDetected( + pendingReadSince: number | null, + now: number, + timeoutMs: number +): boolean { + if (pendingReadSince === null) return false; + return now - pendingReadSince > timeoutMs; +} + +export function clampProgress(progress: number): number { + if (!Number.isFinite(progress)) return 0; + return Math.max(0, Math.min(progress, 1)); +} diff --git a/src/main/services/download/js-http-downloader.ts b/src/main/services/download/js-http-downloader.ts index 420a195fa7..d255afcfc6 100644 --- a/src/main/services/download/js-http-downloader.ts +++ b/src/main/services/download/js-http-downloader.ts @@ -3,6 +3,20 @@ import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { logger } from "../logger"; +import { + applySkip, + clampProgress, + computeFileSize, + isRetryableDownloadError, + isRetryableHttpStatus, + MAX_BUDGET_RESETS, + MAX_RESTARTS_FROM_ZERO, + parseRetryAfterMs, + PROGRESS_RESET_THRESHOLD_BYTES, + resolveResumeAction, + shouldResetRetryBudget, + stallDetected, +} from "./js-http-downloader-helpers"; export interface JsHttpDownloaderStatus { folderName: string; @@ -13,6 +27,7 @@ export interface JsHttpDownloaderStatus { numSeeds: number; status: "active" | "paused" | "complete" | "error"; bytesDownloaded: number; + isReconnecting: boolean; } export interface JsHttpDownloaderOptions { @@ -23,29 +38,22 @@ export interface JsHttpDownloaderOptions { } const MAX_RETRY_ATTEMPTS = 10; +const MAX_STATUS_RETRY_ATTEMPTS = 4; +const MAX_RETRY_AFTER_MS = 20000; const INITIAL_RETRY_DELAY_MS = 1000; const MAX_RETRY_DELAY_MS = 15000; -const STALL_TIMEOUT_MS = 8000; +const STALL_TIMEOUT_MS = 30000; const STALL_CHECK_INTERVAL_MS = 2000; +const RECONNECT_RETRY_DELAY_MS = 500; export const DEFAULT_DOWNLOAD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0"; -const RETRYABLE_ERROR_CODES = new Set([ - "ECONNRESET", - "ETIMEDOUT", - "ECONNREFUSED", - "ENOTFOUND", - "ENETUNREACH", - "EHOSTUNREACH", - "EPIPE", - "EAI_AGAIN", - "ECONNABORTED", - "ESOCKETTIMEDOUT", - "ERR_STREAM_PREMATURE_CLOSE", -]); - class HttpDownloadStatusError extends Error { - constructor(public readonly statusCode: number) { + constructor( + public readonly statusCode: number, + public readonly retryable = false, + public readonly retryAfterMs: number | null = null + ) { super(`The download link is not available (HTTP ${statusCode}).`); this.name = "HttpDownloadStatusError"; } @@ -55,6 +63,7 @@ export class JsHttpDownloader { private abortController: AbortController | null = null; private writeStream: fs.WriteStream | null = null; private currentOptions: JsHttpDownloaderOptions | null = null; + private resolvedFilename: string | null = null; private bytesDownloaded = 0; private fileSize = 0; @@ -66,10 +75,16 @@ export class JsHttpDownloader { private isDownloading = false; private retryCount = 0; - private lastDataReceivedAt = Date.now(); + private statusRetryCount = 0; + private budgetResets = 0; + private attemptBytesReceived = 0; + private restartCount = 0; + private pendingReadSince: number | null = null; private stallCheckInterval: NodeJS.Timeout | null = null; private isPaused = false; private isStallRetry = false; + private isReconnecting = false; + private isReconnectRetry = false; private maxDownloadSpeedBytesPerSecond: number | null = null; private throttleWindowStart = Date.now(); private bytesTransferredInThrottleWindow = 0; @@ -95,8 +110,16 @@ export class JsHttpDownloader { this.currentOptions = options; this.isPaused = false; this.retryCount = 0; + this.statusRetryCount = 0; + this.budgetResets = 0; + this.attemptBytesReceived = 0; + this.restartCount = 0; this.isStallRetry = false; + this.isReconnecting = false; + this.isReconnectRetry = false; this.fileSize = 0; + this.resolvedFilename = null; + this.pendingReadSince = null; this.resetThrottleWindow(); await this.startDownloadWithRetry(); } @@ -104,49 +127,52 @@ export class JsHttpDownloader { private async startDownloadWithRetry(): Promise { if (!this.currentOptions) return; - while (!this.isPaused) { - if (!this.currentOptions) return; - - this.abortController = new AbortController(); - this.status = "active"; - this.isDownloading = true; - this.isStallRetry = false; - this.lastDataReceivedAt = Date.now(); - - const { url, savePath, filename, headers = {} } = this.currentOptions; - const { filePath, startByte, usedFallback } = this.prepareDownloadPath( - savePath, - filename, - url - ); - const requestHeaders = this.buildRequestHeaders(headers, startByte); - - this.startStallDetection(); - - try { - await this.executeDownload( - url, - requestHeaders, - filePath, - startByte, + try { + while (!this.isPaused) { + if (!this.currentOptions) return; + + this.abortController = new AbortController(); + this.status = "active"; + this.isDownloading = true; + this.isStallRetry = false; + this.pendingReadSince = null; + this.attemptBytesReceived = 0; + + const { url, savePath, filename, headers = {} } = this.currentOptions; + const { filePath, startByte, usedFallback } = this.prepareDownloadPath( savePath, - usedFallback - ); - break; - } catch (err) { - const shouldRetry = await this.handleDownloadErrorWithRetry( - err as Error + filename, + url ); - if (!shouldRetry) { + const requestHeaders = this.buildRequestHeaders(headers, startByte); + + this.startStallDetection(); + + try { + await this.executeDownload( + url, + requestHeaders, + filePath, + startByte, + savePath, + usedFallback + ); break; + } catch (err) { + const shouldRetry = await this.handleDownloadErrorWithRetry( + err as Error + ); + if (!shouldRetry) { + break; + } + } finally { + this.stopStallDetection(); + this.cleanupResources(); } - } finally { - this.stopStallDetection(); - this.cleanupResources(); } + } finally { + this.isDownloading = false; } - - this.isDownloading = false; } private startStallDetection(): void { @@ -156,10 +182,12 @@ export class JsHttpDownloader { return; } - const timeSinceLastData = Date.now() - this.lastDataReceivedAt; - if (timeSinceLastData > STALL_TIMEOUT_MS) { + if (stallDetected(this.pendingReadSince, Date.now(), STALL_TIMEOUT_MS)) { + const blockedSeconds = Math.round( + (Date.now() - (this.pendingReadSince ?? Date.now())) / 1000 + ); logger.log( - `[JsHttpDownloader] Download stalled (no data for ${Math.round(timeSinceLastData / 1000)}s), triggering retry` + `[JsHttpDownloader] Read blocked for ${blockedSeconds}s with no data, triggering retry` ); this.triggerRetry(); } @@ -180,44 +208,40 @@ export class JsHttpDownloader { } } - private isRetryableError(err: Error): boolean { - const nodeError = err as NodeJS.ErrnoException; - if (nodeError.code && RETRYABLE_ERROR_CODES.has(nodeError.code)) { - return true; - } - - const message = err.message.toLowerCase(); - if ( - message.includes("network") || - message.includes("socket") || - message.includes("connection") || - message.includes("timeout") || - message.includes("aborted") || - message.includes("econnreset") || - message.includes("etimedout") || - message.includes("fetch failed") - ) { - return true; - } - - return false; - } - private async handleDownloadErrorWithRetry(err: Error): Promise { - const wasStallRetry = this.isStallRetry; - - if (this.isPaused && !wasStallRetry) { - logger.log("[JsHttpDownloader] Download paused by user"); + if (this.isPaused) { + logger.log("[JsHttpDownloader] Download paused/cancelled by user"); this.status = "paused"; return false; } + const wasStallRetry = this.isStallRetry; + const wasReconnect = this.isReconnectRetry; + this.isReconnectRetry = false; const isAbortError = err.name === "AbortError"; - const isRetryable = wasStallRetry || this.isRetryableError(err); - const canRetry = this.retryCount < MAX_RETRY_ATTEMPTS; + const isRetryable = + wasStallRetry || wasReconnect || isRetryableDownloadError(err); + const transientStatus = + err instanceof HttpDownloadStatusError && err.retryable; + + this.maybeResetRetryBudget(); - if (isRetryable && canRetry && !this.isPaused) { + if (transientStatus) { + return this.handleTransientStatusError(err as HttpDownloadStatusError); + } + + if (wasReconnect) { + logger.log( + `[JsHttpDownloader] Reconnecting after a network change; resuming in ${RECONNECT_RETRY_DELAY_MS}ms` + ); + await this.sleep(RECONNECT_RETRY_DELAY_MS); + return !this.isPaused; + } + + if (isRetryable && this.retryCount < MAX_RETRY_ATTEMPTS) { this.retryCount++; + this.isReconnecting = true; + this.downloadSpeed = 0; const delay = Math.min( INITIAL_RETRY_DELAY_MS * Math.pow(2, this.retryCount - 1), MAX_RETRY_DELAY_MS @@ -233,16 +257,72 @@ export class JsHttpDownloader { return !this.isPaused; } - if (isAbortError && !wasStallRetry) { + if (wasStallRetry) { + this.handleDownloadError( + new Error( + "Download stalled repeatedly and could not be resumed after multiple retries." + ) + ); + return false; + } + + if (isAbortError) { logger.log("[JsHttpDownloader] Download aborted"); this.status = "paused"; - } else { - this.handleDownloadError(err); + return false; } + this.handleDownloadError(err); return false; } + private maybeResetRetryBudget(): void { + if ( + shouldResetRetryBudget( + this.attemptBytesReceived, + this.budgetResets, + PROGRESS_RESET_THRESHOLD_BYTES, + MAX_BUDGET_RESETS + ) + ) { + logger.log( + "[JsHttpDownloader] Data is flowing again; resetting retry budget" + ); + this.retryCount = 0; + this.statusRetryCount = 0; + this.budgetResets += 1; + } + } + + private async handleTransientStatusError( + statusError: HttpDownloadStatusError + ): Promise { + if (this.statusRetryCount >= MAX_STATUS_RETRY_ATTEMPTS) { + this.handleDownloadError( + new Error( + `The download server is rate-limiting or temporarily unavailable (HTTP ${statusError.statusCode}). Try again later or use another source.` + ) + ); + return false; + } + + this.statusRetryCount++; + const backoff = Math.min( + INITIAL_RETRY_DELAY_MS * Math.pow(2, this.statusRetryCount - 1), + MAX_RETRY_DELAY_MS + ); + const delay = + statusError.retryAfterMs === null + ? backoff + : Math.min(statusError.retryAfterMs, MAX_RETRY_AFTER_MS); + logger.log( + `[JsHttpDownloader] Server unavailable (HTTP ${statusError.statusCode}). ` + + `Retry ${this.statusRetryCount}/${MAX_STATUS_RETRY_ATTEMPTS} in ${delay}ms` + ); + await this.sleep(delay); + return !this.isPaused; + } + private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -256,7 +336,7 @@ export class JsHttpDownloader { const limit = this.maxDownloadSpeedBytesPerSecond; if (!limit) return; - while (!this.isPaused) { + while (!this.isPaused && !this.abortController?.signal.aborted) { const now = Date.now(); const elapsed = now - this.throttleWindowStart; @@ -284,7 +364,8 @@ export class JsHttpDownloader { filename: string | undefined, url: string ): { filePath: string; startByte: number; usedFallback: boolean } { - const extractedFilename = filename || this.extractFilename(url); + const extractedFilename = + this.resolvedFilename || filename || this.extractFilename(url); const usedFallback = !extractedFilename; const resolvedFilename = extractedFilename || "download"; this.folderName = resolvedFilename; @@ -325,6 +406,14 @@ export class JsHttpDownloader { requestHeaders["User-Agent"] = DEFAULT_DOWNLOAD_USER_AGENT; } + const hasAcceptEncoding = Object.keys(requestHeaders).some( + (key) => key.toLowerCase() === "accept-encoding" + ); + + if (!hasAcceptEncoding) { + requestHeaders["Accept-Encoding"] = "identity"; + } + if (startByte > 0) { requestHeaders["Range"] = `bytes=${startByte}-`; } @@ -338,18 +427,15 @@ export class JsHttpDownloader { } private parseFileSize(response: Response, startByte: number): void { - const contentRange = response.headers.get("content-range"); - if (contentRange) { - const match = /bytes \d+-\d+\/(\d+)/.exec(contentRange); - if (match) { - this.fileSize = Number.parseInt(match[1], 10); - } - return; - } + const size = computeFileSize({ + status: response.status, + contentRange: response.headers.get("content-range"), + contentLength: response.headers.get("content-length"), + startByte, + }); - const contentLength = response.headers.get("content-length"); - if (contentLength) { - this.fileSize = startByte + Number.parseInt(contentLength, 10); + if (size !== null) { + this.fileSize = size; } } @@ -364,6 +450,17 @@ export class JsHttpDownloader { return Number.isFinite(total) && total > 0 ? total : null; } + private parseContentRangeStart(response: Response): number | null { + const contentRange = response.headers.get("content-range"); + if (!contentRange) return null; + + const match = /bytes\s+(\d+)-/i.exec(contentRange); + if (!match) return null; + + const start = Number.parseInt(match[1], 10); + return Number.isFinite(start) ? start : null; + } + private async executeDownload( url: string, requestHeaders: Record, @@ -405,7 +502,11 @@ export class JsHttpDownloader { } if (response.status >= 400) { - throw new HttpDownloadStatusError(response.status); + throw new HttpDownloadStatusError( + response.status, + isRetryableHttpStatus(response.status), + parseRetryAfterMs(response.headers.get("retry-after"), Date.now()) + ); } if (!response.ok && response.status !== 206) { @@ -422,10 +523,55 @@ export class JsHttpDownloader { ); } + const action = resolveResumeAction({ + startByte, + status: response.status, + partialStart: this.parseContentRangeStart(response), + }); + + let { flags, skipBytes, restart } = action; + + const contentEncoding = (response.headers.get("content-encoding") ?? "") + .toLowerCase() + .trim(); + if (contentEncoding && contentEncoding !== "identity" && startByte > 0) { + logger.log( + `[JsHttpDownloader] Response is "${contentEncoding}"-encoded; byte-offset resume is unreliable, restarting from byte 0` + ); + flags = "w"; + skipBytes = 0; + restart = true; + } + + if (restart) { + this.restartCount += 1; + if (this.restartCount > MAX_RESTARTS_FROM_ZERO) { + throw new Error( + "The server keeps refusing to resume and the download cannot make progress; aborting to avoid endless re-downloads." + ); + } + this.bytesDownloaded = 0; + this.resetSpeedTracking(); + logger.log( + `[JsHttpDownloader] Restarting the file from byte 0 (restart ${this.restartCount}/${MAX_RESTARTS_FROM_ZERO}).` + ); + } else if (action.rangeIgnored) { + logger.log( + `[JsHttpDownloader] Server ignored the Range header (HTTP 200). Discarding ${skipBytes} body bytes to preserve the existing partial.` + ); + } else if (skipBytes > 0) { + logger.log( + `[JsHttpDownloader] Partial response started before the resume offset; discarding ${skipBytes} overlapping body bytes.` + ); + } + this.parseFileSize(response, startByte); + // Resolve the on-disk filename once and pin it for the download's + // lifetime so a later restart cannot orphan the existing partial. + const writingFreshFile = flags === "w"; let actualFilePath = filePath; - if (startByte === 0) { + if (writingFreshFile && this.resolvedFilename === null) { const urlDerivedFilename = path.basename(filePath); const headerFilename = this.parseContentDisposition(response); if (headerFilename) { @@ -436,6 +582,7 @@ export class JsHttpDownloader { } actualFilePath = path.join(savePath, headerFilename); this.folderName = headerFilename; + this.resolvedFilename = headerFilename; const targetDir = path.dirname(actualFilePath); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); @@ -443,10 +590,13 @@ export class JsHttpDownloader { logger.log( `[JsHttpDownloader] Using filename from Content-Disposition: ${headerFilename}` ); - } else if (usedFallback) { - logger.log( - "[JsHttpDownloader] Content-Disposition filename not found, using fallback filename" - ); + } else { + this.resolvedFilename = path.basename(actualFilePath); + if (usedFallback) { + logger.log( + "[JsHttpDownloader] Content-Disposition filename not found, using fallback filename" + ); + } } } @@ -454,14 +604,20 @@ export class JsHttpDownloader { throw new Error("Response body is null"); } - const flags = startByte > 0 ? "a" : "w"; this.writeStream = fs.createWriteStream(actualFilePath, { flags }); - const readableStream = this.createReadableStream(response.body.getReader()); + const readableStream = this.createReadableStream( + response.body.getReader(), + skipBytes + ); await pipeline(readableStream, this.writeStream); this.status = "complete"; this.retryCount = 0; + this.statusRetryCount = 0; + this.budgetResets = 0; + this.restartCount = 0; + this.isReconnecting = false; this.downloadSpeed = 0; logger.log( `[JsHttpDownloader] Download complete (${this.bytesDownloaded} bytes)` @@ -516,37 +672,82 @@ export class JsHttpDownloader { } private createReadableStream( - reader: ReadableStreamDefaultReader + reader: ReadableStreamDefaultReader, + skipBytes = 0 ): Readable { const applyThrottle = this.applyThrottle.bind(this); + const markReadPending = () => { + this.pendingReadSince = Date.now(); + }; + const clearReadPending = () => { + this.pendingReadSince = null; + }; + const countReceived = (length: number) => { + this.attemptBytesReceived += length; + }; const onChunk = (length: number) => { + if (this.isReconnecting) { + this.isReconnecting = false; + } this.bytesDownloaded += length; - this.lastDataReceivedAt = Date.now(); this.updateSpeed(); }; + let remainingToSkip = skipBytes; return new Readable({ read() { void (async () => { try { - const { done, value } = await reader.read(); - if (done) { - this.push(null); + for (;;) { + markReadPending(); + const { done, value } = await reader.read(); + clearReadPending(); + + if (done) { + if (remainingToSkip > 0) { + this.destroy( + new Error( + `[JsHttpDownloader] Server body shorter than the existing partial (missing ${remainingToSkip} bytes); refusing to append a truncated file.` + ) + ); + return; + } + this.push(null); + return; + } + + countReceived(value.length); + + const plan = applySkip(remainingToSkip, value.length); + remainingToSkip = plan.newRemainingToSkip; + if (!plan.shouldWrite) { + continue; + } + + const chunk = + plan.writeOffset > 0 ? value.subarray(plan.writeOffset) : value; + await applyThrottle(chunk.length); + onChunk(chunk.length); + this.push(Buffer.from(chunk)); return; } - - await applyThrottle(value.length); - onChunk(value.length); - this.push(Buffer.from(value)); } catch (err) { + clearReadPending(); this.destroy(err as Error); } })(); }, + destroy(err, callback) { + reader + .cancel() + .catch(() => undefined) + .finally(() => callback(err)); + }, }); } private handleDownloadError(err: Error): void { + this.isReconnecting = false; if ( err.name === "AbortError" || (err as NodeJS.ErrnoException).code === "ERR_STREAM_PREMATURE_CLOSE" @@ -567,13 +768,51 @@ export class JsHttpDownloader { this.isDownloading = false; this.isPaused = false; this.retryCount = 0; + this.statusRetryCount = 0; + this.budgetResets = 0; + this.attemptBytesReceived = 0; + this.restartCount = 0; this.isStallRetry = false; + this.isReconnecting = false; + this.isReconnectRetry = false; + this.pendingReadSince = null; await this.startDownloadWithRetry(); } + setReconnecting(value: boolean): void { + this.isReconnecting = value; + if (value) { + this.downloadSpeed = 0; + } + } + + reconnect(): void { + if (!this.isDownloading || this.isPaused) return; + + logger.log( + "[JsHttpDownloader] Network change detected; reconnecting and resuming" + ); + this.isReconnecting = true; + this.isReconnectRetry = true; + this.downloadSpeed = 0; + this.pendingReadSince = null; + if (this.abortController) { + this.abortController.abort(); + } + } + + stopForNoNetwork(): void { + logger.log( + "[JsHttpDownloader] No internet connection; pausing download and keeping the partial file" + ); + this.isReconnecting = false; + this.pauseDownload(); + } + pauseDownload(): void { logger.log("[JsHttpDownloader] Pausing download"); this.isPaused = true; + this.pendingReadSince = null; this.stopStallDetection(); if (this.abortController) { this.abortController.abort(); @@ -585,6 +824,7 @@ export class JsHttpDownloader { cancelDownload(deleteFile = true): void { logger.log("[JsHttpDownloader] Cancelling download"); this.isPaused = true; + this.pendingReadSince = null; this.stopStallDetection(); if (this.abortController) { @@ -620,7 +860,7 @@ export class JsHttpDownloader { if (this.status === "complete") { progress = 1; } else if (this.fileSize > 0) { - progress = this.bytesDownloaded / this.fileSize; + progress = clampProgress(this.bytesDownloaded / this.fileSize); } return { @@ -632,6 +872,7 @@ export class JsHttpDownloader { numSeeds: 0, status: this.status, bytesDownloaded: this.bytesDownloaded, + isReconnecting: this.isReconnecting, }; } @@ -665,7 +906,7 @@ export class JsHttpDownloader { private cleanupResources(): void { if (this.writeStream) { - this.writeStream.close(); + this.writeStream.destroy(); this.writeStream = null; } this.abortController = null; @@ -673,6 +914,7 @@ export class JsHttpDownloader { private reset(): void { this.currentOptions = null; + this.resolvedFilename = null; this.bytesDownloaded = 0; this.fileSize = 0; this.downloadSpeed = 0; @@ -680,7 +922,14 @@ export class JsHttpDownloader { this.folderName = ""; this.isDownloading = false; this.retryCount = 0; + this.statusRetryCount = 0; + this.budgetResets = 0; + this.attemptBytesReceived = 0; + this.restartCount = 0; + this.pendingReadSince = null; this.isStallRetry = false; + this.isReconnecting = false; + this.isReconnectRetry = false; this.resetThrottleWindow(); } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 38bca0be64..df85ac45b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1325,3 +1325,26 @@ contextBridge.exposeInMainWorld("electron", { transferGameFiles: (shop: GameShop, objectId: string, destParent: string) => ipcRenderer.invoke("transferGameFiles", shop, objectId, destParent), }); + +const reportNetworkStatus = (online: boolean, switched = false) => { + ipcRenderer.invoke("updateNetworkStatus", { online, switched }).catch(() => { + return undefined; + }); +}; + +if (globalThis.window !== undefined) { + globalThis.addEventListener("online", () => reportNetworkStatus(true, true)); + globalThis.addEventListener("offline", () => reportNetworkStatus(false)); + + const connection = ( + navigator as Navigator & { + connection?: { + addEventListener?: (type: string, listener: () => void) => void; + }; + } + ).connection; + + connection?.addEventListener?.("change", () => + reportNetworkStatus(navigator.onLine, true) + ); +} diff --git a/src/renderer/src/components/bottom-panel/bottom-panel.tsx b/src/renderer/src/components/bottom-panel/bottom-panel.tsx index ca8b542f72..38082d5d07 100644 --- a/src/renderer/src/components/bottom-panel/bottom-panel.tsx +++ b/src/renderer/src/components/bottom-panel/bottom-panel.tsx @@ -89,6 +89,11 @@ export function BottomPanel() { : undefined; if (game) { + if (lastPacket?.isReconnecting) + return t("reconnecting", { + title: game.title, + }); + if (lastPacket?.isCheckingFiles) return t("checking_files", { title: game.title, diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index dc74f9116b..332d95eb28 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -303,6 +303,7 @@ function HeroDownloadView({ !lastPacket?.isCheckingFiles && !hasEta; const shouldShowEta = hasEta || shouldShowEtaPlaceholder; + const isReconnecting = !isGameExtracting && !!lastPacket?.isReconnecting; return (
@@ -351,14 +352,21 @@ function HeroDownloadView({ {t("checking_files")} )} - {!isGameExtracting && !lastPacket?.isCheckingFiles && ( - - - {isGameDownloading && lastPacket - ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` - : `${formatBytes(game.download?.bytesDownloaded ?? 0)} / ${finalDownloadSize}`} + {isReconnecting && !lastPacket?.isCheckingFiles && ( + + {t("reconnecting")} )} + {!isGameExtracting && + !lastPacket?.isCheckingFiles && + !isReconnecting && ( + + + {isGameDownloading && lastPacket + ? `${formatBytes(lastPacket.download.bytesDownloaded)} / ${finalDownloadSize}` + : `${formatBytes(game.download?.bytesDownloaded ?? 0)} / ${finalDownloadSize}`} + + )}
diff --git a/src/types/download.types.ts b/src/types/download.types.ts index cec4c3841f..d63eedd4e0 100644 --- a/src/types/download.types.ts +++ b/src/types/download.types.ts @@ -19,6 +19,7 @@ export interface DownloadProgress { numSeeds: number; isDownloadingMetadata: boolean; isCheckingFiles: boolean; + isReconnecting?: boolean; progress: number; gameId: string; download: Download;