Release v1.4.17 with security fixes, stability hardening and retry improvements

- Fix ZIP path traversal vulnerability (reject entries escaping target dir)
- Add single instance lock (prevent data corruption from multiple instances)
- Add unhandled exception/rejection handlers (prevent silent crashes)
- Fix mainWindow reference cleanup on close
- Add second-instance handler to focus existing window
- Fix claimTargetPath infinite loop (add 10k iteration bound)
- Add duplicate startItem guard (prevent concurrent downloads of same item)
- Clone session in getSnapshot to prevent live-reference mutation bugs
- Clear stateEmitTimer on clearAll to prevent dangling timer emissions
- Add extraction timeout safety (4h deadline with logging)
- Add dedicated unrestrict retry system with longer backoff for Mega-Debrid errors
- Add log rotation (10MB max, keeps one .old backup)
- Fix writeExtractResumeState missing mkdir (prevents crash on deleted dirs)
- Fix saveSessionAsync EXDEV cross-device rename with copy fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe
2026-02-28 05:04:21 +01:00
co-authored by Claude Opus 4.6
parent ea6301d326
commit d4dd266f6b
6 changed files with 127 additions and 12 deletions
+46 -4
View File
@@ -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) {