From 9598fca34e5659d071814fb48169642ef68f116e Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 28 Feb 2026 12:16:08 +0100 Subject: [PATCH] Release v1.4.23 with critical bug audit fixes --- package-lock.json | 4 +- package.json | 2 +- src/main/cleanup.ts | 48 +++++++---- src/main/debrid.ts | 37 +++++++-- src/main/download-manager.ts | 142 +++++++++++++++++++++++++------- src/main/extractor.ts | 31 ++++++- src/main/storage.ts | 9 ++- src/main/update.ts | 75 +++++++++++++++-- src/main/utils.ts | 29 ++++++- src/renderer/App.tsx | 55 +++++++++---- src/shared/types.ts | 1 + tests/app-order.test.ts | 21 +++++ tests/debrid.test.ts | 138 +++++++++++++++++++++++++++++++ tests/download-manager.test.ts | 144 +++++++++++++++++++++++++++++++++ tests/storage.test.ts | 17 ++++ tests/update.test.ts | 44 ++++++++++ tests/utils.test.ts | 4 + 17 files changed, 723 insertions(+), 78 deletions(-) create mode 100644 tests/app-order.test.ts diff --git a/package-lock.json b/package-lock.json index 9beda0c..cb83123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.19", + "version": "1.4.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.19", + "version": "1.4.23", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 7335f52..1374874 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.22", + "version": "1.4.23", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/cleanup.ts b/src/main/cleanup.ts index e129026..5ddacc4 100644 --- a/src/main/cleanup.ts +++ b/src/main/cleanup.ts @@ -141,15 +141,42 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs let removedFiles = 0; let removedDirs = 0; - const allDirs: string[] = []; + const sampleDirs: string[] = []; const stack = [extractDir]; + const countFilesRecursive = (rootDir: string): number => { + let count = 0; + const dirs = [rootDir]; + while (dirs.length > 0) { + const current = dirs.pop() as string; + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + dirs.push(full); + } else if (entry.isFile()) { + count += 1; + } + } + } + return count; + }; + while (stack.length > 0) { const current = stack.pop() as string; - allDirs.push(current); for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const full = path.join(current, entry.name); if (entry.isDirectory()) { + const base = entry.name.toLowerCase(); + if (SAMPLE_DIR_NAMES.has(base)) { + sampleDirs.push(full); + continue; + } stack.push(full); continue; } @@ -157,13 +184,11 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs continue; } - const parent = path.basename(path.dirname(full)).toLowerCase(); const stem = path.parse(entry.name).name.toLowerCase(); const ext = path.extname(entry.name).toLowerCase(); - const inSampleDir = SAMPLE_DIR_NAMES.has(parent); const isSampleVideo = SAMPLE_VIDEO_EXTENSIONS.has(ext) && SAMPLE_TOKEN_RE.test(stem); - if (inSampleDir || isSampleVideo) { + if (isSampleVideo) { try { fs.rmSync(full, { force: true }); removedFiles += 1; @@ -174,17 +199,12 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs } } - allDirs.sort((a, b) => b.length - a.length); - for (const dir of allDirs) { - if (dir === extractDir) { - continue; - } - const base = path.basename(dir).toLowerCase(); - if (!SAMPLE_DIR_NAMES.has(base)) { - continue; - } + sampleDirs.sort((a, b) => b.length - a.length); + for (const dir of sampleDirs) { try { + const filesInDir = countFilesRecursive(dir); fs.rmSync(dir, { recursive: true, force: true }); + removedFiles += filesInDir; removedDirs += 1; } catch { // ignore diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 0483e56..f2803d6 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -320,6 +320,9 @@ class MegaDebridClient { web.retriesUsed = attempt - 1; return web; } + if (!lastError) { + lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer"; + } if (attempt < REQUEST_RETRIES) { await sleep(retryDelay(attempt)); } @@ -358,7 +361,7 @@ class BestDebridClient { "User-Agent": "RD-Node-Downloader/1.1.12" }; if (request.useAuthHeader) { - headers.Authorization = this.token; + headers.Authorization = `Bearer ${this.token}`; } const response = await fetch(request.url, { @@ -478,7 +481,7 @@ class AllDebridClient { const responseLink = pickString(info, ["link"]); const byResponse = canonicalToInput.get(canonicalLink(responseLink)); - const byIndex = chunk[i] || ""; + const byIndex = chunk.length === 1 ? chunk[0] : ""; const original = byResponse || byIndex; if (!original) { continue; @@ -609,7 +612,7 @@ export class DebridService { reportResolved(link, fromPage); }); - const stillUnresolved = unresolved.filter((link) => !clean.has(link)); + const stillUnresolved = unresolved.filter((link) => !clean.has(link) && !isRapidgatorLink(link)); await runWithConcurrency(stillUnresolved, 4, async (link) => { try { const unrestricted = await this.unrestrictLink(link); @@ -629,6 +632,31 @@ export class DebridService { this.settings.providerTertiary ); + const primary = order[0]; + if (!this.settings.autoProviderFallback) { + if (!this.isProviderConfigured(primary)) { + throw new Error(`${PROVIDER_LABELS[primary]} nicht konfiguriert`); + } + try { + const result = await this.unrestrictViaProvider(primary, link); + let fileName = result.fileName; + if (isRapidgatorLink(link) && looksLikeOpaqueFilename(fileName || filenameFromUrl(link))) { + const fromPage = await resolveRapidgatorFilename(link); + if (fromPage) { + fileName = fromPage; + } + } + return { + ...result, + fileName, + provider: primary, + providerLabel: PROVIDER_LABELS[primary] + }; + } catch (error) { + throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[primary]}: ${compactErrorText(error)}`); + } + } + let configuredFound = false; const attempts: string[] = []; @@ -637,9 +665,6 @@ export class DebridService { continue; } configuredFound = true; - if (!this.settings.autoProviderFallback && attempts.length > 0) { - break; - } try { const result = await this.unrestrictViaProvider(provider, link); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index d68f408..5f00f82 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -42,6 +42,8 @@ const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000; const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 90000; +const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000; + function getDownloadStallTimeoutMs(): number { const fromEnv = Number(process.env.RD_STALL_TIMEOUT_MS ?? NaN); if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 600000) { @@ -71,6 +73,14 @@ function getGlobalStallWatchdogTimeoutMs(): number { return DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS; } +function getPostExtractTimeoutMs(): number { + const fromEnv = Number(process.env.RD_POST_EXTRACT_TIMEOUT_MS ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 2000 && fromEnv <= 24 * 60 * 60 * 1000) { + return Math.floor(fromEnv); + } + return DEFAULT_POST_EXTRACT_TIMEOUT_MS; +} + type DownloadManagerOptions = { megaWebUnrestrict?: MegaWebUnrestrictor; }; @@ -562,17 +572,26 @@ export class DownloadManager extends EventEmitter { if (pkg.status === "paused") { pkg.status = "queued"; } + let hasReactivatedRunItems = false; for (const itemId of pkg.itemIds) { const item = this.session.items[itemId]; if (!item) { continue; } + if (this.session.running && !isFinishedStatus(item.status)) { + this.runOutcomes.delete(itemId); + this.runItemIds.add(itemId); + hasReactivatedRunItems = true; + } if (item.status === "queued" && item.fullStatus === "Paket gestoppt") { item.fullStatus = "Wartet"; item.updatedAt = nowMs(); } } if (this.session.running) { + if (hasReactivatedRunItems) { + this.runPackageIds.add(packageId); + } void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`)); } } @@ -1297,6 +1316,8 @@ export class DownloadManager extends EventEmitter { this.speedBytesLastWindow = 0; this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = nowMs(); + this.globalSpeedLimitQueue = Promise.resolve(); + this.globalSpeedLimitNextAt = 0; this.summary = null; this.persistSoon(); this.emitState(true); @@ -2103,6 +2124,9 @@ export class DownloadManager extends EventEmitter { while (true) { try { const unrestricted = await this.debridService.unrestrictLink(item.url); + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } item.provider = unrestricted.provider; item.retries += unrestricted.retriesUsed; item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url)); @@ -2206,23 +2230,28 @@ export class DownloadManager extends EventEmitter { return; } catch (error) { const reason = active.abortReason; + const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || ""; if (reason === "cancel") { item.status = "cancelled"; item.fullStatus = "Entfernt"; this.recordRunOutcome(item.id, "cancelled"); - try { - fs.rmSync(item.targetPath, { force: true }); - } catch { - // ignore + if (claimedTargetPath) { + try { + fs.rmSync(claimedTargetPath, { force: true }); + } catch { + // ignore + } } } else if (reason === "stop") { item.status = "cancelled"; item.fullStatus = "Gestoppt"; this.recordRunOutcome(item.id, "cancelled"); - try { - fs.rmSync(item.targetPath, { force: true }); - } catch { - // ignore + if (claimedTargetPath) { + try { + fs.rmSync(claimedTargetPath, { force: true }); + } catch { + // ignore + } } } else if (reason === "shutdown") { item.status = "queued"; @@ -2916,6 +2945,10 @@ export class DownloadManager extends EventEmitter { private cachedSpeedLimitAt = 0; + private globalSpeedLimitQueue: Promise = Promise.resolve(); + + private globalSpeedLimitNextAt = 0; + private getEffectiveSpeedLimitKbps(): number { const now = nowMs(); if (now - this.cachedSpeedLimitAt < 2000) { @@ -2947,6 +2980,25 @@ export class DownloadManager extends EventEmitter { return 0; } + private async applyGlobalSpeedLimit(chunkBytes: number, bytesPerSecond: number): Promise { + const task = this.globalSpeedLimitQueue + .catch(() => undefined) + .then(async () => { + const now = nowMs(); + const waitMs = Math.max(0, this.globalSpeedLimitNextAt - now); + if (waitMs > 0) { + await sleep(waitMs); + } + + const startAt = Math.max(nowMs(), this.globalSpeedLimitNextAt); + const durationMs = Math.max(1, Math.ceil((chunkBytes / bytesPerSecond) * 1000)); + this.globalSpeedLimitNextAt = startAt + durationMs; + }); + + this.globalSpeedLimitQueue = task; + await task; + } + private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise { const limitKbps = this.getEffectiveSpeedLimitKbps(); if (limitKbps <= 0) { @@ -2963,16 +3015,11 @@ export class DownloadManager extends EventEmitter { if (sleepMs > 0) { await sleep(Math.min(300, sleepMs)); } - } - return; } + return; + } - this.pruneSpeedEvents(now); - const globalBytes = this.speedBytesLastWindow + chunkBytes; - const globalAllowed = bytesPerSecond * 3; - if (globalBytes > globalAllowed) { - await sleep(Math.min(250, Math.ceil(((globalBytes - globalAllowed) / bytesPerSecond) * 1000))); - } + await this.applyGlobalSpeedLimit(chunkBytes, bytesPerSecond); } private findReadyArchiveSets(pkg: PackageEntry): Set { @@ -3194,9 +3241,28 @@ export class DownloadManager extends EventEmitter { updateExtractingStatus("Entpacken 0%"); this.emitState(); - const extractTimeoutMs = 4 * 60 * 60 * 1000; + const extractTimeoutMs = getPostExtractTimeoutMs(); + const extractAbortController = new AbortController(); + let timedOut = false; + const onParentAbort = (): void => { + if (extractAbortController.signal.aborted) { + return; + } + extractAbortController.abort("aborted:extract"); + }; + if (signal) { + if (signal.aborted) { + onParentAbort(); + } else { + signal.addEventListener("abort", onParentAbort, { once: true }); + } + } const extractDeadline = setTimeout(() => { - logger.error(`Post-Processing Extraction Timeout nach 4h: pkg=${pkg.name}`); + timedOut = true; + logger.error(`Post-Processing Extraction Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s: pkg=${pkg.name}`); + if (!extractAbortController.signal.aborted) { + extractAbortController.abort("extract_timeout"); + } }, extractTimeoutMs); try { const result = await extractPackageArchives({ @@ -3207,7 +3273,7 @@ export class DownloadManager extends EventEmitter { removeLinks: this.settings.removeLinkFilesAfterExtract, removeSamples: this.settings.removeSamplesAfterExtract, passwordList: this.settings.archivePasswordList, - signal, + signal: extractAbortController.signal, onProgress: (progress) => { const label = progress.phase === "done" ? "Entpacken 100%" @@ -3224,7 +3290,6 @@ export class DownloadManager extends EventEmitter { this.emitState(); } }); - clearTimeout(extractDeadline); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); if (result.failed > 0) { const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); @@ -3257,19 +3322,29 @@ export class DownloadManager extends EventEmitter { pkg.status = "completed"; } } catch (error) { - clearTimeout(extractDeadline); const reasonRaw = String(error || ""); - if (reasonRaw.includes("aborted:extract")) { - for (const entry of completedItems) { - if (/^Entpacken/i.test(entry.fullStatus || "")) { - entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + if (reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout")) { + if (timedOut) { + const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`; + logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`); + for (const entry of completedItems) { + entry.fullStatus = `Entpack-Fehler: ${timeoutReason}`; + entry.updatedAt = nowMs(); } - entry.updatedAt = nowMs(); + pkg.status = "failed"; + pkg.updatedAt = nowMs(); + } else { + for (const entry of completedItems) { + if (/^Entpacken/i.test(entry.fullStatus || "")) { + entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; + } + entry.updatedAt = nowMs(); + } + pkg.status = pkg.enabled ? "queued" : "paused"; + pkg.updatedAt = nowMs(); + logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`); + return; } - pkg.status = pkg.enabled ? "queued" : "paused"; - pkg.updatedAt = nowMs(); - logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`); - return; } const reason = compactErrorText(error); logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); @@ -3278,6 +3353,11 @@ export class DownloadManager extends EventEmitter { entry.updatedAt = nowMs(); } pkg.status = "failed"; + } finally { + clearTimeout(extractDeadline); + if (signal) { + signal.removeEventListener("abort", onParentAbort); + } } } else if (failed > 0) { pkg.status = "failed"; @@ -3380,6 +3460,8 @@ export class DownloadManager extends EventEmitter { this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); this.itemContributedBytes.clear(); + this.globalSpeedLimitQueue = Promise.resolve(); + this.globalSpeedLimitNextAt = 0; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); this.persistNow(); diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 5d5a07a..ab34455 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -12,8 +12,12 @@ const NO_EXTRACTOR_MESSAGE = "WinRAR/UnRAR nicht gefunden. Bitte WinRAR installi let resolvedExtractorCommand: string | null = null; let resolveFailureReason = ""; +let resolveFailureAt = 0; let externalExtractorSupportsPerfFlags = true; +const EXTRACTOR_RETRY_AFTER_MS = 30_000; +const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256; + export interface ExtractOptions { packageDir: string; targetDir: string; @@ -45,6 +49,14 @@ const EXTRACT_PER_GIB_TIMEOUT_MS = 4 * 60 * 1000; const EXTRACT_MAX_TIMEOUT_MS = 120 * 60 * 1000; const ARCHIVE_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); +function zipEntryMemoryLimitBytes(): number { + const fromEnvMb = Number(process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB ?? NaN); + if (Number.isFinite(fromEnvMb) && fromEnvMb >= 8 && fromEnvMb <= 4096) { + return Math.floor(fromEnvMb * 1024 * 1024); + } + return DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB * 1024 * 1024; +} + export function pathSetKey(filePath: string): string { return process.platform === "win32" ? filePath.toLowerCase() : filePath; } @@ -490,7 +502,12 @@ async function resolveExtractorCommand(): Promise { return resolvedExtractorCommand; } if (resolveFailureReason) { - throw new Error(resolveFailureReason); + const age = Date.now() - resolveFailureAt; + if (age < EXTRACTOR_RETRY_AFTER_MS) { + throw new Error(resolveFailureReason); + } + resolveFailureReason = ""; + resolveFailureAt = 0; } const candidates = winRarCandidates(); @@ -503,12 +520,14 @@ async function resolveExtractorCommand(): Promise { if (!probe.missingCommand) { resolvedExtractorCommand = command; resolveFailureReason = ""; + resolveFailureAt = 0; logger.info(`Entpacker erkannt: ${command}`); return command; } } resolveFailureReason = NO_EXTRACTOR_MESSAGE; + resolveFailureAt = Date.now(); throw new Error(resolveFailureReason); } @@ -581,6 +600,7 @@ async function runExternalExtract( if (result.missingCommand) { resolvedExtractorCommand = null; resolveFailureReason = NO_EXTRACTOR_MESSAGE; + resolveFailureAt = Date.now(); throw new Error(NO_EXTRACTOR_MESSAGE); } @@ -592,6 +612,7 @@ async function runExternalExtract( function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode): void { const mode = effectiveConflictMode(conflictMode); + const memoryLimitBytes = zipEntryMemoryLimitBytes(); const zip = new AdmZip(archivePath); const entries = zip.getEntries(); const resolvedTarget = path.resolve(targetDir); @@ -605,6 +626,14 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode: fs.mkdirSync(outputPath, { recursive: true }); continue; } + + const uncompressedSize = Number((entry as unknown as { header?: { size?: number } }).header?.size ?? NaN); + if (Number.isFinite(uncompressedSize) && uncompressedSize > memoryLimitBytes) { + const entryMb = Math.ceil(uncompressedSize / (1024 * 1024)); + const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); + throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); + } + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); // TOCTOU note: There is a small race between existsSync and writeFileSync below. // This is acceptable here because zip extraction is single-threaded and we need diff --git a/src/main/storage.ts b/src/main/storage.ts index f62d3b2..51a7b38 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -25,18 +25,25 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number) return Math.max(min, Math.min(max, Math.floor(num))); } +function createScheduleId(index: number): string { + return `sched-${Date.now().toString(36)}-${index.toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] { if (!Array.isArray(raw)) { return []; } const normalized: BandwidthScheduleEntry[] = []; - for (const entry of raw) { + for (let index = 0; index < raw.length; index += 1) { + const entry = raw[index]; if (!entry || typeof entry !== "object") { continue; } const value = entry as Partial; + const rawId = typeof value.id === "string" ? value.id.trim() : ""; normalized.push({ + id: rawId || createScheduleId(index), startHour: clampNumber(value.startHour, 0, 0, 23), endHour: clampNumber(value.endHour, 8, 0, 23), speedLimitKbps: clampNumber(value.speedLimitKbps, 0, 0, 500000), diff --git a/src/main/update.ts b/src/main/update.ts index 894eb94..4e44353 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -12,6 +12,7 @@ import { logger } from "./logger"; const RELEASE_FETCH_TIMEOUT_MS = 12000; const CONNECT_TIMEOUT_MS = 30000; +const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45000; const RETRIES_PER_CANDIDATE = 3; const RETRY_DELAY_MS = 1500; const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; @@ -69,6 +70,14 @@ function timeoutController(ms: number): { signal: AbortSignal; clear: () => void }; } +function getDownloadBodyIdleTimeoutMs(): number { + const fromEnv = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN); + if (Number.isFinite(fromEnv) && fromEnv >= 1000 && fromEnv <= 30 * 60 * 1000) { + return Math.floor(fromEnv); + } + return DOWNLOAD_BODY_IDLE_TIMEOUT_MS; +} + export function parseVersionParts(version: string): number[] { const cleaned = version.replace(/^v/i, "").trim(); return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0")); @@ -200,9 +209,9 @@ function readHttpStatusFromError(error: unknown): number { return match ? Number(match[1]) : 0; } -function isRecoverableDownloadError(error: unknown): boolean { +function isRetryableDownloadError(error: unknown): boolean { const status = readHttpStatusFromError(error); - if (status === 404 || status === 403 || status === 429 || status >= 500) { + if (status === 429 || status >= 500) { return true; } @@ -215,6 +224,14 @@ function isRecoverableDownloadError(error: unknown): boolean { || text.includes("aborted"); } +function shouldTryNextDownloadCandidate(error: unknown): boolean { + const status = readHttpStatusFromError(error); + if (status >= 400 && status <= 599) { + return true; + } + return isRetryableDownloadError(error); +} + function deriveUpdateFileName(check: UpdateCheckResult, url: string): string { const fromName = String(check.setupAssetName || "").trim(); if (fromName) { @@ -298,7 +315,55 @@ async function downloadFile(url: string, targetPath: string): Promise { await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); const source = Readable.fromWeb(response.body as unknown as NodeReadableStream); const target = fs.createWriteStream(targetPath); - await pipeline(source, target); + const idleTimeoutMs = getDownloadBodyIdleTimeoutMs(); + let idleTimer: NodeJS.Timeout | null = null; + const clearIdleTimer = (): void => { + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + }; + const onIdleTimeout = (): void => { + const timeoutError = new Error(`Update Download Body Timeout nach ${Math.ceil(idleTimeoutMs / 1000)}s`); + source.destroy(timeoutError); + target.destroy(timeoutError); + }; + const resetIdleTimer = (): void => { + if (idleTimeoutMs <= 0) { + return; + } + clearIdleTimer(); + idleTimer = setTimeout(onIdleTimeout, idleTimeoutMs); + }; + + const onSourceData = (): void => { + resetIdleTimer(); + }; + const onSourceDone = (): void => { + clearIdleTimer(); + }; + + if (idleTimeoutMs > 0) { + source.on("data", onSourceData); + source.on("end", onSourceDone); + source.on("close", onSourceDone); + source.on("error", onSourceDone); + target.on("close", onSourceDone); + target.on("error", onSourceDone); + resetIdleTimer(); + } + + try { + await pipeline(source, target); + } finally { + clearIdleTimer(); + source.off("data", onSourceData); + source.off("end", onSourceDone); + source.off("close", onSourceDone); + source.off("error", onSourceDone); + target.off("close", onSourceDone); + target.off("error", onSourceDone); + } logger.info(`Update-Download abgeschlossen: ${targetPath}`); } @@ -319,7 +384,7 @@ async function downloadWithRetries(url: string, targetPath: string): Promise]+>/g, " ").replace(/\s+/g, " ").trim(); if (!raw) { @@ -21,8 +27,27 @@ export function compactErrorText(message: unknown, maxLen = 220): string { } export function sanitizeFilename(name: string): string { - const cleaned = String(name || "").trim().replace(/\0/g, "").replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim(); - return cleaned || "Paket"; + const cleaned = String(name || "") + .replace(/\0/g, "") + .replace(/[\\/:*?"<>|]/g, " ") + .replace(/\s+/g, " ") + .trim(); + + let normalized = cleaned + .replace(/^[.\s]+/g, "") + .replace(/[.\s]+$/g, "") + .trim(); + + if (!normalized || normalized === "." || normalized === ".." || /^\.+$/.test(normalized)) { + return "Paket"; + } + + const parsed = path.parse(normalized); + if (WINDOWS_RESERVED_BASENAMES.has(parsed.name.toLowerCase())) { + normalized = `${parsed.name}_${parsed.ext}`; + } + + return normalized || "Paket"; } export function isHttpLink(value: string): boolean { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 1f36dbc..8700a5e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -81,6 +81,23 @@ function humanSize(bytes: number): string { let nextCollectorId = 1; +function createScheduleId(): string { + return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] { + const fromIndex = order.indexOf(draggedPackageId); + const toIndex = order.indexOf(targetPackageId); + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { + return order; + } + const next = [...order]; + const [dragged] = next.splice(fromIndex, 1); + const insertIndex = Math.max(0, Math.min(next.length, toIndex)); + next.splice(insertIndex, 0, dragged); + return next; +} + export function App(): ReactElement { const [snapshot, setSnapshot] = useState(emptySnapshot); const [tab, setTab] = useState("collector"); @@ -122,10 +139,6 @@ export function App(): ReactElement { activeTabRef.current = tab; }, [tab]); - useEffect(() => { - settingsDirtyRef.current = settingsDirty; - }, [settingsDirty]); - const showToast = (message: string, timeoutMs = 2200): void => { setStatusToast(message); if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } @@ -141,6 +154,7 @@ export function App(): ReactElement { void window.rd.getSnapshot().then((state) => { setSnapshot(state); setSettingsDraft(state.settings); + settingsDirtyRef.current = false; setSettingsDirty(false); applyTheme(state.settings.theme); if (state.settings.autoUpdateCheck) { @@ -406,6 +420,7 @@ export function App(): ReactElement { const persistDraftSettings = async (): Promise => { const result = await window.rd.updateSettings(normalizedSettingsDraft); setSettingsDraft(result); + settingsDirtyRef.current = false; setSettingsDirty(false); return result; }; @@ -485,8 +500,8 @@ export function App(): ReactElement { const onAddLinks = async (): Promise => { await performQuickAction(async () => { - await persistDraftSettings(); - const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: settingsDraft.packageName }); + const persisted = await persistDraftSettings(); + const result = await window.rd.addLinks({ rawText: currentCollectorTab.text, packageName: persisted.packageName }); if (result.addedLinks > 0) { showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id ? { ...t, text: "" } : t)); @@ -575,19 +590,23 @@ export function App(): ReactElement { }; const setBool = (key: keyof AppSettings, value: boolean): void => { + settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setText = (key: keyof AppSettings, value: string): void => { + settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setNum = (key: keyof AppSettings, value: number): void => { + settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; const setSpeedLimitMbps = (value: number): void => { const mbps = Number.isFinite(value) ? Math.max(0, value) : 0; + settingsDirtyRef.current = true; setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) })); }; @@ -628,16 +647,13 @@ export function App(): ReactElement { }, [snapshot.session.packageOrder]); const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => { - const order = [...snapshot.session.packageOrder]; - const fromIndex = order.indexOf(draggedPackageId); - const toIndex = order.indexOf(targetPackageId); - if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { + const nextOrder = reorderPackageOrderByDrop(snapshot.session.packageOrder, draggedPackageId, targetPackageId); + const unchanged = nextOrder.length === snapshot.session.packageOrder.length + && nextOrder.every((id, index) => id === snapshot.session.packageOrder[index]); + if (unchanged) { return; } - const [dragged] = order.splice(fromIndex, 1); - const insertIndex = fromIndex < toIndex ? toIndex - 1 : toIndex; - order.splice(insertIndex, 0, dragged); - void window.rd.reorderPackages(order); + void window.rd.reorderPackages(nextOrder); }, [snapshot.session.packageOrder]); const addCollectorTab = (): void => { @@ -684,18 +700,24 @@ export function App(): ReactElement { const schedules = settingsDraft.bandwidthSchedules ?? []; const addSchedule = (): void => { + settingsDirtyRef.current = true; + setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, - bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }] + bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { id: createScheduleId(), startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }] })); }; const removeSchedule = (idx: number): void => { + settingsDirtyRef.current = true; + setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx) })); }; const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => { + settingsDirtyRef.current = true; + setSettingsDirty(true); setSettingsDraft((prev) => ({ ...prev, bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s) @@ -909,6 +931,7 @@ export function App(): ReactElement {