From 17e947fc6b2a38230419e6b3d9996745327425d1 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 10 Mar 2026 05:54:19 +0100 Subject: [PATCH] Harden type safety and recovery guards --- src/main/app-controller.ts | 8 +-- src/main/debrid.ts | 92 +--------------------------------- src/main/download-manager.ts | 25 +++++---- src/main/trace-log.ts | 2 +- src/main/utils.ts | 22 +++++++- src/renderer/App.tsx | 36 ++++++++++--- src/shared/types.ts | 6 +-- tests/download-manager.test.ts | 3 +- 8 files changed, 77 insertions(+), 117 deletions(-) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index f9b8a72..c08b4c2 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -32,7 +32,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; -import { addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; +import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { encryptBackup, decryptBackup } from "./backup-crypto"; @@ -587,6 +587,8 @@ export class AppController { // Restore settings — ALL credentials are included (no more masking) const importedSettings = parsed.settings as AppSettings; + const importedSettingsRecord = importedSettings as unknown as Record; + const currentSettingsRecord = this.settings as unknown as Record; // Legacy backup compatibility: if credentials were masked with ***, keep current values const SENSITIVE_KEYS: (keyof AppSettings)[] = [ "token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", @@ -594,9 +596,9 @@ export class AppController { "debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword" ]; for (const key of SENSITIVE_KEYS) { - const val = (importedSettings as Record)[key]; + const val = importedSettingsRecord[key]; if (typeof val === "string" && val.startsWith("***")) { - (importedSettings as Record)[key] = (this.settings as Record)[key]; + importedSettingsRecord[key] = currentSettingsRecord[key]; } } const restoredSettings = normalizeSettings(importedSettings); diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 58c626c..bdd5bde 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -580,7 +580,7 @@ async function fetchDebridLinkHostLimitForKey(apiKey: { id: string; label: strin throw new Error(String(lastError || `Debrid-Link Limits für ${apiKey.label} fehlgeschlagen`).replace(/^Error:\s*/i, "")); } -function uniqueProviderOrder(order: DebridProvider[]): DebridProvider[] { +function uniqueProviderOrder(order: readonly DebridProvider[]): DebridProvider[] { const seen = new Set(); const result: DebridProvider[] = []; for (const provider of order) { @@ -1668,94 +1668,6 @@ class DebridLinkClient { : ""; logger.warn(`Debrid-Link${keyLabel}: ${failure.message}${cooldownInfo}, pruefe naechsten Key`); } - continue; - - let lastError = ""; - for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { - if (signal?.aborted) throw new Error("aborted:debrid"); - try { - const res = await fetch(`${DEBRID_LINK_API_BASE}/downloader/add`, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `Bearer ${apiKey.token}` - }, - body: `url=${encodeURIComponent(link)}`, - signal: withTimeoutSignal(signal, API_TIMEOUT_MS) - }); - - const json = await res.json() as Record; - - if (!json.success) { - const errorCode = String(json.error || ""); - const errorDesc = String(json.error_description || json.error || "Unbekannter Debrid-Link-Fehler"); - - if (DEBRID_LINK_QUOTA_ERRORS.has(errorCode)) { - logger.warn(`Debrid-Link${keyLabel}: API-Quota erreicht (${errorCode}: ${errorDesc}), wechsle zum naechsten Key`); - debridLinkKeyCooldowns.set(apiKey.id, Date.now() + DEBRID_LINK_KEY_COOLDOWN_MS); - break; - } - if (DEBRID_LINK_SKIP_KEY_ERRORS.has(errorCode)) { - logger.warn(`Debrid-Link${keyLabel}: Key kann Link nicht verarbeiten (${errorCode}: ${errorDesc}), wechsle zum naechsten Key`); - debridLinkKeyCooldowns.set(apiKey.id, Date.now() + DEBRID_LINK_KEY_COOLDOWN_MS); - break; - } - - if (errorCode === "badToken" || errorCode === "expired_token") { - throw new Error(`Debrid-Link${keyLabel}: Ungültiger oder abgelaufener API-Key`); - } - if (errorCode === "floodDetected") { - await sleep(retryDelay(attempt), signal); - continue; - } - - throw new Error(`Debrid-Link${keyLabel}: ${errorDesc}`); - } - - const value = json.value as Record | undefined; - if (!value) { - throw new Error(`Debrid-Link${keyLabel}: Keine Daten in Antwort`); - } - - const directUrl = String(value.downloadUrl || ""); - if (!directUrl) { - throw new Error(`Debrid-Link${keyLabel}: Keine Download-URL in Antwort`); - } - - const fileName = String(value.name || "") || filenameFromUrl(directUrl) || filenameFromUrl(link); - const fileSize = typeof value.size === "number" && value.size > 0 ? value.size : null; - - logger.info(`Debrid-Link${keyLabel}: Unrestrict OK → ${fileName || "?"}`); - - return { - fileName, - directUrl, - fileSize, - retriesUsed: attempt - 1, - sourceLabel: apiKey.label, - sourceAccountId: apiKey.id, - sourceAccountLabel: apiKey.label - }; - } catch (error) { - lastError = compactErrorText(error); - if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { - throw error; - } - if (/Ungültig|abgelaufen/i.test(lastError)) { - throw error; - } - logger.warn(`Debrid-Link${keyLabel}: Fehler bei Unrestrict-Versuch ${attempt}/${REQUEST_RETRIES}: ${lastError}`); - if (attempt < REQUEST_RETRIES) { - await sleep(retryDelay(attempt), signal); - } - } - } - - if (keyIdx + 1 < this.apiKeys.length) { - const nextKey = this.apiKeys[keyIdx + 1]; - const nextKeyLabel = this.apiKeys.length > 1 ? ` (${nextKey.label})` : ""; - logger.info(`Debrid-Link${keyLabel}: kein Erfolg, wechsle zu naechstem Key${nextKeyLabel}`); - } } if (!usableKeySeen) { @@ -2299,7 +2211,7 @@ class DdownloadClient { if (!xfss) { throw new Error("DDownload Login fehlgeschlagen (kein Session-Cookie)"); } - this.cookies = [loginCookie, xfss].filter(Boolean).map((c: string) => c.split(";")[0]).join("; "); + this.cookies = [loginCookie, xfss].filter((c): c is string => Boolean(c)).map((c) => c.split(";")[0]).join("; "); } public async unrestrictLink(link: string, signal?: AbortSignal): Promise { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 4e534de..ad56374 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -6,6 +6,7 @@ import { v4 as uuidv4 } from "uuid"; import { AllDebridHostInfo, AppSettings, + DebridProvider, DownloadItem, DownloadStats, DownloadSummary, @@ -2662,7 +2663,9 @@ export class DownloadManager extends EventEmitter { // Reuse result if same URL was already checked if (checkedUrls.has(url)) { const cached = checkedUrls.get(url); - this.applyRapidgatorCheckResult(item, cached); + if (cached !== undefined) { + this.applyRapidgatorCheckResult(item, cached); + } this.emitState(); continue; } @@ -6219,8 +6222,8 @@ export class DownloadManager extends EventEmitter { } private getPackageHistoryDurationSeconds(pkg: PackageEntry): number { - const startedAt = pkg.downloadStartedAt > 0 ? pkg.downloadStartedAt : pkg.createdAt; - const finishedAtCandidate = pkg.downloadCompletedAt > 0 ? pkg.downloadCompletedAt : nowMs(); + const startedAt = (pkg.downloadStartedAt || 0) > 0 ? (pkg.downloadStartedAt || 0) : pkg.createdAt; + const finishedAtCandidate = (pkg.downloadCompletedAt || 0) > 0 ? (pkg.downloadCompletedAt || 0) : nowMs(); const finishedAt = Math.max(startedAt || 0, finishedAtCandidate || 0); if (startedAt <= 0 || finishedAt <= 0) { return 1; @@ -6458,7 +6461,7 @@ export class DownloadManager extends EventEmitter { private getProviderOrder(): DebridProvider[] { if (this.settings.providerOrder && this.settings.providerOrder.length > 0) { - return this.settings.providerOrder; + return [...this.settings.providerOrder]; } return [ this.settings.providerPrimary, @@ -7195,7 +7198,7 @@ export class DownloadManager extends EventEmitter { errorText: string, claimedTargetPath: string ): void { - active.genericErrorRetries += 1; + active.genericErrorRetries = Number(active.genericErrorRetries || 0) + 1; item.retries += 1; if (claimedTargetPath) { try { @@ -7660,6 +7663,7 @@ export class DownloadManager extends EventEmitter { // Persist retry counters so shelve logic survives reconnect interruption this.retryStateByItem.set(item.id, { freshRetryUsed: Boolean(active.freshRetryUsed), + resumeHardResetUsed: Boolean(active.resumeHardResetUsed), stallRetries: Number(active.stallRetries || 0), genericErrorRetries: Number(active.genericErrorRetries || 0), unrestrictRetries: Number(active.unrestrictRetries || 0) @@ -7676,6 +7680,7 @@ export class DownloadManager extends EventEmitter { item.fullStatus = "Paket gestoppt"; this.retryStateByItem.set(item.id, { freshRetryUsed: Boolean(active.freshRetryUsed), + resumeHardResetUsed: Boolean(active.resumeHardResetUsed), stallRetries: Number(active.stallRetries || 0), genericErrorRetries: Number(active.genericErrorRetries || 0), unrestrictRetries: Number(active.unrestrictRetries || 0) @@ -9389,8 +9394,9 @@ export class DownloadManager extends EventEmitter { const stat = await fs.promises.stat(part); // Find the item that owns this file to get its expected totalBytes const ownerItem = this.findItemByDiskPath(pkg, part); - const minBytes = ownerItem?.totalBytes && ownerItem.totalBytes > 0 - ? ownerItem.totalBytes - ALLOCATION_UNIT_SIZE + const ownerTotalBytes = ownerItem?.totalBytes ?? 0; + const minBytes = ownerTotalBytes > 0 + ? ownerTotalBytes - ALLOCATION_UNIT_SIZE : 10240; if (stat.size < minBytes) { allMissingFullOnDisk = false; @@ -9933,8 +9939,9 @@ export class DownloadManager extends EventEmitter { : 10240; if (stat.size >= minSize) { // Re-check: another task may have started this item during the await - if (this.activeTasks.has(item.id) || item.status === "downloading" - || item.status === "validating" || item.status === "integrity_check") { + const latestItem = this.session.items[item.id]; + if (!latestItem || this.activeTasks.has(item.id) || latestItem.status === "downloading" + || latestItem.status === "validating" || latestItem.status === "integrity_check") { continue; } // Guard against pre-allocated sparse files from a hard crash: file has diff --git a/src/main/trace-log.ts b/src/main/trace-log.ts index 8b8fe99..b6b8249 100644 --- a/src/main/trace-log.ts +++ b/src/main/trace-log.ts @@ -267,7 +267,7 @@ export function updateTraceConfig(patch: Partial): SupportTr }); persistTraceConfig(); scheduleAutoDisable(); - appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig)}\n`); + appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record)}\n`); return getTraceConfig(); } diff --git a/src/main/utils.ts b/src/main/utils.ts index 3ab0045..372fbe5 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -271,8 +271,26 @@ export function nowMs(): number { return Date.now(); } -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error(String(signal.reason || "aborted"))); + return; + } + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const onAbort = (): void => { + clearTimeout(timer); + cleanup(); + reject(new Error(String(signal?.reason || "aborted"))); + }; + const cleanup = (): void => { + signal?.removeEventListener("abort", onAbort); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); } export function formatEta(seconds: number): string { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8002662..15cf42a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import type { AllDebridHostInfo, @@ -761,6 +761,7 @@ function validateAccountDialog(dialog: AccountDialogState): string | null { const emptyStats = (): DownloadStats => ({ totalDownloaded: 0, totalDownloadedAllTime: 0, + totalFiles: 0, totalFilesSession: 0, totalFilesAllTime: 0, totalPackages: 0, @@ -771,6 +772,24 @@ const emptyStats = (): DownloadStats => ({ runtimeMeasuredAt: 0 }); +type StatsSectionItem = { + key: string; + eyebrow: string; + label: string; + value: string; + compactValue?: boolean; + danger?: boolean; + clickable?: boolean; + title?: string; + onClick?: () => void; +}; + +type StatsSection = { + key: string; + title: string; + items: StatsSectionItem[]; +}; + const emptySnapshot = (): UiSnapshot => ({ settings: { token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", @@ -3204,6 +3223,7 @@ export function App(): ReactElement { }); void window.rd.togglePackage(packageId).catch((error) => { if (previousEnabled !== null) { + const revertedEnabled = previousEnabled; setSnapshot((prev) => { const pkg = prev.session.packages[packageId]; if (!pkg) { @@ -3217,8 +3237,8 @@ export function App(): ReactElement { ...prev.session.packages, [packageId]: { ...pkg, - enabled: previousEnabled, - status: previousEnabled && pkg.status === "paused" ? "queued" : pkg.status, + enabled: revertedEnabled, + status: revertedEnabled && pkg.status === "paused" ? "queued" : pkg.status, updatedAt: Date.now() } }, @@ -3554,7 +3574,7 @@ export function App(): ReactElement { }, [selectedIds, settingsDraft.confirmDeleteSelection, executeDeleteSelection]); useEffect(() => { - const onKey = (e: KeyboardEvent): void => { + const onKey = (e: globalThis.KeyboardEvent): void => { if (e.key === "Escape") { const target = e.target as HTMLElement; if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { @@ -3801,7 +3821,7 @@ export function App(): ReactElement { .map((it) => it.id); void window.rd.resetItems(failedIds).catch(() => {}); }; - const statsSections = [ + const statsSections: StatsSection[] = [ { key: "live", title: "Aktuell", @@ -3835,7 +3855,7 @@ export function App(): ReactElement { { key: "files-total", eyebrow: "Gesamt", label: "Fertige Dateien", value: String(snapshot.stats.totalFilesAllTime) } ] } - ] as const; + ]; return (
): void => { + const onKeyDown = (e: ReactKeyboardEvent): void => { if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); } if (e.key === "Escape") { onFinishEdit(pkg.id, pkg.name, pkg.name); } }; @@ -6093,7 +6113,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe ); case "progress": return ( - {item.totalBytes > 0 ? ( + {(item.totalBytes || 0) > 0 ? ( {item.progressPercent}% diff --git a/src/shared/types.ts b/src/shared/types.ts index 0e27fc8..b0da9c0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -42,7 +42,7 @@ export interface BandwidthScheduleEntry { export interface DownloadStats { totalDownloaded: number; totalDownloadedAllTime: number; - totalFiles: number; + totalFiles?: number; totalFilesSession: number; totalFilesAllTime: number; totalPackages: number; @@ -74,7 +74,7 @@ export interface AppSettings { linkSnappyPassword: string; archivePasswordList: string; rememberToken: boolean; - providerOrder: DebridProvider[]; + providerOrder: readonly DebridProvider[]; providerPrimary: DebridProvider; providerSecondary: DebridFallbackProvider; providerTertiary: DebridFallbackProvider; @@ -168,7 +168,7 @@ export interface PackageEntry { itemIds: string[]; cancelled: boolean; enabled: boolean; - priority: PackagePriority; + priority?: PackagePriority; postProcessLabel?: string; downloadStartedAt?: number; downloadCompletedAt?: number; diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 63de81f..6615b92 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -14,6 +14,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item import { createStoragePaths, emptySession } from "../src/main/storage"; import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests } from "../src/main/debrid"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; +import { UnrestrictedLink } from "../src/main/realdebrid"; const tempDirs: string[] = []; const originalFetch = globalThis.fetch; @@ -5694,7 +5695,7 @@ describe("download manager", () => { { megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal) => { unrestrictCalls += 1; - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { const rejector = (error: Error): void => { signal?.removeEventListener("abort", onAbort); pendingRejectors.delete(rejector);