From 8700db4a37e519c7d47bfe562c6dbc4e3042c0e9 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 28 Feb 2026 14:12:16 +0100 Subject: [PATCH] Release v1.4.27 with bug audit hardening fixes --- package-lock.json | 4 +- package.json | 2 +- src/main/constants.ts | 2 +- src/main/container.ts | 29 ++++- src/main/debrid.ts | 39 ++++-- src/main/download-manager.ts | 50 ++++++-- src/main/extractor.ts | 79 ++++++++++-- src/main/integrity.ts | 18 ++- src/main/main.ts | 15 ++- src/main/realdebrid.ts | 22 +++- src/main/storage.ts | 225 +++++++++++++++++++++++++++------ src/main/update.ts | 106 ++++++++++++++-- src/main/utils.ts | 30 +++++ src/renderer/App.tsx | 26 +++- src/renderer/styles.css | 17 ++- src/shared/types.ts | 3 +- tests/app-order.test.ts | 30 ++++- tests/cleanup.test.ts | 17 +++ tests/container.test.ts | 31 +++++ tests/debrid.test.ts | 154 ++++++++++++++++++++-- tests/download-manager.test.ts | 187 +++++++++++++++++++++++++++ tests/extractor.test.ts | 64 ++++++++++ tests/integrity.test.ts | 11 ++ tests/realdebrid.test.ts | 42 ++++++ tests/storage.test.ts | 78 +++++++++++- tests/update.test.ts | 147 ++++++++++++++++++++- tests/utils.test.ts | 13 +- 27 files changed, 1322 insertions(+), 119 deletions(-) create mode 100644 tests/container.test.ts create mode 100644 tests/realdebrid.test.ts diff --git a/package-lock.json b/package-lock.json index cb83123..8c31646 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.23", + "version": "1.4.27", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.23", + "version": "1.4.27", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 7b089eb..3531b22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.26", + "version": "1.4.27", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/constants.ts b/src/main/constants.ts index 9271304..9c2e590 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -8,7 +8,7 @@ export const APP_VERSION: string = packageJson.version; export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; -export const DLC_SERVICE_URL = "http://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}"; +export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}"; export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8"); export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8"); diff --git a/src/main/container.ts b/src/main/container.ts index 84f83ae..1d36ae7 100644 --- a/src/main/container.ts +++ b/src/main/container.ts @@ -5,6 +5,8 @@ import { DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./c import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils"; import { ParsedPackageInput } from "../shared/types"; +const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024; + function decodeDcryptPayload(responseText: string): unknown { let text = String(responseText || "").trim(); const m = text.match(/]*>([\s\S]*?)<\/textarea>/i); @@ -62,6 +64,14 @@ function decryptRcPayload(base64Rc: string): Buffer { return Buffer.concat([decipher.update(rcBytes), decipher.final()]); } +function readDlcFileWithLimit(filePath: string): Buffer { + const stat = fs.statSync(filePath); + if (stat.size <= 0 || stat.size > MAX_DLC_FILE_BYTES) { + throw new Error(`DLC-Datei ungültig oder zu groß (${Math.floor(stat.size)} B)`); + } + return fs.readFileSync(filePath); +} + function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] { const packages: ParsedPackageInput[] = []; const packageRegex = /]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi; @@ -104,7 +114,7 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] { } async function decryptDlcLocal(filePath: string): Promise { - const content = fs.readFileSync(filePath, "ascii").trim(); + const content = readDlcFileWithLimit(filePath).toString("ascii").trim(); if (content.length < 89) { return []; } @@ -129,10 +139,19 @@ async function decryptDlcLocal(filePath: string): Promise decipher.setAutoPadding(false); let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); - const pad = decrypted[decrypted.length - 1]; - if (pad > 0 && pad <= 16) { - decrypted = decrypted.subarray(0, decrypted.length - pad); + if (decrypted.length === 0) { + throw new Error("DLC-Entschlüsselung lieferte keine Daten"); } + const pad = decrypted[decrypted.length - 1]; + if (pad <= 0 || pad > 16 || pad > decrypted.length) { + throw new Error("Ungültiges DLC-Padding"); + } + for (let index = 1; index <= pad; index += 1) { + if (decrypted[decrypted.length - index] !== pad) { + throw new Error("Ungültiges DLC-Padding"); + } + } + decrypted = decrypted.subarray(0, decrypted.length - pad); const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8"); return parsePackagesFromDlcXml(xmlData); @@ -140,7 +159,7 @@ async function decryptDlcLocal(filePath: string): Promise async function decryptDlcViaDcrypt(filePath: string): Promise { const fileName = path.basename(filePath); - const blob = new Blob([fs.readFileSync(filePath)]); + const blob = new Blob([new Uint8Array(readDlcFileWithLimit(filePath))]); const form = new FormData(); form.set("dlcfile", blob, fileName); diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 500f9a7..883c6b6 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -50,6 +50,27 @@ function retryDelay(attempt: number): number { return Math.min(5000, 400 * 2 ** attempt); } +function readHttpStatusFromErrorText(text: string): number { + const match = String(text || "").match(/HTTP\s+(\d{3})/i); + return match ? Number(match[1]) : 0; +} + +function isRetryableErrorText(text: string): boolean { + const status = readHttpStatusFromErrorText(text); + if (status === 429 || status >= 500) { + return true; + } + const lower = String(text || "").toLowerCase(); + return lower.includes("timeout") + || lower.includes("network") + || lower.includes("fetch failed") + || lower.includes("aborted") + || lower.includes("econnreset") + || lower.includes("enotfound") + || lower.includes("etimedout") + || lower.includes("html statt json"); +} + function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -286,15 +307,12 @@ async function resolveRapidgatorFilename(link: string): Promise { function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] { const linkParam = encodeURIComponent(link); - const authParam = encodeURIComponent(token); + const safeToken = String(token || "").trim(); + const useAuthHeader = Boolean(safeToken); return [ { url: `${BEST_DEBRID_API_BASE}/generateLink?link=${linkParam}`, - useAuthHeader: true - }, - { - url: `${BEST_DEBRID_API_BASE}/generateLink?auth=${authParam}&link=${linkParam}`, - useAuthHeader: false + useAuthHeader } ]; } @@ -402,7 +420,7 @@ class BestDebridClient { throw new Error("BestDebrid Antwort ohne Download-Link"); } catch (error) { lastError = compactErrorText(error); - if (attempt >= REQUEST_RETRIES) { + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { break; } await sleep(retryDelay(attempt)); @@ -490,7 +508,8 @@ class AllDebridClient { chunkResolved = true; break; } catch (error) { - if (attempt >= REQUEST_RETRIES) { + const errorText = compactErrorText(error); + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(errorText)) { throw error; } await sleep(retryDelay(attempt)); @@ -579,7 +598,7 @@ class AllDebridClient { }; } catch (error) { lastError = compactErrorText(error); - if (attempt >= REQUEST_RETRIES) { + if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { break; } await sleep(retryDelay(attempt)); @@ -738,7 +757,7 @@ export class DebridService { return Boolean(this.settings.token.trim()); } if (provider === "megadebrid") { - return Boolean(this.settings.megaLogin.trim() && this.settings.megaPassword.trim()); + return Boolean(this.settings.megaLogin.trim() && this.settings.megaPassword.trim() && this.options.megaWebUnrestrict); } if (provider === "alldebrid") { return Boolean(this.settings.allDebridToken.trim()); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 86baaf1..e33df9e 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -655,6 +655,9 @@ export class DownloadManager extends EventEmitter { this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); this.itemContributedBytes.clear(); + this.speedEvents = []; + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; this.packagePostProcessTasks.clear(); this.packagePostProcessAbortControllers.clear(); this.hybridExtractRequeue.clear(); @@ -798,11 +801,17 @@ export class DownloadManager extends EventEmitter { active.abortController.abort("cancel"); } this.releaseTargetPath(itemId); + this.runItemIds.delete(itemId); + this.runOutcomes.delete(itemId); + this.itemContributedBytes.delete(itemId); delete this.session.items[itemId]; this.itemCount = Math.max(0, this.itemCount - 1); } delete this.session.packages[packageId]; this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); + this.runPackageIds.delete(packageId); + this.runCompletedPackages.delete(packageId); + this.hybridExtractRequeue.delete(packageId); this.persistSoon(); this.emitState(true); return { skipped: true, overwritten: false }; @@ -846,6 +855,11 @@ export class DownloadManager extends EventEmitter { item.fullStatus = "Wartet"; item.updatedAt = nowMs(); item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url))); + this.runOutcomes.delete(itemId); + this.itemContributedBytes.delete(itemId); + if (this.session.running) { + this.runItemIds.add(itemId); + } } pkg.status = "queued"; pkg.updatedAt = nowMs(); @@ -1294,6 +1308,7 @@ export class DownloadManager extends EventEmitter { this.session.reconnectReason = ""; this.speedEvents = []; this.speedBytesLastWindow = 0; + this.speedEventsHead = 0; this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = nowMs(); this.summary = null; @@ -1319,6 +1334,7 @@ export class DownloadManager extends EventEmitter { this.consecutiveReconnects = 0; this.speedEvents = []; this.speedBytesLastWindow = 0; + this.speedEventsHead = 0; this.lastGlobalProgressBytes = 0; this.lastGlobalProgressAt = nowMs(); this.globalSpeedLimitQueue = Promise.resolve(); @@ -1326,7 +1342,13 @@ export class DownloadManager extends EventEmitter { this.summary = null; this.persistSoon(); this.emitState(true); - this.ensureScheduler(); + void this.ensureScheduler().catch((error) => { + logger.error(`Scheduler abgestürzt: ${compactErrorText(error)}`); + this.session.running = false; + this.session.paused = false; + this.persistSoon(); + this.emitState(true); + }); } public stop(): void { @@ -1396,6 +1418,7 @@ export class DownloadManager extends EventEmitter { this.speedEvents = []; this.speedBytesLastWindow = 0; + this.speedEventsHead = 0; this.runItemIds.clear(); this.runPackageIds.clear(); this.runOutcomes.clear(); @@ -1599,6 +1622,9 @@ export class DownloadManager extends EventEmitter { private recordSpeed(bytes: number): void { const now = nowMs(); + if (bytes > 0 && this.consecutiveReconnects > 0) { + this.consecutiveReconnects = 0; + } const bucket = now - (now % 120); const last = this.speedEvents[this.speedEvents.length - 1]; if (last && last.at === bucket) { @@ -3363,7 +3389,9 @@ export class DownloadManager extends EventEmitter { } } catch (error) { const reasonRaw = String(error || ""); - if (reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout")) { + const isExtractAbort = reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout"); + let timeoutHandled = false; + if (isExtractAbort) { if (timedOut) { const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`; logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`); @@ -3373,6 +3401,7 @@ export class DownloadManager extends EventEmitter { } pkg.status = "failed"; pkg.updatedAt = nowMs(); + timeoutHandled = true; } else { for (const entry of completedItems) { if (/^Entpacken/i.test(entry.fullStatus || "")) { @@ -3386,13 +3415,15 @@ export class DownloadManager extends EventEmitter { return; } } - const reason = compactErrorText(error); - logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); - for (const entry of completedItems) { - entry.fullStatus = `Entpack-Fehler: ${reason}`; - entry.updatedAt = nowMs(); + if (!timeoutHandled) { + const reason = compactErrorText(error); + logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); + for (const entry of completedItems) { + entry.fullStatus = `Entpack-Fehler: ${reason}`; + entry.updatedAt = nowMs(); + } + pkg.status = "failed"; } - pkg.status = "failed"; } finally { clearTimeout(extractDeadline); if (signal) { @@ -3500,6 +3531,9 @@ export class DownloadManager extends EventEmitter { this.reservedTargetPaths.clear(); this.claimedTargetPathByItem.clear(); this.itemContributedBytes.clear(); + this.speedEvents = []; + this.speedEventsHead = 0; + this.speedBytesLastWindow = 0; this.globalSpeedLimitQueue = Promise.resolve(); this.globalSpeedLimitNextAt = 0; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; diff --git a/src/main/extractor.ts b/src/main/extractor.ts index fd3093a..ec4d2f0 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -18,6 +18,7 @@ let resolveExtractorCommandInFlight: Promise | null = null; const EXTRACTOR_RETRY_AFTER_MS = 30_000; const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256; +const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000; export interface ExtractOptions { packageDir: string; @@ -63,6 +64,10 @@ export function pathSetKey(filePath: string): string { return process.platform === "win32" ? filePath.toLowerCase() : filePath; } +function archiveNameKey(fileName: string): string { + return process.platform === "win32" ? String(fileName || "").toLowerCase() : String(fileName || ""); +} + function archiveSortKey(filePath: string): string { const fileName = path.basename(filePath).toLowerCase(); return fileName @@ -244,7 +249,7 @@ function readExtractResumeState(packageDir: string, packageId?: string): Set; const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : []; - return new Set(names.map((value) => String(value || "").trim()).filter(Boolean)); + return new Set(names.map((value) => archiveNameKey(String(value || "").trim())).filter(Boolean)); } catch { return new Set(); } @@ -255,7 +260,9 @@ function writeExtractResumeState(packageDir: string, completedArchives: Set a.localeCompare(b)) + completedArchives: Array.from(completedArchives) + .map((name) => archiveNameKey(name)) + .sort((a, b) => a.localeCompare(b)) }; fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8"); } catch (error) { @@ -457,10 +464,24 @@ function runExtractCommand( }); child.on("close", (code) => { - if (code === 0 || code === 1) { + if (code === 0) { finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" }); return; } + if (code === 1) { + const lowered = output.toLowerCase(); + const warningOnly = !lowered.includes("crc failed") + && !lowered.includes("checksum error") + && !lowered.includes("wrong password") + && !lowered.includes("cannot open") + && !lowered.includes("fatal error") + && !lowered.includes("unexpected end of archive") + && !lowered.includes("error:"); + if (warningOnly) { + finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" }); + return; + } + } const cleaned = cleanErrorText(output); finish({ ok: false, @@ -521,7 +542,7 @@ async function resolveExtractorCommandInternal(): Promise { continue; } const probeArgs = command.toLowerCase().includes("winrar") ? ["-?"] : ["?"]; - const probe = await runExtractCommand(command, probeArgs); + const probe = await runExtractCommand(command, probeArgs, undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS); if (!probe.missingCommand) { resolvedExtractorCommand = command; resolveFailureReason = ""; @@ -634,13 +655,35 @@ async function runExternalExtract( throw new Error(lastError || "Entpacken fehlgeschlagen"); } -function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode): void { +function isZipSafetyGuardError(error: unknown): boolean { + const text = String(error || "").toLowerCase(); + return text.includes("zip-eintrag zu groß") + || text.includes("zip-eintrag komprimiert zu groß") + || text.includes("zip-eintrag ohne sichere groessenangabe") + || text.includes("path traversal"); +} + +function shouldFallbackToExternalZip(error: unknown): boolean { + if (isZipSafetyGuardError(error)) { + return false; + } + const text = String(error || "").toLowerCase(); + if (text.includes("aborted:extract") || text.includes("extract_aborted")) { + return false; + } + return true; +} + +function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): void { const mode = effectiveConflictMode(conflictMode); const memoryLimitBytes = zipEntryMemoryLimitBytes(); const zip = new AdmZip(archivePath); const entries = zip.getEntries(); const resolvedTarget = path.resolve(targetDir); for (const entry of entries) { + if (signal?.aborted) { + throw new Error("aborted:extract"); + } const outputPath = path.resolve(targetDir, entry.entryName); if (!outputPath.startsWith(resolvedTarget + path.sep) && outputPath !== resolvedTarget) { logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`); @@ -700,10 +743,16 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode: candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`); n += 1; } + if (signal?.aborted) { + throw new Error("aborted:extract"); + } fs.writeFileSync(candidate, entry.getData()); continue; } } + if (signal?.aborted) { + throw new Error("aborted:extract"); + } fs.writeFileSync(outputPath, entry.getData()); } } @@ -945,7 +994,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ let passwordCandidates = archivePasswords(options.passwordList || ""); const resumeCompleted = readExtractResumeState(options.packageDir, options.packageId); const resumeCompletedAtStart = resumeCompleted.size; - const allCandidateNames = new Set(allCandidates.map((archivePath) => path.basename(archivePath))); + const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath)))); for (const archiveName of Array.from(resumeCompleted.values())) { if (!allCandidateNames.has(archiveName)) { resumeCompleted.delete(archiveName); @@ -957,13 +1006,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ clearExtractResumeState(options.packageDir, options.packageId); } - const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(path.basename(archivePath))); + const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(archiveNameKey(path.basename(archivePath)))); let extracted = candidates.length - pendingCandidates.length; let failed = 0; let lastError = ""; const extractedArchives = new Set(); for (const archivePath of candidates) { - if (resumeCompleted.has(path.basename(archivePath))) { + if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { extractedArchives.add(archivePath); } } @@ -1002,7 +1051,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (options.signal?.aborted) { throw new Error("aborted:extract"); } - const archiveName = path.basename(archivePath); + const archiveName = path.basename(archivePath); + const archiveResumeKey = archiveNameKey(archiveName); const archiveStartedAt = Date.now(); let archivePercent = 0; emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0); @@ -1023,16 +1073,19 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); } catch (error) { if (isNoExtractorError(String(error))) { - extractZipArchive(archivePath, options.targetDir, options.conflictMode); + extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal); } else { throw error; } } } else { try { - extractZipArchive(archivePath, options.targetDir, options.conflictMode); + extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal); archivePercent = 100; - } catch { + } catch (error) { + if (!shouldFallbackToExternalZip(error)) { + throw error; + } const usedPassword = await runExternalExtract(archivePath, options.targetDir, "overwrite", passwordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); @@ -1049,7 +1102,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } extracted += 1; extractedArchives.add(archivePath); - resumeCompleted.add(archiveName); + resumeCompleted.add(archiveResumeKey); writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); archivePercent = 100; diff --git a/src/main/integrity.ts b/src/main/integrity.ts index 5943405..2eca7ff 100644 --- a/src/main/integrity.ts +++ b/src/main/integrity.ts @@ -41,7 +41,17 @@ export function readHashManifest(packageDir: string): Map { + if (!entry.isFile()) { + return false; + } + const ext = path.extname(entry.name).toLowerCase(); + return patterns.some(([pattern]) => pattern === ext); + }) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })); + + for (const entry of manifestFiles) { if (!entry.isFile()) { continue; } @@ -70,7 +80,11 @@ export function readHashManifest(packageDir: string): Map | null = null; let lastClipboardText = ""; const controller = new AppController(); +const CLIPBOARD_MAX_TEXT_CHARS = 50_000; function isDevMode(): boolean { return process.env.NODE_ENV === "development"; @@ -115,21 +117,24 @@ function destroyTray(): void { } function extractLinksFromText(text: string): string[] { - const matches = text.match(/https?:\/\/[^\s<>"']+/gi); - return matches ? Array.from(new Set(matches)) : []; + return extractHttpLinksFromText(text); +} + +function normalizeClipboardText(text: string): string { + return String(text || "").slice(0, CLIPBOARD_MAX_TEXT_CHARS); } function startClipboardWatcher(): void { if (clipboardTimer) { return; } - lastClipboardText = clipboard.readText().slice(0, 50000); + lastClipboardText = normalizeClipboardText(clipboard.readText()); clipboardTimer = setInterval(() => { - const text = clipboard.readText(); + const text = normalizeClipboardText(clipboard.readText()); if (text === lastClipboardText || !text.trim()) { return; } - lastClipboardText = text.slice(0, 50000); + lastClipboardText = text; const links = extractLinksFromText(text); if (links.length > 0 && mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(IPC_CHANNELS.CLIPBOARD_DETECTED, links); diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index 27ca537..5a61123 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -16,7 +16,18 @@ function retryDelay(attempt: number): number { return Math.min(5000, 400 * 2 ** attempt); } -function parseErrorBody(status: number, body: string): string { +function looksLikeHtmlResponse(contentType: string, body: string): boolean { + const type = String(contentType || "").toLowerCase(); + if (type.includes("text/html") || type.includes("application/xhtml+xml")) { + return true; + } + return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || "")); +} + +function parseErrorBody(status: number, body: string, contentType: string): string { + if (looksLikeHtmlResponse(contentType, body)) { + return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`; + } const clean = compactErrorText(body); return clean || `HTTP ${status}`; } @@ -45,8 +56,9 @@ export class RealDebridClient { }); const text = await response.text(); + const contentType = String(response.headers.get("content-type") || ""); if (!response.ok) { - const parsed = parseErrorBody(response.status, text); + const parsed = parseErrorBody(response.status, text, contentType); if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { await sleep(retryDelay(attempt)); continue; @@ -54,11 +66,15 @@ export class RealDebridClient { throw new Error(parsed); } + if (looksLikeHtmlResponse(contentType, text)) { + throw new Error("Real-Debrid lieferte HTML statt JSON"); + } + let payload: Record; try { payload = JSON.parse(text) as Record; } catch { - throw new Error(`Ungültige JSON-Antwort: ${text.slice(0, 120)}`); + throw new Error("Ungültige JSON-Antwort von Real-Debrid"); } const directUrl = String(payload.download || payload.link || "").trim(); if (!directUrl) { diff --git a/src/main/storage.ts b/src/main/storage.ts index 51a7b38..7139c79 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; -import { AppSettings, BandwidthScheduleEntry, SessionState } from "../shared/types"; +import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, DownloadStatus, PackageEntry, SessionState } from "../shared/types"; import { defaultSettings } from "./constants"; import { logger } from "./logger"; @@ -12,6 +12,10 @@ const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]); const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]); const VALID_SPEED_MODES = new Set(["global", "per_download"]); const VALID_THEMES = new Set(["dark", "light"]); +const VALID_DOWNLOAD_STATUSES = new Set([ + "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" +]); +const VALID_ITEM_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]); function asText(value: unknown): string { return String(value ?? "").trim(); @@ -148,24 +152,171 @@ function ensureBaseDir(baseDir: string): void { fs.mkdirSync(baseDir, { recursive: true }); } -export function loadSettings(paths: StoragePaths): AppSettings { - ensureBaseDir(paths.baseDir); - if (!fs.existsSync(paths.configFile)) { - return defaultSettings(); +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; } + return value as Record; +} + +function readSettingsFile(filePath: string): AppSettings | null { try { - // Safe: parsed is spread into a fresh object with defaults first, and normalizeSettings - // validates every field, so prototype pollution via __proto__ / constructor is not a concern. - const parsed = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as AppSettings; + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings; const merged = normalizeSettings({ ...defaultSettings(), ...parsed }); return sanitizeCredentialPersistence(merged); - } catch (error) { - logger.error(`Konfiguration konnte nicht geladen werden: ${String(error)}`); + } catch { + return null; + } +} + +function normalizeLoadedSession(raw: unknown): SessionState { + const fallback = emptySession(); + const parsed = asRecord(raw); + if (!parsed) { + return fallback; + } + + const now = Date.now(); + const itemsById: Record = {}; + const rawItems = asRecord(parsed.items) ?? {}; + for (const [entryId, rawItem] of Object.entries(rawItems)) { + const item = asRecord(rawItem); + if (!item) { + continue; + } + const id = asText(item.id) || entryId; + const packageId = asText(item.packageId); + const url = asText(item.url); + if (!id || !packageId || !url) { + continue; + } + + const statusRaw = asText(item.status) as DownloadStatus; + const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; + const providerRaw = asText(item.provider) as DebridProvider; + + itemsById[id] = { + id, + packageId, + url, + provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null, + status, + retries: clampNumber(item.retries, 0, 0, 1_000_000), + speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000), + downloadedBytes: clampNumber(item.downloadedBytes, 0, 0, 10_000_000_000_000), + totalBytes: item.totalBytes == null ? null : clampNumber(item.totalBytes, 0, 0, 10_000_000_000_000), + progressPercent: clampNumber(item.progressPercent, 0, 0, 100), + fileName: asText(item.fileName) || "download.bin", + targetPath: asText(item.targetPath), + resumable: item.resumable === undefined ? true : Boolean(item.resumable), + attempts: clampNumber(item.attempts, 0, 0, 10_000), + lastError: asText(item.lastError), + fullStatus: asText(item.fullStatus), + createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER), + updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) + }; + } + + const packagesById: Record = {}; + const rawPackages = asRecord(parsed.packages) ?? {}; + for (const [entryId, rawPkg] of Object.entries(rawPackages)) { + const pkg = asRecord(rawPkg); + if (!pkg) { + continue; + } + const id = asText(pkg.id) || entryId; + if (!id) { + continue; + } + const statusRaw = asText(pkg.status) as DownloadStatus; + const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; + const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : []; + packagesById[id] = { + id, + name: asText(pkg.name) || "Paket", + outputDir: asText(pkg.outputDir), + extractDir: asText(pkg.extractDir), + status, + itemIds: rawItemIds + .map((value) => asText(value)) + .filter((value) => value.length > 0), + cancelled: Boolean(pkg.cancelled), + enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), + createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), + updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) + }; + } + + for (const [itemId, item] of Object.entries(itemsById)) { + if (!packagesById[item.packageId]) { + delete itemsById[itemId]; + } + } + + for (const pkg of Object.values(packagesById)) { + pkg.itemIds = pkg.itemIds.filter((itemId) => { + const item = itemsById[itemId]; + return Boolean(item) && item.packageId === pkg.id; + }); + } + + const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; + const packageOrder = rawOrder + .map((entry) => asText(entry)) + .filter((id) => id in packagesById); + for (const packageId of Object.keys(packagesById)) { + if (!packageOrder.includes(packageId)) { + packageOrder.push(packageId); + } + } + + return { + ...fallback, + version: clampNumber(parsed.version, fallback.version, 1, 10), + packageOrder, + packages: packagesById, + items: itemsById, + runStartedAt: clampNumber(parsed.runStartedAt, 0, 0, Number.MAX_SAFE_INTEGER), + totalDownloadedBytes: clampNumber(parsed.totalDownloadedBytes, 0, 0, Number.MAX_SAFE_INTEGER), + summaryText: asText(parsed.summaryText), + reconnectUntil: clampNumber(parsed.reconnectUntil, 0, 0, Number.MAX_SAFE_INTEGER), + reconnectReason: asText(parsed.reconnectReason), + paused: Boolean(parsed.paused), + running: Boolean(parsed.running), + updatedAt: clampNumber(parsed.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) + }; +} + +export function loadSettings(paths: StoragePaths): AppSettings { + ensureBaseDir(paths.baseDir); + if (!fs.existsSync(paths.configFile)) { return defaultSettings(); } + const loaded = readSettingsFile(paths.configFile); + if (loaded) { + return loaded; + } + + const backupFile = `${paths.configFile}.bak`; + const backupLoaded = fs.existsSync(backupFile) ? readSettingsFile(backupFile) : null; + if (backupLoaded) { + logger.warn("Konfiguration defekt, Backup-Datei wird verwendet"); + try { + const payload = JSON.stringify(backupLoaded, null, 2); + const tempPath = `${paths.configFile}.tmp`; + fs.writeFileSync(tempPath, payload, "utf8"); + syncRenameWithExdevFallback(tempPath, paths.configFile); + } catch { + // ignore restore write failure + } + return backupLoaded; + } + + logger.error("Konfiguration konnte nicht geladen werden (auch Backup fehlgeschlagen)"); + return defaultSettings(); } function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void { @@ -221,14 +372,8 @@ export function loadSession(paths: StoragePaths): SessionState { return emptySession(); } try { - const parsed = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as Partial; - const session: SessionState = { - ...emptySession(), - ...parsed, - packages: parsed.packages ?? {}, - items: parsed.items ?? {}, - packageOrder: parsed.packageOrder ?? [] - }; + const parsed = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as unknown; + const session = normalizeLoadedSession(parsed); // Reset transient fields that may be stale from a previous crash const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]); @@ -257,29 +402,32 @@ export function saveSession(paths: StoragePaths, session: SessionState): void { } let asyncSaveRunning = false; -let asyncSaveQueued: { paths: StoragePaths; session: SessionState } | null = null; +let asyncSaveQueued: { paths: StoragePaths; payload: string } | null = null; -export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise { +async function writeSessionPayload(paths: StoragePaths, payload: string): Promise { + await fs.promises.mkdir(paths.baseDir, { recursive: true }); + const tempPath = `${paths.sessionFile}.tmp`; + await fsp.writeFile(tempPath, payload, "utf8"); + try { + await fsp.rename(tempPath, paths.sessionFile); + } catch (renameError: unknown) { + if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") { + await fsp.copyFile(tempPath, paths.sessionFile); + await fsp.rm(tempPath, { force: true }).catch(() => {}); + } else { + throw renameError; + } + } +} + +async function saveSessionPayloadAsync(paths: StoragePaths, payload: string): Promise { if (asyncSaveRunning) { - asyncSaveQueued = { paths, session }; + asyncSaveQueued = { paths, payload }; return; } asyncSaveRunning = true; try { - await fs.promises.mkdir(paths.baseDir, { recursive: true }); - const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); - const tempPath = `${paths.sessionFile}.tmp`; - await fsp.writeFile(tempPath, payload, "utf8"); - try { - await fsp.rename(tempPath, paths.sessionFile); - } catch (renameError: unknown) { - if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") { - await fsp.copyFile(tempPath, paths.sessionFile); - await fsp.rm(tempPath, { force: true }).catch(() => {}); - } else { - throw renameError; - } - } + await writeSessionPayload(paths, payload); } catch (error) { logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`); } finally { @@ -287,7 +435,12 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat if (asyncSaveQueued) { const queued = asyncSaveQueued; asyncSaveQueued = null; - void saveSessionAsync(queued.paths, queued.session); + void saveSessionPayloadAsync(queued.paths, queued.payload); } } } + +export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise { + const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); + await saveSessionPayloadAsync(paths, payload); +} diff --git a/src/main/update.ts b/src/main/update.ts index 4e44353..97edafc 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import crypto from "node:crypto"; import { spawn } from "node:child_process"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; @@ -20,6 +21,7 @@ const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; type ReleaseAsset = { name: string; browser_download_url: string; + digest: string; }; export function normalizeUpdateRepo(repo: string): string { @@ -28,6 +30,17 @@ export function normalizeUpdateRepo(repo: string): string { return DEFAULT_UPDATE_REPO; } + const isValidRepoPart = (value: string): boolean => { + const part = String(value || "").trim(); + if (!part || part === "." || part === "..") { + return false; + } + if (part.includes("..")) { + return false; + } + return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(part); + }; + const normalizeParts = (input: string): string => { const cleaned = input .replace(/^https?:\/\/(?:www\.)?github\.com\//i, "") @@ -37,7 +50,11 @@ export function normalizeUpdateRepo(repo: string): string { .replace(/^\/+|\/+$/g, ""); const parts = cleaned.split("/").filter(Boolean); if (parts.length >= 2) { - return `${parts[0]}/${parts[1]}`; + const owner = parts[0]; + const repository = parts[1]; + if (isValidRepoPart(owner) && isValidRepoPart(repository)) { + return `${owner}/${repository}`; + } } return ""; }; @@ -70,6 +87,31 @@ function timeoutController(ms: number): { signal: AbortSignal; clear: () => void }; } +async function readJsonWithTimeout(response: Response, timeoutMs: number): Promise | null> { + let timer: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + void response.body?.cancel().catch(() => undefined); + reject(new Error(`timeout:${timeoutMs}`)); + }, timeoutMs); + }); + + try { + const payload = await Promise.race([ + response.json().catch(() => null) as Promise, + timeoutPromise + ]); + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + return payload as Record; + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + 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) { @@ -116,7 +158,8 @@ function readReleaseAssets(payload: Record): ReleaseAsset[] { return assets .map((asset) => ({ name: String(asset.name || ""), - browser_download_url: String(asset.browser_download_url || "") + browser_download_url: String(asset.browser_download_url || ""), + digest: String(asset.digest || "").trim() })) .filter((asset) => asset.name && asset.browser_download_url); } @@ -145,10 +188,15 @@ function parseReleasePayload(payload: Record, fallback: UpdateC latestTag, releaseUrl, setupAssetUrl: setup?.browser_download_url || "", - setupAssetName: setup?.name || "" + setupAssetName: setup?.name || "", + setupAssetDigest: setup?.digest || "" }; } +function isDraftOrPrereleaseRelease(payload: Record): boolean { + return Boolean(payload.draft) || Boolean(payload.prerelease); +} + async function fetchReleasePayload(safeRepo: string, endpoint: string): Promise<{ ok: boolean; status: number; payload: Record | null }> { const timeout = timeoutController(RELEASE_FETCH_TIMEOUT_MS); let response: Response; @@ -164,7 +212,7 @@ async function fetchReleasePayload(safeRepo: string, endpoint: string): Promise< timeout.clear(); } - const payload = await response.json().catch(() => null) as Record | null; + const payload = await readJsonWithTimeout(response, RELEASE_FETCH_TIMEOUT_MS); return { ok: response.ok, status: response.status, @@ -245,7 +293,40 @@ function deriveUpdateFileName(check: UpdateCheckResult, url: string): string { } } -async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Promise<{ setupAssetUrl: string; setupAssetName: string } | null> { +function normalizeSha256Digest(raw: string): string { + const text = String(raw || "").trim(); + const prefixed = text.match(/^sha256:([a-fA-F0-9]{64})$/i); + if (prefixed) { + return prefixed[1].toLowerCase(); + } + const plain = text.match(/^([a-fA-F0-9]{64})$/); + return plain ? plain[1].toLowerCase() : ""; +} + +async function sha256File(filePath: string): Promise { + const hash = crypto.createHash("sha256"); + const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 }); + return await new Promise((resolve, reject) => { + stream.on("data", (chunk: string | Buffer) => { + hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + }); + stream.on("error", reject); + stream.on("end", () => resolve(hash.digest("hex").toLowerCase())); + }); +} + +async function verifyDownloadedInstaller(targetPath: string, expectedDigestRaw: string): Promise { + const expectedDigest = normalizeSha256Digest(expectedDigestRaw); + if (!expectedDigest) { + throw new Error("Update-Asset ohne gültigen SHA256-Digest"); + } + const actualDigest = await sha256File(targetPath); + if (actualDigest !== expectedDigest) { + throw new Error("Update-Integritätsprüfung fehlgeschlagen (SHA256 mismatch)"); + } +} + +async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Promise<{ setupAssetUrl: string; setupAssetName: string; setupAssetDigest: string } | null> { const endpointCandidates = uniqueStrings([ tagHint ? `releases/tags/${encodeURIComponent(tagHint)}` : "", "releases/latest" @@ -257,13 +338,17 @@ async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Prom if (!release.ok || !release.payload) { continue; } + if (isDraftOrPrereleaseRelease(release.payload)) { + continue; + } const setup = pickSetupAsset(readReleaseAssets(release.payload)); if (!setup) { continue; } return { setupAssetUrl: setup.browser_download_url, - setupAssetName: setup.name + setupAssetName: setup.name, + setupAssetDigest: setup.digest }; } catch { // ignore and continue with next endpoint candidate @@ -433,16 +518,18 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck let effectiveCheck: UpdateCheckResult = { ...check, setupAssetUrl: String(check.setupAssetUrl || ""), - setupAssetName: String(check.setupAssetName || "") + setupAssetName: String(check.setupAssetName || ""), + setupAssetDigest: String(check.setupAssetDigest || "") }; - if (!effectiveCheck.setupAssetUrl) { + if (!effectiveCheck.setupAssetUrl || !effectiveCheck.setupAssetDigest) { const refreshed = await resolveSetupAssetFromApi(safeRepo, effectiveCheck.latestTag); if (refreshed) { effectiveCheck = { ...effectiveCheck, setupAssetUrl: refreshed.setupAssetUrl, - setupAssetName: refreshed.setupAssetName + setupAssetName: refreshed.setupAssetName, + setupAssetDigest: refreshed.setupAssetDigest }; } } @@ -457,6 +544,7 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck try { await downloadFromCandidates(candidates, targetPath); + await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || "")); const child = spawn(targetPath, [], { detached: true, stdio: "ignore" diff --git a/src/main/utils.ts b/src/main/utils.ts index 44c51f6..d9787d3 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -63,6 +63,33 @@ export function isHttpLink(value: string): boolean { } } +export function extractHttpLinksFromText(text: string): string[] { + const matches = String(text || "").match(/https?:\/\/[^\s<>"']+/gi) ?? []; + const seen = new Set(); + const links: string[] = []; + + for (const match of matches) { + let candidate = String(match || "").trim(); + while (candidate.length > 0 && /[)\],.!?;:]+$/.test(candidate)) { + if (candidate.endsWith(")")) { + const openCount = (candidate.match(/\(/g) || []).length; + const closeCount = (candidate.match(/\)/g) || []).length; + if (closeCount <= openCount) { + break; + } + } + candidate = candidate.slice(0, -1); + } + if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) { + continue; + } + seen.add(candidate); + links.push(candidate); + } + + return links; +} + export function humanSize(bytes: number): string { const value = Number(bytes); if (!Number.isFinite(value) || value < 0) { @@ -84,6 +111,9 @@ export function humanSize(bytes: number): string { export function filenameFromUrl(url: string): string { try { const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "download.bin"; + } const queryName = parsed.searchParams.get("filename") || parsed.searchParams.get("file") || parsed.searchParams.get("name") diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a386e9e..359cbdd 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -128,6 +128,7 @@ export function App(): ReactElement { const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); const activeCollectorTabRef = useRef(activeCollectorTab); const activeTabRef = useRef(tab); + const packageOrderRef = useRef([]); const draggedPackageIdRef = useRef(null); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); @@ -150,6 +151,10 @@ export function App(): ReactElement { activeTabRef.current = tab; }, [tab]); + useEffect(() => { + packageOrderRef.current = snapshot.session.packageOrder; + }, [snapshot.session.packageOrder]); + const showToast = (message: string, timeoutMs = 2200): void => { setStatusToast(message); if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } @@ -647,24 +652,28 @@ export function App(): ReactElement { }; const movePackage = useCallback((packageId: string, direction: "up" | "down") => { - const order = [...snapshot.session.packageOrder]; + const currentOrder = packageOrderRef.current; + const order = [...currentOrder]; const idx = order.indexOf(packageId); if (idx < 0) { return; } const target = direction === "up" ? idx - 1 : idx + 1; if (target < 0 || target >= order.length) { return; } [order[idx], order[target]] = [order[target], order[idx]]; + packageOrderRef.current = order; void window.rd.reorderPackages(order); - }, [snapshot.session.packageOrder]); + }, []); const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => { - 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]); + const currentOrder = packageOrderRef.current; + const nextOrder = reorderPackageOrderByDrop(currentOrder, draggedPackageId, targetPackageId); + const unchanged = nextOrder.length === currentOrder.length + && nextOrder.every((id, index) => id === currentOrder[index]); if (unchanged) { return; } + packageOrderRef.current = nextOrder; void window.rd.reorderPackages(nextOrder); - }, [snapshot.session.packageOrder]); + }, []); const addCollectorTab = (): void => { const id = `tab-${nextCollectorId++}`; @@ -888,7 +897,9 @@ export function App(): ReactElement { onClick={() => { const nextDescending = !downloadsSortDescending; setDownloadsSortDescending(nextDescending); - const sorted = sortPackageOrderByName(snapshot.session.packageOrder, snapshot.session.packages, nextDescending); + const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder; + const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending); + packageOrderRef.current = sorted; void window.rd.reorderPackages(sorted); }} > @@ -1021,6 +1032,7 @@ export function App(): ReactElement { +