diff --git a/package.json b/package.json index 9c762a8..a236f19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.16", + "version": "1.4.17", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 319c680..b4863b8 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -134,6 +134,13 @@ function isFetchFailure(errorText: string): boolean { return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error"); } +function isUnrestrictFailure(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("unrestrict") || text.includes("mega-web") || text.includes("mega-debrid") + || text.includes("bestdebrid") || text.includes("alldebrid") || text.includes("kein debrid") + || text.includes("session") || text.includes("login"); +} + function isFinishedStatus(status: DownloadStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; } @@ -304,7 +311,7 @@ export class DownloadManager extends EventEmitter { return { settings: this.settings, - session: this.session, + session: cloneSession(this.session), summary: this.summary, stats: this.getStats(now), speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, @@ -494,6 +501,10 @@ export class DownloadManager extends EventEmitter { public clearAll(): void { this.stop(); this.abortPostProcessing("clear_all"); + if (this.stateEmitTimer) { + clearTimeout(this.stateEmitTimer); + this.stateEmitTimer = null; + } this.session.packageOrder = []; this.session.packages = {}; this.session.items = {}; @@ -1394,8 +1405,8 @@ export class DownloadManager extends EventEmitter { } const parsed = path.parse(preferredPath); - let index = 0; - while (true) { + const maxIndex = 10000; + for (let index = 0; index <= maxIndex; index += 1) { const candidate = index === 0 ? preferredPath : path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`); @@ -1408,8 +1419,11 @@ export class DownloadManager extends EventEmitter { this.claimedTargetPathByItem.set(itemId, candidate); return candidate; } - index += 1; } + logger.error(`claimTargetPath: Limit erreicht für ${preferredPath}`); + this.reservedTargetPaths.set(pathKey(preferredPath), itemId); + this.claimedTargetPathByItem.set(itemId, preferredPath); + return preferredPath; } private releaseTargetPath(itemId: string): void { @@ -1804,6 +1818,9 @@ export class DownloadManager extends EventEmitter { if (!item || !pkg || pkg.cancelled || !pkg.enabled) { return; } + if (this.activeTasks.has(itemId)) { + return; + } item.status = "validating"; item.fullStatus = "Link wird umgewandelt"; @@ -1844,7 +1861,9 @@ export class DownloadManager extends EventEmitter { let freshRetryUsed = false; let stallRetries = 0; let genericErrorRetries = 0; + let unrestrictRetries = 0; const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES); + const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES); while (true) { try { const unrestricted = await this.debridService.unrestrictLink(item.url); @@ -2039,6 +2058,23 @@ export class DownloadManager extends EventEmitter { continue; } + if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) { + unrestrictRetries += 1; + item.retries += 1; + item.status = "queued"; + item.fullStatus = `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`; + item.lastError = errorText; + item.attempts = 0; + item.speedBps = 0; + item.updatedAt = nowMs(); + active.abortController = new AbortController(); + active.abortReason = "none"; + this.persistSoon(); + this.emitState(); + await sleep(Math.min(8000, 2000 * unrestrictRetries)); + continue; + } + if (genericErrorRetries < maxGenericErrorRetries) { genericErrorRetries += 1; item.retries += 1; @@ -2728,6 +2764,10 @@ export class DownloadManager extends EventEmitter { updateExtractingStatus("Entpacken 0%"); this.emitState(); + const extractTimeoutMs = 4 * 60 * 60 * 1000; + const extractDeadline = setTimeout(() => { + logger.error(`Post-Processing Extraction Timeout nach 4h: pkg=${pkg.name}`); + }, extractTimeoutMs); try { const result = await extractPackageArchives({ packageDir: pkg.outputDir, @@ -2754,6 +2794,7 @@ 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"); @@ -2783,6 +2824,7 @@ 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) { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 77cf005..56ca0ec 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -232,11 +232,16 @@ function readExtractResumeState(packageDir: string): Set { } function writeExtractResumeState(packageDir: string, completedArchives: Set): void { - const progressPath = extractProgressFilePath(packageDir); - const payload: ExtractResumeState = { - completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b)) - }; - fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8"); + try { + fs.mkdirSync(packageDir, { recursive: true }); + const progressPath = extractProgressFilePath(packageDir); + const payload: ExtractResumeState = { + completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b)) + }; + fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8"); + } catch (error) { + logger.warn(`ExtractResumeState schreiben fehlgeschlagen: ${String(error)}`); + } } function clearExtractResumeState(packageDir: string): void { @@ -582,8 +587,13 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode: const mode = effectiveConflictMode(conflictMode); const zip = new AdmZip(archivePath); const entries = zip.getEntries(); + const resolvedTarget = path.resolve(targetDir); for (const entry of entries) { - const outputPath = path.join(targetDir, entry.entryName); + const outputPath = path.resolve(targetDir, entry.entryName); + if (!outputPath.startsWith(resolvedTarget + path.sep) && outputPath !== resolvedTarget) { + logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`); + continue; + } if (entry.isDirectory) { fs.mkdirSync(outputPath, { recursive: true }); continue; diff --git a/src/main/logger.ts b/src/main/logger.ts index fe51947..f53f7c3 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -5,6 +5,8 @@ let logFilePath = path.resolve(process.cwd(), "rd_downloader.log"); let fallbackLogFilePath: string | null = null; const LOG_FLUSH_INTERVAL_MS = 120; const LOG_BUFFER_LIMIT_CHARS = 1_000_000; +const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024; +let lastRotateCheckAt = 0; let pendingLines: string[] = []; let pendingChars = 0; @@ -90,6 +92,29 @@ function scheduleFlush(immediate = false): void { }, LOG_FLUSH_INTERVAL_MS); } +function rotateIfNeeded(filePath: string): void { + try { + const now = Date.now(); + if (now - lastRotateCheckAt < 60_000) { + return; + } + lastRotateCheckAt = now; + const stat = fs.statSync(filePath); + if (stat.size < LOG_MAX_FILE_BYTES) { + return; + } + const backup = `${filePath}.old`; + try { + fs.rmSync(backup, { force: true }); + } catch { + // ignore + } + fs.renameSync(filePath, backup); + } catch { + // ignore - file may not exist yet + } +} + async function flushAsync(): Promise { if (flushInFlight || pendingLines.length === 0) { return; @@ -101,6 +126,7 @@ async function flushAsync(): Promise { pendingChars = 0; try { + rotateIfNeeded(logFilePath); const primary = await appendChunk(logFilePath, chunk); if (fallbackLogFilePath) { const fallback = await appendChunk(fallbackLogFilePath, chunk); diff --git a/src/main/main.ts b/src/main/main.ts index 0dfb86c..06e3042 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -6,6 +6,20 @@ import { IPC_CHANNELS } from "../shared/ipc"; import { logger } from "./logger"; import { APP_NAME } from "./constants"; +/* ── Single Instance Lock ───────────────────────────────────────── */ +const gotLock = app.requestSingleInstanceLock(); +if (!gotLock) { + app.quit(); +} + +/* ── Unhandled error protection ─────────────────────────────────── */ +process.on("uncaughtException", (error) => { + logger.error(`Uncaught Exception: ${String(error?.stack || error)}`); +}); +process.on("unhandledRejection", (reason) => { + logger.error(`Unhandled Rejection: ${String(reason)}`); +}); + let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; let clipboardTimer: ReturnType | null = null; @@ -202,6 +216,16 @@ function registerIpcHandlers(): void { }; } +app.on("second-instance", () => { + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.show(); + mainWindow.focus(); + } +}); + app.whenReady().then(() => { registerIpcHandlers(); mainWindow = createWindow(); @@ -216,6 +240,10 @@ app.whenReady().then(() => { } }); + mainWindow.on("closed", () => { + mainWindow = null; + }); + app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createWindow(); diff --git a/src/main/storage.ts b/src/main/storage.ts index c55817d..c857e1e 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -226,7 +226,16 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); const tempPath = `${paths.sessionFile}.tmp`; await fsp.writeFile(tempPath, payload, "utf8"); - await fsp.rename(tempPath, paths.sessionFile); + try { + await fsp.rename(tempPath, paths.sessionFile); + } catch (renameError: unknown) { + if ((renameError as NodeJS.ErrnoException).code === "EXDEV") { + await fsp.copyFile(tempPath, paths.sessionFile); + await fsp.rm(tempPath, { force: true }).catch(() => {}); + } else { + throw renameError; + } + } } catch (error) { logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`); } finally {