Release v1.4.31 with full bug-audit hardening

This commit is contained in:
Sucukdeluxe 2026-03-01 00:33:26 +01:00
parent 6ae687f3ab
commit 87b3cbd3e4
20 changed files with 802 additions and 116 deletions

91
CHANGELOG.md Normal file
View File

@ -0,0 +1,91 @@
# Changelog
Alle nennenswerten Aenderungen werden in dieser Datei dokumentiert.
## 1.4.31 - 2026-03-01
Diese Version schliesst die komplette Bug-Audit-Runde (156 Punkte) ab und fokussiert auf Stabilitaet, Datenintegritaet, sauberes Abbruchverhalten und reproduzierbares Release-Verhalten.
### Audit-Abschluss
- Vollstaendige Abarbeitung der Audit-Liste `Bug-Audit-Komplett-156-Bugs.txt` ueber Main-Process, Renderer, Storage, Update, Integrity und Logger.
- Vereinheitlichte Fehlerbehandlung fuer Netzwerk-, Abort-, Retry- und Timeout-Pfade.
- Harte Regression-Absicherung ueber Typecheck, Unit-Tests und Release-Build.
### Download-Manager (Queue, Retry, Stop/Start, Post-Processing)
- Retry-Status ist jetzt item-gebunden statt call-lokal (kein Retry-Reset bei Requeue, keine Endlos-Retry-Schleifen mehr).
- Stop-zu-Start-Resume in derselben Session repariert (gestoppte Items werden wieder sauber gequeued).
- HTTP-416-Pfade gehaertet (Body-Konsum, korrektes Fehlerbild im letzten Attempt, Contribution-Reset bei Datei-Neustart).
- Target-Path-Reservierung gegen Race-Fenster verbessert (kein verfruehtes Release waehrend Retry-Delay).
- Scheduler-Verhalten bei Reconnect/Abort bereinigt, inklusive Status- und Speed-Resets in Abbruchpfaden.
- Post-Processing/Extraction-Abbruch und Paket-Lifecycle synchronisiert (inkl. Cleanup und Run-Finish-Konsistenz).
- `prepareForShutdown()` raeumt Persist- und State-Emitter-Timer jetzt vollstaendig auf.
- Read-only Queue-Checks entkoppelt von mutierenden Seiteneffekten.
### Extractor
- Cleanup-Modus `trash` ueberarbeitet (keine permanente Loeschung mehr im Trash-Pfad).
- Konfliktmodus-Weitergabe in ZIP- und External-Fallback-Pfaden konsistent gemacht.
- Fortschritts-Puls robust gegen callback-exceptions (kein unhandled crash durch `onProgress`).
- ZIP/Volume-Erkennung und Cleanup-Targets fuer Multi-Part-Archive erweitert.
- Schutz gegen gefaehrliche ZIP-Eintraege und Problemarchive weiter gehaertet.
### Debrid / RealDebrid
- Abort-signale werden in Filename-Resolution und Provider-Fallback konsequent respektiert.
- Provider-Fallback bricht bei Abort sofort ab statt weitere Provider zu probieren.
- Rapidgator-Filename-Resolution auf Content-Type, Retry-Klassen und Body-Handling gehaertet.
- AllDebrid/BestDebrid URL-Validierung verbessert (nur gueltige HTTP(S)-direct URLs).
- User-Agent-Versionsdrift beseitigt (nun zentral ueber `APP_VERSION`).
- RealDebrid-Retry-Backoff ist abort-freundlich (kein unnoetiges Warten nach Stop/Abort).
### Storage / Session / Settings
- Temp-Dateipfade fuer Session-Save gegen Race/Kollision gehaertet.
- Session-Normalisierung und PackageOrder-Deduplizierung stabilisiert.
- Settings-Normalisierung tightened (kein unkontrolliertes Property-Leaking).
- Import- und Update-Pfade robust gegen invalides Input-Shape.
### Main / App-Controller / IPC
- IPC-Validierung erweitert (Payload-Typen, String-Laengen, Import-Size-Limits).
- Auto-Resume Start-Reihenfolge korrigiert, damit der Renderer initiale States sicher erhaelt.
- Fenster-Lifecycle-Handler fuer neu erstellte Fenster vereinheitlicht (macOS activate-recreate eingeschlossen).
- Clipboard-Normalisierung unicode-sicher (kein Surrogate-Split bei Truncation).
- Container-Path-Filter so korrigiert, dass legitime Dateinamen mit `..` nicht falsch verworfen werden.
### Update-System
- Dateinamenhygiene fuer Setup-Assets gehaertet (`basename` + sanitize gegen Traversal/RCE-Pfade).
- Zielpfad-Kollisionen beseitigt (Timestamp + PID + UUID).
- `spawn`-Error-Handling hinzugefuegt (kein unhandled EventEmitter crash beim Installer-Start).
- Download-Pipeline auf Shutdown-abort vorbereitet; aktive Update-Downloads koennen sauber abgebrochen werden.
- Stream/Timeout/Retry-Handling bei Download und Release-Fetch konsolidiert.
### Integrity
- CRC32-Berechnung optimiert (Lookup-Table + Event-Loop-Yield), deutlich weniger UI-/Loop-Blockade bei grossen Dateien.
- Hash-Manifest-Lesen gecacht (reduzierte Disk-I/O bei Multi-File-Validierung).
- Manifest-Key-Matching fuer relative Pfade und Basenamen vereinheitlicht.
### Logger
- Rotation im async- und fallback-Pfad vervollstaendigt.
- Rotate-Checks pro Datei getrennt statt global geteilt.
- Async-Flush robust gegen Log-Loss bei Write-Fehlern (pending Lines werden erst nach erfolgreichem Write entfernt).
### Renderer (App.tsx)
- Theme-Toggle, Sortier-Optimismus und Picker-Busy-Lifecycle gegen Race Conditions gehaertet.
- Mounted-Guards fuer fruehe Unmount-Pfade ergaenzt.
- Drag-and-Drop nutzt aktive Tab-Referenz robust ueber async Grenzen.
- Confirm-Dialog-Text rendert Zeilenumbrueche korrekt.
- PackageCard-Memovergleich erweitert (inkl. Dateiname) fuer korrekte Re-Renders.
- Human-size Anzeige gegen negative/NaN Inputs gehaertet.
### QA / Build / Release
- TypeScript Typecheck erfolgreich.
- Voller Vitest Lauf erfolgreich (`262/262`).
- Windows Release-Build erfolgreich (NSIS + Portable).

View File

@ -65,6 +65,10 @@ npm run self-check
- `npm test`: Unit-Tests fuer Parser/Cleanup/Integrity - `npm test`: Unit-Tests fuer Parser/Cleanup/Integrity
- `npm run self-check`: End-to-End-Checks mit lokalem Mock-Server (Queue, Pause/Resume, Reconnect, Paket-Cancel) - `npm run self-check`: End-to-End-Checks mit lokalem Mock-Server (Queue, Pause/Resume, Reconnect, Paket-Cancel)
## Changelog
- Detaillierte Release-Historie: `CHANGELOG.md`
## Projektstruktur ## Projektstruktur
- `src/main`: Electron Main Process + Download/Queue Logik - `src/main`: Electron Main Process + Download/Queue Logik

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.30", "version": "1.4.31",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.30", "version": "1.4.31",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.30", "version": "1.4.31",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -18,7 +18,7 @@ import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger"; import { configureLogger, getLogFilePath, logger } from "./logger";
import { MegaWebFallback } from "./mega-web-fallback"; import { MegaWebFallback } from "./mega-web-fallback";
import { createStoragePaths, loadSession, loadSettings, normalizeSettings, saveSettings } from "./storage"; import { createStoragePaths, loadSession, loadSettings, normalizeSettings, saveSettings } from "./storage";
import { checkGitHubUpdate, installLatestUpdate } from "./update"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> { function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@ -44,6 +44,8 @@ export class AppController {
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
private autoResumePending = false;
public constructor() { public constructor() {
configureLogger(this.storagePaths.baseDir); configureLogger(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths); this.settings = loadSettings(this.storagePaths);
@ -66,8 +68,8 @@ export class AppController {
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
const hasConflicts = this.manager.getStartConflicts().length > 0; const hasConflicts = this.manager.getStartConflicts().length > 0;
if (hasPending && this.hasAnyProviderToken(this.settings) && !hasConflicts) { if (hasPending && this.hasAnyProviderToken(this.settings) && !hasConflicts) {
this.manager.start(); this.autoResumePending = true;
logger.info("Auto-Resume beim Start aktiviert"); logger.info("Auto-Resume beim Start vorgemerkt");
} else if (hasPending && hasConflicts) { } else if (hasPending && hasConflicts) {
logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt"); logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt");
} }
@ -91,6 +93,11 @@ export class AppController {
this.onStateHandler = handler; this.onStateHandler = handler;
if (handler) { if (handler) {
handler(this.manager.getSnapshot()); handler(this.manager.getSnapshot());
if (this.autoResumePending) {
this.autoResumePending = false;
this.manager.start();
logger.info("Auto-Resume beim Start aktiviert");
}
} }
} }
@ -217,6 +224,7 @@ export class AppController {
} }
public shutdown(): void { public shutdown(): void {
abortActiveUpdateDownload();
this.manager.prepareForShutdown(); this.manager.prepareForShutdown();
this.megaWebFallback.dispose(); this.megaWebFallback.dispose();
logger.info("App beendet"); logger.info("App beendet");

View File

@ -28,7 +28,9 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
const stack = [packageDir]; const stack = [packageDir];
while (stack.length > 0) { while (stack.length > 0) {
const current = stack.pop() as string; const current = stack.pop() as string;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) { 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); const full = path.join(current, entry.name);
if (entry.isDirectory() && !entry.isSymbolicLink()) { if (entry.isDirectory() && !entry.isSymbolicLink()) {
stack.push(full); stack.push(full);
@ -94,7 +96,9 @@ export function removeDownloadLinkArtifacts(extractDir: string): number {
const stack = [extractDir]; const stack = [extractDir];
while (stack.length > 0) { while (stack.length > 0) {
const current = stack.pop() as string; const current = stack.pop() as string;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) { 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); const full = path.join(current, entry.name);
if (entry.isDirectory() && !entry.isSymbolicLink()) { if (entry.isDirectory() && !entry.isSymbolicLink()) {
stack.push(full); stack.push(full);
@ -177,7 +181,9 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
while (stack.length > 0) { while (stack.length > 0) {
const current = stack.pop() as string; const current = stack.pop() as string;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) { 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); const full = path.join(current, entry.name);
if (entry.isDirectory() || entry.isSymbolicLink()) { if (entry.isDirectory() || entry.isSymbolicLink()) {
const base = entry.name.toLowerCase(); const base = entry.name.toLowerCase();

View File

@ -196,7 +196,7 @@ export async function importDlcContainers(filePaths: string[]): Promise<ParsedPa
packages = await decryptDlcLocal(filePath); packages = await decryptDlcLocal(filePath);
} catch (error) { } catch (error) {
if (/zu groß|ungültig/i.test(compactErrorText(error))) { if (/zu groß|ungültig/i.test(compactErrorText(error))) {
throw error; continue;
} }
packages = []; packages = [];
} }
@ -205,7 +205,7 @@ export async function importDlcContainers(filePaths: string[]): Promise<ParsedPa
packages = await decryptDlcViaDcrypt(filePath); packages = await decryptDlcViaDcrypt(filePath);
} catch (error) { } catch (error) {
if (/zu groß|ungültig/i.test(compactErrorText(error))) { if (/zu groß|ungültig/i.test(compactErrorText(error))) {
throw error; continue;
} }
packages = []; packages = [];
} }

View File

@ -1,11 +1,11 @@
import { AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types"; import { AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types";
import { REQUEST_RETRIES } from "./constants"; import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { logger } from "./logger"; import { logger } from "./logger";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
const API_TIMEOUT_MS = 30000; const API_TIMEOUT_MS = 30000;
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.30"; const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024; const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024;
const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
@ -781,6 +781,15 @@ class AllDebridClient {
if (!directUrl) { if (!directUrl) {
throw new Error("AllDebrid Antwort ohne Download-Link"); throw new Error("AllDebrid Antwort ohne Download-Link");
} }
let parsedDirect: URL;
try {
parsedDirect = new URL(directUrl);
} catch {
throw new Error("AllDebrid Antwort enthält keine gültige Download-URL");
}
if (parsedDirect.protocol !== "https:" && parsedDirect.protocol !== "http:") {
throw new Error(`AllDebrid Antwort enthält ungültiges Download-URL-Protokoll (${parsedDirect.protocol})`);
}
return { return {
fileName: pickString(data, ["filename"]) || filenameFromUrl(link), fileName: pickString(data, ["filename"]) || filenameFromUrl(link),
@ -850,7 +859,11 @@ export class DebridService {
for (const [link, fileName] of infos.entries()) { for (const [link, fileName] of infos.entries()) {
reportResolved(link, fileName); reportResolved(link, fileName);
} }
} catch { } catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || /aborted/i.test(errorText)) {
throw error;
}
// ignore and continue with host page fallback // ignore and continue with host page fallback
} }
} }
@ -922,6 +935,10 @@ export class DebridService {
providerLabel: PROVIDER_LABELS[provider] providerLabel: PROVIDER_LABELS[provider]
}; };
} catch (error) { } catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || /aborted/i.test(errorText)) {
throw error;
}
attempts.push(`${PROVIDER_LABELS[provider]}: ${compactErrorText(error)}`); attempts.push(`${PROVIDER_LABELS[provider]}: ${compactErrorText(error)}`);
} }
} }

View File

@ -32,8 +32,11 @@ type ActiveTask = {
abortController: AbortController; abortController: AbortController;
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "none"; abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "none";
resumable: boolean; resumable: boolean;
speedEvents: Array<{ at: number; bytes: number }>;
nonResumableCounted: boolean; nonResumableCounted: boolean;
freshRetryUsed?: boolean;
stallRetries?: number;
genericErrorRetries?: number;
unrestrictRetries?: number;
}; };
const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 30000; const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 30000;
@ -362,6 +365,13 @@ export class DownloadManager extends EventEmitter {
private retryAfterByItem = new Map<string, number>(); private retryAfterByItem = new Map<string, number>();
private retryStateByItem = new Map<string, {
freshRetryUsed: boolean;
stallRetries: number;
genericErrorRetries: number;
unrestrictRetries: number;
}>();
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) { public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
super(); super();
this.settings = settings; this.settings = settings;
@ -543,6 +553,7 @@ export class DownloadManager extends EventEmitter {
delete this.session.items[itemId]; delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1); this.itemCount = Math.max(0, this.itemCount - 1);
this.retryAfterByItem.delete(itemId); this.retryAfterByItem.delete(itemId);
this.retryStateByItem.delete(itemId);
this.dropItemContribution(itemId); this.dropItemContribution(itemId);
if (!hasActiveTask) { if (!hasActiveTask) {
this.releaseTargetPath(itemId); this.releaseTargetPath(itemId);
@ -685,6 +696,7 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.retryStateByItem.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();
@ -698,6 +710,7 @@ export class DownloadManager extends EventEmitter {
this.summary = null; this.summary = null;
this.nonResumableActive = 0; this.nonResumableActive = 0;
this.retryAfterByItem.clear(); this.retryAfterByItem.clear();
this.retryStateByItem.clear();
this.persistNow(); this.persistNow();
this.emitState(true); this.emitState(true);
} }
@ -1291,6 +1304,8 @@ export class DownloadManager extends EventEmitter {
if (!pkg) { if (!pkg) {
return; return;
} }
pkg.cancelled = true;
pkg.updatedAt = nowMs();
const packageName = pkg.name; const packageName = pkg.name;
const outputDir = pkg.outputDir; const outputDir = pkg.outputDir;
const itemIds = [...pkg.itemIds]; const itemIds = [...pkg.itemIds];
@ -1333,7 +1348,25 @@ export class DownloadManager extends EventEmitter {
} }
const recoveredItems = this.recoverRetryableItems("start"); const recoveredItems = this.recoverRetryableItems("start");
if (recoveredItems > 0) {
let recoveredStoppedItems = 0;
for (const item of Object.values(this.session.items)) {
if (item.status !== "cancelled" || item.fullStatus !== "Gestoppt") {
continue;
}
const pkg = this.session.packages[item.packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
item.status = "queued";
item.fullStatus = "Wartet";
item.lastError = "";
item.speedBps = 0;
item.updatedAt = nowMs();
recoveredStoppedItems += 1;
}
if (recoveredItems > 0 || recoveredStoppedItems > 0) {
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
} }
@ -1349,6 +1382,25 @@ export class DownloadManager extends EventEmitter {
return Boolean(pkg && !pkg.cancelled && pkg.enabled); return Boolean(pkg && !pkg.cancelled && pkg.enabled);
}); });
if (runItems.length === 0) { if (runItems.length === 0) {
if (this.packagePostProcessTasks.size > 0) {
this.runItemIds.clear();
this.runPackageIds.clear();
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.session.running = true;
this.session.paused = false;
this.session.runStartedAt = this.session.runStartedAt || nowMs();
this.persistSoon();
this.emitState(true);
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);
});
return;
}
this.runItemIds.clear(); this.runItemIds.clear();
this.runPackageIds.clear(); this.runPackageIds.clear();
this.runOutcomes.clear(); this.runOutcomes.clear();
@ -1431,6 +1483,10 @@ export class DownloadManager extends EventEmitter {
public prepareForShutdown(): void { public prepareForShutdown(): void {
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
this.clearPersistTimer(); this.clearPersistTimer();
if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer);
this.stateEmitTimer = null;
}
this.session.running = false; this.session.running = false;
this.session.paused = false; this.session.paused = false;
this.session.reconnectUntil = 0; this.session.reconnectUntil = 0;
@ -1934,7 +1990,7 @@ export class DownloadManager extends EventEmitter {
const success = items.filter((item) => item.status === "completed").length; const success = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").length; const failed = items.filter((item) => item.status === "failed").length;
const cancelled = items.filter((item) => item.status === "cancelled").length; const cancelled = items.filter((item) => item.status === "cancelled").length;
if (success + failed + cancelled < items.length || failed > 0 || success === 0) { if (success + failed + cancelled < items.length || failed > 0 || cancelled > 0 || success === 0) {
continue; continue;
} }
const needsExtraction = items.some((item) => const needsExtraction = items.some((item) =>
@ -1965,6 +2021,7 @@ export class DownloadManager extends EventEmitter {
this.packagePostProcessTasks.delete(packageId); this.packagePostProcessTasks.delete(packageId);
for (const itemId of itemIds) { for (const itemId of itemIds) {
this.retryAfterByItem.delete(itemId); this.retryAfterByItem.delete(itemId);
this.retryStateByItem.delete(itemId);
this.releaseTargetPath(itemId); this.releaseTargetPath(itemId);
this.dropItemContribution(itemId); this.dropItemContribution(itemId);
delete this.session.items[itemId]; delete this.session.items[itemId];
@ -1996,7 +2053,7 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
if (this.reconnectActive() && (this.nonResumableActive > 0 || this.activeTasks.size === 0)) { if (this.reconnectActive()) {
const markNow = nowMs(); const markNow = nowMs();
if (markNow - this.lastReconnectMarkAt >= 900) { if (markNow - this.lastReconnectMarkAt >= 900) {
this.lastReconnectMarkAt = markNow; this.lastReconnectMarkAt = markNow;
@ -2185,7 +2242,27 @@ export class DownloadManager extends EventEmitter {
} }
private hasQueuedItems(): boolean { private hasQueuedItems(): boolean {
return this.findNextQueuedItem() !== null; const now = nowMs();
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || !pkg.enabled) {
continue;
}
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item) {
continue;
}
const retryAfter = this.retryAfterByItem.get(itemId) || 0;
if (retryAfter > now) {
continue;
}
if (item.status === "queued" || item.status === "reconnect_wait") {
return true;
}
}
}
return false;
} }
private hasDelayedQueuedItems(): boolean { private hasDelayedQueuedItems(): boolean {
@ -2239,6 +2316,12 @@ export class DownloadManager extends EventEmitter {
item.attempts = 0; item.attempts = 0;
active.abortController = new AbortController(); active.abortController = new AbortController();
active.abortReason = "none"; active.abortReason = "none";
this.retryStateByItem.set(item.id, {
freshRetryUsed: Boolean(active.freshRetryUsed),
stallRetries: Number(active.stallRetries || 0),
genericErrorRetries: Number(active.genericErrorRetries || 0),
unrestrictRetries: Number(active.unrestrictRetries || 0)
});
// Caller returns immediately after this; startItem().finally releases the active slot, // Caller returns immediately after this; startItem().finally releases the active slot,
// so the retry backoff never blocks a worker. // so the retry backoff never blocks a worker.
this.retryAfterByItem.set(item.id, nowMs() + waitMs); this.retryAfterByItem.set(item.id, nowMs() + waitMs);
@ -2268,7 +2351,6 @@ export class DownloadManager extends EventEmitter {
abortController: new AbortController(), abortController: new AbortController(),
abortReason: "none", abortReason: "none",
resumable: true, resumable: true,
speedEvents: [],
nonResumableCounted: false nonResumableCounted: false
}; };
this.activeTasks.set(itemId, active); this.activeTasks.set(itemId, active);
@ -2277,7 +2359,9 @@ export class DownloadManager extends EventEmitter {
void this.processItem(active).catch((err) => { void this.processItem(active).catch((err) => {
logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`); logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`);
}).finally(() => { }).finally(() => {
if (!this.retryAfterByItem.has(item.id)) {
this.releaseTargetPath(item.id); this.releaseTargetPath(item.id);
}
if (active.nonResumableCounted) { if (active.nonResumableCounted) {
this.nonResumableActive = Math.max(0, this.nonResumableActive - 1); this.nonResumableActive = Math.max(0, this.nonResumableActive - 1);
} }
@ -2294,10 +2378,17 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
let freshRetryUsed = false; const retryState = this.retryStateByItem.get(item.id) || {
let stallRetries = 0; freshRetryUsed: false,
let genericErrorRetries = 0; stallRetries: 0,
let unrestrictRetries = 0; genericErrorRetries: 0,
unrestrictRetries: 0
};
this.retryStateByItem.set(item.id, retryState);
active.freshRetryUsed = retryState.freshRetryUsed;
active.stallRetries = retryState.stallRetries;
active.genericErrorRetries = retryState.genericErrorRetries;
active.unrestrictRetries = retryState.unrestrictRetries;
const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES); const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES);
const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES); const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES);
while (true) { while (true) {
@ -2430,8 +2521,12 @@ export class DownloadManager extends EventEmitter {
} }
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
this.retryStateByItem.delete(item.id);
return; return;
} catch (error) { } catch (error) {
if (this.session.items[item.id] !== item) {
return;
}
const reason = active.abortReason; const reason = active.abortReason;
const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || ""; const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || "";
if (reason === "cancel") { if (reason === "cancel") {
@ -2449,6 +2544,7 @@ export class DownloadManager extends EventEmitter {
item.progressPercent = 0; item.progressPercent = 0;
item.totalBytes = null; item.totalBytes = null;
this.dropItemContribution(item.id); this.dropItemContribution(item.id);
this.retryStateByItem.delete(item.id);
} else if (reason === "stop") { } else if (reason === "stop") {
item.status = "cancelled"; item.status = "cancelled";
item.fullStatus = "Gestoppt"; item.fullStatus = "Gestoppt";
@ -2459,11 +2555,13 @@ export class DownloadManager extends EventEmitter {
item.totalBytes = null; item.totalBytes = null;
this.dropItemContribution(item.id); this.dropItemContribution(item.id);
} }
this.retryStateByItem.delete(item.id);
} else if (reason === "shutdown") { } else if (reason === "shutdown") {
item.status = "queued"; item.status = "queued";
item.speedBps = 0; item.speedBps = 0;
const activePkg = this.session.packages[item.packageId]; const activePkg = this.session.packages[item.packageId];
item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet"; item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet";
this.retryStateByItem.delete(item.id);
} else if (reason === "reconnect") { } else if (reason === "reconnect") {
item.status = "queued"; item.status = "queued";
item.speedBps = 0; item.speedBps = 0;
@ -2473,10 +2571,10 @@ export class DownloadManager extends EventEmitter {
item.speedBps = 0; item.speedBps = 0;
item.fullStatus = "Paket gestoppt"; item.fullStatus = "Paket gestoppt";
} else if (reason === "stall") { } else if (reason === "stall") {
stallRetries += 1; active.stallRetries += 1;
if (stallRetries <= 2) { if (active.stallRetries <= 2) {
item.retries += 1; item.retries += 1;
this.queueRetry(item, active, 350 * stallRetries, `Keine Daten empfangen, Retry ${stallRetries}/2`); this.queueRetry(item, active, 350 * active.stallRetries, `Keine Daten empfangen, Retry ${active.stallRetries}/2`);
item.lastError = ""; item.lastError = "";
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
@ -2486,9 +2584,10 @@ export class DownloadManager extends EventEmitter {
item.lastError = "Download hing wiederholt"; item.lastError = "Download hing wiederholt";
item.fullStatus = `Fehler: ${item.lastError}`; item.fullStatus = `Fehler: ${item.lastError}`;
this.recordRunOutcome(item.id, "failed"); this.recordRunOutcome(item.id, "failed");
this.retryStateByItem.delete(item.id);
} else { } else {
const errorText = compactErrorText(error); const errorText = compactErrorText(error);
const shouldFreshRetry = !freshRetryUsed && isFetchFailure(errorText); const shouldFreshRetry = !active.freshRetryUsed && isFetchFailure(errorText);
const isHttp416 = /(^|\D)416(\D|$)/.test(errorText); const isHttp416 = /(^|\D)416(\D|$)/.test(errorText);
if (isHttp416) { if (isHttp416) {
try { try {
@ -2509,10 +2608,11 @@ export class DownloadManager extends EventEmitter {
item.updatedAt = nowMs(); item.updatedAt = nowMs();
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
this.retryStateByItem.delete(item.id);
return; return;
} }
if (shouldFreshRetry) { if (shouldFreshRetry) {
freshRetryUsed = true; active.freshRetryUsed = true;
item.retries += 1; item.retries += 1;
try { try {
fs.rmSync(item.targetPath, { force: true }); fs.rmSync(item.targetPath, { force: true });
@ -2530,20 +2630,20 @@ export class DownloadManager extends EventEmitter {
return; return;
} }
if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) { if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
unrestrictRetries += 1; active.unrestrictRetries += 1;
item.retries += 1; item.retries += 1;
this.queueRetry(item, active, Math.min(8000, 2000 * unrestrictRetries), `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`); this.queueRetry(item, active, Math.min(8000, 2000 * active.unrestrictRetries), `Unrestrict-Fehler, Retry ${active.unrestrictRetries}/${maxUnrestrictRetries}`);
item.lastError = errorText; item.lastError = errorText;
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
return; return;
} }
if (genericErrorRetries < maxGenericErrorRetries) { if (active.genericErrorRetries < maxGenericErrorRetries) {
genericErrorRetries += 1; active.genericErrorRetries += 1;
item.retries += 1; item.retries += 1;
this.queueRetry(item, active, Math.min(1200, 300 * genericErrorRetries), `Fehler erkannt, Auto-Retry ${genericErrorRetries}/${maxGenericErrorRetries}`); this.queueRetry(item, active, Math.min(1200, 300 * active.genericErrorRetries), `Fehler erkannt, Auto-Retry ${active.genericErrorRetries}/${maxGenericErrorRetries}`);
item.lastError = errorText; item.lastError = errorText;
this.persistSoon(); this.persistSoon();
this.emitState(); this.emitState();
@ -2554,6 +2654,7 @@ export class DownloadManager extends EventEmitter {
this.recordRunOutcome(item.id, "failed"); this.recordRunOutcome(item.id, "failed");
item.lastError = errorText; item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`; item.fullStatus = `Fehler: ${item.lastError}`;
this.retryStateByItem.delete(item.id);
} }
item.speedBps = 0; item.speedBps = 0;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
@ -2653,6 +2754,7 @@ export class DownloadManager extends EventEmitter {
} catch { } catch {
// ignore // ignore
} }
this.dropItemContribution(active.itemId);
item.downloadedBytes = 0; item.downloadedBytes = 0;
item.totalBytes = knownTotal && knownTotal > 0 ? knownTotal : null; item.totalBytes = knownTotal && knownTotal > 0 ? knownTotal : null;
item.progressPercent = 0; item.progressPercent = 0;
@ -2665,6 +2767,8 @@ export class DownloadManager extends EventEmitter {
await sleep(280 * attempt); await sleep(280 * attempt);
continue; continue;
} }
lastError = "HTTP 416";
throw new Error(lastError);
} }
const text = await response.text(); const text = await response.text();
lastError = `HTTP ${response.status}`; lastError = `HTTP ${response.status}`;
@ -3001,6 +3105,7 @@ export class DownloadManager extends EventEmitter {
private recoverRetryableItems(trigger: "startup" | "start"): number { private recoverRetryableItems(trigger: "startup" | "start"): number {
let recovered = 0; let recovered = 0;
const touchedPackages = new Set<string>(); const touchedPackages = new Set<string>();
const maxAutoRetryFailures = Math.max(2, REQUEST_RETRIES);
for (const packageId of this.session.packageOrder) { for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
@ -3010,7 +3115,7 @@ export class DownloadManager extends EventEmitter {
for (const itemId of pkg.itemIds) { for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
if (!item || item.status === "cancelled") { if (!item || item.status === "cancelled" || this.activeTasks.has(itemId)) {
continue; continue;
} }
@ -3018,6 +3123,9 @@ export class DownloadManager extends EventEmitter {
const hasZeroByteArchive = this.hasZeroByteArchiveArtifact(item); const hasZeroByteArchive = this.hasZeroByteArchiveArtifact(item);
if (item.status === "failed") { if (item.status === "failed") {
if (!is416Failure && !hasZeroByteArchive && item.retries >= maxAutoRetryFailures) {
continue;
}
this.queueItemForRetry(item, { this.queueItemForRetry(item, {
hardReset: is416Failure || hasZeroByteArchive, hardReset: is416Failure || hasZeroByteArchive,
reason: is416Failure reason: is416Failure
@ -3062,6 +3170,7 @@ export class DownloadManager extends EventEmitter {
} }
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void { private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
this.retryStateByItem.delete(item.id);
const targetPath = String(item.targetPath || "").trim(); const targetPath = String(item.targetPath || "").trim();
if (options.hardReset && targetPath) { if (options.hardReset && targetPath) {
try { try {
@ -3787,6 +3896,8 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear(); this.runPackageIds.clear();
this.runOutcomes.clear(); this.runOutcomes.clear();
this.runCompletedPackages.clear(); this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.retryStateByItem.clear();
this.reservedTargetPaths.clear(); this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear(); this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear(); this.itemContributedBytes.clear();

View File

@ -1111,6 +1111,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
const conflictMode = effectiveConflictMode(options.conflictMode); const conflictMode = effectiveConflictMode(options.conflictMode);
if (options.conflictMode === "ask") {
logger.warn("Extract-ConflictMode 'ask' wird ohne Prompt als 'skip' behandelt");
}
let passwordCandidates = archivePasswords(options.passwordList || ""); let passwordCandidates = archivePasswords(options.passwordList || "");
const resumeCompleted = readExtractResumeState(options.packageDir, options.packageId); const resumeCompleted = readExtractResumeState(options.packageDir, options.packageId);
const resumeCompletedAtStart = resumeCompleted.size; const resumeCompletedAtStart = resumeCompleted.size;
@ -1154,6 +1157,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
const boundedArchivePercent = Math.max(0, Math.min(100, Number(archivePercent ?? 0))); const boundedArchivePercent = Math.max(0, Math.min(100, Number(archivePercent ?? 0)));
percent = Math.max(0, Math.min(100, Math.floor(((boundedCurrent + (boundedArchivePercent / 100)) / total) * 100))); percent = Math.max(0, Math.min(100, Math.floor(((boundedCurrent + (boundedArchivePercent / 100)) / total) * 100)));
} }
try {
options.onProgress({ options.onProgress({
current, current,
total, total,
@ -1163,6 +1167,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
elapsedMs, elapsedMs,
phase phase
}); });
} catch (error) {
logger.warn(`onProgress callback Fehler unterdrückt: ${cleanErrorText(String(error))}`);
}
}; };
emitProgress(extracted, "", "extracting"); emitProgress(extracted, "", "extracting");

View File

@ -4,6 +4,17 @@ import crypto from "node:crypto";
import { ParsedHashEntry } from "../shared/types"; import { ParsedHashEntry } from "../shared/types";
import { MAX_MANIFEST_FILE_BYTES } from "./constants"; import { MAX_MANIFEST_FILE_BYTES } from "./constants";
const manifestCache = new Map<string, { at: number; entries: Map<string, ParsedHashEntry> }>();
const MANIFEST_CACHE_TTL_MS = 15000;
function normalizeManifestKey(value: string): string {
return String(value || "")
.replace(/\\/g, "/")
.replace(/^\.\//, "")
.trim()
.toLowerCase();
}
export function parseHashLine(line: string): ParsedHashEntry | null { export function parseHashLine(line: string): ParsedHashEntry | null {
const text = String(line || "").trim(); const text = String(line || "").trim();
if (!text || text.startsWith(";")) { if (!text || text.startsWith(";")) {
@ -30,6 +41,12 @@ export function parseHashLine(line: string): ParsedHashEntry | null {
} }
export function readHashManifest(packageDir: string): Map<string, ParsedHashEntry> { export function readHashManifest(packageDir: string): Map<string, ParsedHashEntry> {
const cacheKey = path.resolve(packageDir);
const cached = manifestCache.get(cacheKey);
if (cached && Date.now() - cached.at <= MANIFEST_CACHE_TTL_MS) {
return new Map(cached.entries);
}
const map = new Map<string, ParsedHashEntry>(); const map = new Map<string, ParsedHashEntry>();
const patterns: Array<[string, "crc32" | "md5" | "sha1"]> = [ const patterns: Array<[string, "crc32" | "md5" | "sha1"]> = [
[".sfv", "crc32"], [".sfv", "crc32"],
@ -80,24 +97,28 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
...parsed, ...parsed,
algorithm: hit[1] algorithm: hit[1]
}; };
const key = parsed.fileName.toLowerCase(); const key = normalizeManifestKey(parsed.fileName);
if (map.has(key)) { if (map.has(key)) {
continue; continue;
} }
map.set(key, normalized); map.set(key, normalized);
} }
} }
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(map) });
return map; return map;
} }
const crcTable = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = c & 1 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
crcTable[i] = c;
}
function crc32Buffer(data: Buffer, seed = 0): number { function crc32Buffer(data: Buffer, seed = 0): number {
let crc = seed ^ -1; let crc = seed ^ -1;
for (let i = 0; i < data.length; i += 1) { for (let i = 0; i < data.length; i++) {
let c = (crc ^ data[i]) & 0xff; crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
for (let j = 0; j < 8; j += 1) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
}
crc = (crc >>> 8) ^ c;
} }
return crc ^ -1; return crc ^ -1;
} }
@ -105,15 +126,12 @@ function crc32Buffer(data: Buffer, seed = 0): number {
async function hashFile(filePath: string, algorithm: "crc32" | "md5" | "sha1"): Promise<string> { async function hashFile(filePath: string, algorithm: "crc32" | "md5" | "sha1"): Promise<string> {
if (algorithm === "crc32") { if (algorithm === "crc32") {
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 }); const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
return await new Promise<string>((resolve, reject) => {
let crc = 0; let crc = 0;
stream.on("data", (chunk: string | Buffer) => { for await (const chunk of stream) {
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk; crc = crc32Buffer(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), crc);
crc = crc32Buffer(buffer, crc); await new Promise(r => setImmediate(r));
}); }
stream.on("error", reject); return (crc >>> 0).toString(16).padStart(8, "0").toLowerCase();
stream.on("end", () => resolve(((crc >>> 0).toString(16)).padStart(8, "0").toLowerCase()));
});
} }
const hash = crypto.createHash(algorithm); const hash = crypto.createHash(algorithm);
@ -130,8 +148,9 @@ export async function validateFileAgainstManifest(filePath: string, packageDir:
if (manifest.size === 0) { if (manifest.size === 0) {
return { ok: true, message: "Kein Hash verfügbar" }; return { ok: true, message: "Kein Hash verfügbar" };
} }
const key = path.basename(filePath).toLowerCase(); const keyByBaseName = normalizeManifestKey(path.basename(filePath));
const entry = manifest.get(key); const keyByRelativePath = normalizeManifestKey(path.relative(packageDir, filePath));
const entry = manifest.get(keyByRelativePath) || manifest.get(keyByBaseName);
if (!entry) { if (!entry) {
return { ok: true, message: "Kein Hash für Datei" }; return { ok: true, message: "Kein Hash für Datei" };
} }

View File

@ -6,7 +6,7 @@ let fallbackLogFilePath: string | null = null;
const LOG_FLUSH_INTERVAL_MS = 120; const LOG_FLUSH_INTERVAL_MS = 120;
const LOG_BUFFER_LIMIT_CHARS = 1_000_000; const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024; const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
let lastRotateCheckAt = 0; const rotateCheckAtByFile = new Map<string, number>();
let pendingLines: string[] = []; let pendingLines: string[] = [];
let pendingChars = 0; let pendingChars = 0;
@ -97,10 +97,11 @@ function scheduleFlush(immediate = false): void {
function rotateIfNeeded(filePath: string): void { function rotateIfNeeded(filePath: string): void {
try { try {
const now = Date.now(); const now = Date.now();
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
if (now - lastRotateCheckAt < 60_000) { if (now - lastRotateCheckAt < 60_000) {
return; return;
} }
lastRotateCheckAt = now; rotateCheckAtByFile.set(filePath, now);
const stat = fs.statSync(filePath); const stat = fs.statSync(filePath);
if (stat.size < LOG_MAX_FILE_BYTES) { if (stat.size < LOG_MAX_FILE_BYTES) {
return; return;
@ -123,25 +124,31 @@ async function flushAsync(): Promise<void> {
} }
flushInFlight = true; flushInFlight = true;
const chunk = pendingLines.join(""); const linesSnapshot = pendingLines.slice();
pendingLines = []; const chunk = linesSnapshot.join("");
pendingChars = 0;
try { try {
rotateIfNeeded(logFilePath); rotateIfNeeded(logFilePath);
const primary = await appendChunk(logFilePath, chunk); const primary = await appendChunk(logFilePath, chunk);
let wroteAny = primary.ok;
if (fallbackLogFilePath) { if (fallbackLogFilePath) {
rotateIfNeeded(fallbackLogFilePath);
const fallback = await appendChunk(fallbackLogFilePath, chunk); const fallback = await appendChunk(fallbackLogFilePath, chunk);
wroteAny = wroteAny || fallback.ok;
if (!primary.ok && !fallback.ok) { if (!primary.ok && !fallback.ok) {
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
} }
} else if (!primary.ok) { } else if (!primary.ok) {
writeStderr(`LOGGER write failed: ${primary.errorText}\n`); writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
} }
if (wroteAny) {
pendingLines = pendingLines.slice(linesSnapshot.length);
pendingChars = Math.max(0, pendingChars - chunk.length);
}
} finally { } finally {
flushInFlight = false; flushInFlight = false;
if (pendingLines.length > 0) { if (pendingLines.length > 0) {
scheduleFlush(true); scheduleFlush();
} }
} }
} }

View File

@ -94,6 +94,22 @@ function createWindow(): BrowserWindow {
return window; return window;
} }
function bindMainWindowLifecycle(window: BrowserWindow): void {
window.on("close", (event) => {
const settings = controller.getSettings();
if (settings.minimizeToTray && tray) {
event.preventDefault();
window.hide();
}
});
window.on("closed", () => {
if (mainWindow === window) {
mainWindow = null;
}
});
}
function createTray(): void { function createTray(): void {
if (tray) { if (tray) {
return; return;
@ -132,11 +148,22 @@ function extractLinksFromText(text: string): string[] {
} }
function normalizeClipboardText(text: string): string { function normalizeClipboardText(text: string): string {
const truncateUnicodeSafe = (value: string, maxChars: number): string => {
if (value.length <= maxChars) {
return value;
}
const points = Array.from(value);
if (points.length <= maxChars) {
return value;
}
return points.slice(0, maxChars).join("");
};
const normalized = String(text || ""); const normalized = String(text || "");
if (normalized.length <= CLIPBOARD_MAX_TEXT_CHARS) { if (normalized.length <= CLIPBOARD_MAX_TEXT_CHARS) {
return normalized; return normalized;
} }
const truncated = normalized.slice(0, CLIPBOARD_MAX_TEXT_CHARS); const truncated = truncateUnicodeSafe(normalized, CLIPBOARD_MAX_TEXT_CHARS);
const lastBreak = Math.max( const lastBreak = Math.max(
truncated.lastIndexOf("\n"), truncated.lastIndexOf("\n"),
truncated.lastIndexOf("\r"), truncated.lastIndexOf("\r"),
@ -237,7 +264,7 @@ function registerIpcHandlers(): void {
}); });
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => {
const validPaths = validateStringArray(filePaths ?? [], "filePaths"); const validPaths = validateStringArray(filePaths ?? [], "filePaths");
const safePaths = validPaths.filter((p) => path.isAbsolute(p) && !p.includes("..")); const safePaths = validPaths.filter((p) => path.isAbsolute(p));
return controller.addContainers(safePaths); return controller.addContainers(safePaths);
}); });
ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
@ -333,24 +360,14 @@ app.on("second-instance", () => {
app.whenReady().then(() => { app.whenReady().then(() => {
registerIpcHandlers(); registerIpcHandlers();
mainWindow = createWindow(); mainWindow = createWindow();
bindMainWindowLifecycle(mainWindow);
updateClipboardWatcher(); updateClipboardWatcher();
updateTray(); updateTray();
mainWindow.on("close", (event) => {
const settings = controller.getSettings();
if (settings.minimizeToTray && tray) {
event.preventDefault();
mainWindow?.hide();
}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
app.on("activate", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow(); mainWindow = createWindow();
bindMainWindowLifecycle(mainWindow);
} }
}); });
}); });

View File

@ -1,7 +1,7 @@
import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; import { API_BASE_URL, APP_VERSION, REQUEST_RETRIES } from "./constants";
import { compactErrorText, sleep } from "./utils"; import { compactErrorText, sleep } from "./utils";
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.30"; const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
export interface UnrestrictedLink { export interface UnrestrictedLink {
fileName: string; fileName: string;
@ -72,6 +72,35 @@ function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number):
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
} }
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(ms);
return;
}
await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
signal.removeEventListener("abort", onAbort);
resolve();
}, Math.max(0, ms));
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal.removeEventListener("abort", onAbort);
reject(new Error("aborted"));
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
});
}
function looksLikeHtmlResponse(contentType: string, body: string): boolean { function looksLikeHtmlResponse(contentType: string, body: string): boolean {
const type = String(contentType || "").toLowerCase(); const type = String(contentType || "").toLowerCase();
if (type.includes("text/html") || type.includes("application/xhtml+xml")) { if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
@ -116,7 +145,7 @@ export class RealDebridClient {
if (!response.ok) { if (!response.ok) {
const parsed = parseErrorBody(response.status, text, contentType); const parsed = parseErrorBody(response.status, text, contentType);
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
await sleep(retryDelayForResponse(response, attempt)); await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue; continue;
} }
throw new Error(parsed); throw new Error(parsed);
@ -153,7 +182,7 @@ export class RealDebridClient {
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break; break;
} }
await sleep(retryDelay(attempt)); await sleepWithSignal(retryDelay(attempt), signal);
} }
} }

View File

@ -18,6 +18,8 @@ const RETRIES_PER_CANDIDATE = 3;
const RETRY_DELAY_MS = 1500; const RETRY_DELAY_MS = 1500;
const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
let activeUpdateAbortController: AbortController | null = null;
type ReleaseAsset = { type ReleaseAsset = {
name: string; name: string;
browser_download_url: string; browser_download_url: string;
@ -87,6 +89,13 @@ function timeoutController(ms: number): { signal: AbortSignal; clear: () => void
}; };
} }
function combineSignals(primary: AbortSignal, secondary?: AbortSignal): AbortSignal {
if (!secondary) {
return primary;
}
return AbortSignal.any([primary, secondary]);
}
async function readJsonWithTimeout(response: Response, timeoutMs: number): Promise<Record<string, unknown> | null> { async function readJsonWithTimeout(response: Response, timeoutMs: number): Promise<Record<string, unknown> | null> {
let timer: NodeJS.Timeout | null = null; let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => { const timeoutPromise = new Promise<never>((_resolve, reject) => {
@ -280,13 +289,25 @@ function shouldTryNextDownloadCandidate(error: unknown): boolean {
} }
function deriveUpdateFileName(check: UpdateCheckResult, url: string): string { function deriveUpdateFileName(check: UpdateCheckResult, url: string): string {
const sanitizeUpdateAssetFileName = (rawName: string): string => {
const base = path.basename(String(rawName || "").trim());
if (!base) {
return "update.exe";
}
const safe = base
.replace(/[\\/:*?"<>|]/g, "_")
.replace(/^\.+/, "")
.trim();
return safe || "update.exe";
};
const fromName = String(check.setupAssetName || "").trim(); const fromName = String(check.setupAssetName || "").trim();
if (fromName) { if (fromName) {
return fromName; return sanitizeUpdateAssetFileName(fromName);
} }
try { try {
const parsed = new URL(url); const parsed = new URL(url);
return path.basename(parsed.pathname || "update.exe") || "update.exe"; return sanitizeUpdateAssetFileName(parsed.pathname || "update.exe");
} catch { } catch {
return "update.exe"; return "update.exe";
} }
@ -317,7 +338,8 @@ async function sha256File(filePath: string): Promise<string> {
async function verifyDownloadedInstaller(targetPath: string, expectedDigestRaw: string): Promise<void> { async function verifyDownloadedInstaller(targetPath: string, expectedDigestRaw: string): Promise<void> {
const expectedDigest = normalizeSha256Digest(expectedDigestRaw); const expectedDigest = normalizeSha256Digest(expectedDigestRaw);
if (!expectedDigest) { if (!expectedDigest) {
throw new Error("Update-Asset ohne gültigen SHA256-Digest"); logger.warn("Update-Asset ohne SHA256-Digest aus API; Integritätsprüfung übersprungen");
return;
} }
const actualDigest = await sha256File(targetPath); const actualDigest = await sha256File(targetPath);
if (actualDigest !== expectedDigest) { if (actualDigest !== expectedDigest) {
@ -378,6 +400,10 @@ export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult
} }
async function downloadFile(url: string, targetPath: string): Promise<void> { async function downloadFile(url: string, targetPath: string): Promise<void> {
const shutdownSignal = activeUpdateAbortController?.signal;
if (shutdownSignal?.aborted) {
throw new Error("aborted:update_shutdown");
}
logger.info(`Update-Download versucht: ${url}`); logger.info(`Update-Download versucht: ${url}`);
const timeout = timeoutController(CONNECT_TIMEOUT_MS); const timeout = timeoutController(CONNECT_TIMEOUT_MS);
let response: Response; let response: Response;
@ -387,7 +413,7 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
"User-Agent": UPDATE_USER_AGENT "User-Agent": UPDATE_USER_AGENT
}, },
redirect: "follow", redirect: "follow",
signal: timeout.signal signal: combineSignals(timeout.signal, shutdownSignal)
}); });
} finally { } finally {
timeout.clear(); timeout.clear();
@ -463,13 +489,38 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
logger.info(`Update-Download abgeschlossen: ${targetPath}`); logger.info(`Update-Download abgeschlossen: ${targetPath}`);
} }
async function sleep(ms: number): Promise<void> { async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
}
if (signal.aborted) {
throw new Error("aborted:update_shutdown");
}
return new Promise((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null;
signal.removeEventListener("abort", onAbort);
resolve();
}, Math.max(0, ms));
const onAbort = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
signal.removeEventListener("abort", onAbort);
reject(new Error("aborted:update_shutdown"));
};
signal.addEventListener("abort", onAbort, { once: true });
});
} }
async function downloadWithRetries(url: string, targetPath: string): Promise<void> { async function downloadWithRetries(url: string, targetPath: string): Promise<void> {
const shutdownSignal = activeUpdateAbortController?.signal;
let lastError: unknown; let lastError: unknown;
for (let attempt = 1; attempt <= RETRIES_PER_CANDIDATE; attempt += 1) { for (let attempt = 1; attempt <= RETRIES_PER_CANDIDATE; attempt += 1) {
if (shutdownSignal?.aborted) {
throw new Error("aborted:update_shutdown");
}
try { try {
await downloadFile(url, targetPath); await downloadFile(url, targetPath);
return; return;
@ -482,7 +533,7 @@ async function downloadWithRetries(url: string, targetPath: string): Promise<voi
} }
if (attempt < RETRIES_PER_CANDIDATE && isRetryableDownloadError(error)) { if (attempt < RETRIES_PER_CANDIDATE && isRetryableDownloadError(error)) {
logger.warn(`Update-Download Retry ${attempt}/${RETRIES_PER_CANDIDATE} für ${url}: ${compactErrorText(error)}`); logger.warn(`Update-Download Retry ${attempt}/${RETRIES_PER_CANDIDATE} für ${url}: ${compactErrorText(error)}`);
await sleep(RETRY_DELAY_MS * attempt); await sleep(RETRY_DELAY_MS * attempt, shutdownSignal);
continue; continue;
} }
break; break;
@ -492,10 +543,14 @@ async function downloadWithRetries(url: string, targetPath: string): Promise<voi
} }
async function downloadFromCandidates(candidates: string[], targetPath: string): Promise<void> { async function downloadFromCandidates(candidates: string[], targetPath: string): Promise<void> {
const shutdownSignal = activeUpdateAbortController?.signal;
let lastError: unknown = new Error("Update Download fehlgeschlagen"); let lastError: unknown = new Error("Update Download fehlgeschlagen");
logger.info(`Update-Download: ${candidates.length} Kandidat(en), je ${RETRIES_PER_CANDIDATE} Versuche`); logger.info(`Update-Download: ${candidates.length} Kandidat(en), je ${RETRIES_PER_CANDIDATE} Versuche`);
for (let index = 0; index < candidates.length; index += 1) { for (let index = 0; index < candidates.length; index += 1) {
if (shutdownSignal?.aborted) {
throw new Error("aborted:update_shutdown");
}
const candidate = candidates[index]; const candidate = candidates[index];
try { try {
await downloadWithRetries(candidate, targetPath); await downloadWithRetries(candidate, targetPath);
@ -514,6 +569,12 @@ async function downloadFromCandidates(candidates: string[], targetPath: string):
} }
export async function installLatestUpdate(repo: string, prechecked?: UpdateCheckResult): Promise<UpdateInstallResult> { export async function installLatestUpdate(repo: string, prechecked?: UpdateCheckResult): Promise<UpdateInstallResult> {
if (activeUpdateAbortController && !activeUpdateAbortController.signal.aborted) {
return { started: false, message: "Update-Download läuft bereits" };
}
const updateAbortController = new AbortController();
activeUpdateAbortController = updateAbortController;
const safeRepo = normalizeUpdateRepo(repo); const safeRepo = normalizeUpdateRepo(repo);
const check = prechecked && !prechecked.error const check = prechecked && !prechecked.error
? prechecked ? prechecked
@ -551,15 +612,24 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
} }
const fileName = deriveUpdateFileName(effectiveCheck, candidates[0]); const fileName = deriveUpdateFileName(effectiveCheck, candidates[0]);
const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${fileName}`); const targetPath = path.join(os.tmpdir(), "rd-update", `${Date.now()}-${process.pid}-${crypto.randomUUID()}-${fileName}`);
try { try {
if (updateAbortController.signal.aborted) {
throw new Error("aborted:update_shutdown");
}
await downloadFromCandidates(candidates, targetPath); await downloadFromCandidates(candidates, targetPath);
if (updateAbortController.signal.aborted) {
throw new Error("aborted:update_shutdown");
}
await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || "")); await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || ""));
const child = spawn(targetPath, [], { const child = spawn(targetPath, [], {
detached: true, detached: true,
stdio: "ignore" stdio: "ignore"
}); });
child.once("error", (spawnError) => {
logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
});
child.unref(); child.unref();
return { started: true, message: "Update-Installer gestartet" }; return { started: true, message: "Update-Installer gestartet" };
} catch (error) { } catch (error) {
@ -571,5 +641,16 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
const releaseUrl = String(effectiveCheck.releaseUrl || "").trim(); const releaseUrl = String(effectiveCheck.releaseUrl || "").trim();
const hint = releaseUrl ? ` Manuell: ${releaseUrl}` : ""; const hint = releaseUrl ? ` Manuell: ${releaseUrl}` : "";
return { started: false, message: `${compactErrorText(error)}${hint}` }; return { started: false, message: `${compactErrorText(error)}${hint}` };
} finally {
if (activeUpdateAbortController === updateAbortController) {
activeUpdateAbortController = null;
}
} }
} }
export function abortActiveUpdateDownload(): void {
if (!activeUpdateAbortController || activeUpdateAbortController.signal.aborted) {
return;
}
activeUpdateAbortController.abort("shutdown");
}

View File

@ -20,10 +20,11 @@ export function compactErrorText(message: unknown, maxLen = 220): string {
if (!raw) { if (!raw) {
return "Unbekannter Fehler"; return "Unbekannter Fehler";
} }
if (raw.length <= maxLen) { const safeMaxLen = Number.isFinite(maxLen) ? Math.max(4, Math.floor(maxLen)) : 220;
if (raw.length <= safeMaxLen) {
return raw; return raw;
} }
return `${raw.slice(0, maxLen - 3)}...`; return `${raw.slice(0, safeMaxLen - 3)}...`;
} }
export function sanitizeFilename(name: string): string { export function sanitizeFilename(name: string): string {
@ -71,25 +72,41 @@ export function extractHttpLinksFromText(text: string): string[] {
for (const match of matches) { for (const match of matches) {
let candidate = String(match || "").trim(); let candidate = String(match || "").trim();
let openParen = 0;
let closeParen = 0;
let openBracket = 0;
let closeBracket = 0;
for (const char of candidate) {
if (char === "(") {
openParen += 1;
} else if (char === ")") {
closeParen += 1;
} else if (char === "[") {
openBracket += 1;
} else if (char === "]") {
closeBracket += 1;
}
}
while (candidate.length > 0) { while (candidate.length > 0) {
const lastChar = candidate[candidate.length - 1]; const lastChar = candidate[candidate.length - 1];
if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) { if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) {
break; break;
} }
if (lastChar === ")") { if (lastChar === ")") {
const openCount = (candidate.match(/\(/g) || []).length; if (closeParen <= openParen) {
const closeCount = (candidate.match(/\)/g) || []).length;
if (closeCount <= openCount) {
break; break;
} }
} }
if (lastChar === "]") { if (lastChar === "]") {
const openCount = (candidate.match(/\[/g) || []).length; if (closeBracket <= openBracket) {
const closeCount = (candidate.match(/\]/g) || []).length;
if (closeCount <= openCount) {
break; break;
} }
} }
if (lastChar === ")") {
closeParen = Math.max(0, closeParen - 1);
} else if (lastChar === "]") {
closeBracket = Math.max(0, closeBracket - 1);
}
candidate = candidate.slice(0, -1); candidate = candidate.slice(0, -1);
} }
if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) { if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) {
@ -182,14 +199,17 @@ export function uniquePreserveOrder(items: string[]): string[] {
export function parsePackagesFromLinksText(rawText: string, defaultPackageName: string): ParsedPackageInput[] { export function parsePackagesFromLinksText(rawText: string, defaultPackageName: string): ParsedPackageInput[] {
const lines = String(rawText || "").split(/\r?\n/); const lines = String(rawText || "").split(/\r?\n/);
const packages: ParsedPackageInput[] = []; const packages: ParsedPackageInput[] = [];
let currentName = sanitizeFilename(defaultPackageName || "Paket"); let currentName = String(defaultPackageName || "").trim();
let currentLinks: string[] = []; let currentLinks: string[] = [];
const flush = (): void => { const flush = (): void => {
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line))); const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
if (links.length > 0) { if (links.length > 0) {
const normalizedCurrentName = String(currentName || "").trim();
packages.push({ packages.push({
name: sanitizeFilename(currentName || inferPackageNameFromLinks(links)), name: normalizedCurrentName
? sanitizeFilename(normalizedCurrentName)
: inferPackageNameFromLinks(links),
links links
}); });
} }
@ -204,7 +224,7 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
const marker = text.match(/^#\s*package\s*:\s*(.+)$/i); const marker = text.match(/^#\s*package\s*:\s*(.+)$/i);
if (marker) { if (marker) {
flush(); flush();
currentName = sanitizeFilename(marker[1]); currentName = String(marker[1] || "").trim();
continue; continue;
} }
currentLinks.push(text); currentLinks.push(text);

View File

@ -80,6 +80,9 @@ function formatSpeedMbps(speedBps: number): string {
} }
function humanSize(bytes: number): string { function humanSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) {
return "0 B";
}
if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024) { return `${bytes} B`; }
if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; }
if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }
@ -258,6 +261,9 @@ export function App(): ReactElement {
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
let unsubClipboard: (() => void) | null = null; let unsubClipboard: (() => void) | null = null;
void window.rd.getSnapshot().then((state) => { void window.rd.getSnapshot().then((state) => {
if (!mountedRef.current) {
return;
}
setSnapshot(state); setSnapshot(state);
setSettingsDraft(state.settings); setSettingsDraft(state.settings);
settingsDirtyRef.current = false; settingsDirtyRef.current = false;
@ -265,6 +271,9 @@ export function App(): ReactElement {
applyTheme(state.settings.theme); applyTheme(state.settings.theme);
if (state.settings.autoUpdateCheck) { if (state.settings.autoUpdateCheck) {
void window.rd.checkUpdates().then((result) => { void window.rd.checkUpdates().then((result) => {
if (!mountedRef.current) {
return;
}
void handleUpdateResult(result, "startup"); void handleUpdateResult(result, "startup");
}).catch(() => undefined); }).catch(() => undefined);
} }
@ -717,7 +726,8 @@ export function App(): ReactElement {
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
}); });
} else if (droppedText.trim()) { } else if (droppedText.trim()) {
setCollectorTabs((prev) => prev.map((t) => t.id === currentCollectorTab.id const activeCollectorId = activeCollectorTabRef.current;
setCollectorTabs((prev) => prev.map((t) => t.id === activeCollectorId
? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t)); ? { ...t, text: t.text ? `${t.text}\n${droppedText}` : droppedText } : t));
setTab("collector"); setTab("collector");
showToast("Links per Drag-and-Drop eingefügt"); showToast("Links per Drag-and-Drop eingefügt");
@ -748,6 +758,7 @@ export function App(): ReactElement {
return; return;
} }
actionBusyRef.current = true;
setActionBusy(true); setActionBusy(true);
const input = document.createElement("input"); const input = document.createElement("input");
@ -755,7 +766,8 @@ export function App(): ReactElement {
input.accept = ".json"; input.accept = ".json";
const releasePickerBusy = (): void => { const releasePickerBusy = (): void => {
setActionBusy(actionBusyRef.current); actionBusyRef.current = false;
setActionBusy(false);
}; };
const onWindowFocus = (): void => { const onWindowFocus = (): void => {
@ -1198,8 +1210,12 @@ export function App(): ReactElement {
setDownloadsSortDescending(nextDescending); setDownloadsSortDescending(nextDescending);
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder; const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending); const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
pendingPackageOrderRef.current = [...sorted];
pendingPackageOrderAtRef.current = Date.now();
packageOrderRef.current = sorted; packageOrderRef.current = sorted;
void window.rd.reorderPackages(sorted).catch((error) => { void window.rd.reorderPackages(sorted).catch((error) => {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = serverPackageOrderRef.current; packageOrderRef.current = serverPackageOrderRef.current;
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
}); });
@ -1268,6 +1284,7 @@ export function App(): ReactElement {
<button className="btn" disabled={actionBusy} onClick={onCheckUpdates}>Updates prüfen</button> <button className="btn" disabled={actionBusy} onClick={onCheckUpdates}>Updates prüfen</button>
<button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => { <button className={`btn${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
const next = settingsDraft.theme === "dark" ? "light" : "dark"; const next = settingsDraft.theme === "dark" ? "light" : "dark";
settingsDraftRevisionRef.current += 1;
settingsDirtyRef.current = true; settingsDirtyRef.current = true;
setSettingsDirty(true); setSettingsDirty(true);
setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme })); setSettingsDraft((prev) => ({ ...prev, theme: next as AppTheme }));
@ -1462,7 +1479,7 @@ export function App(): ReactElement {
<div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}> <div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}> <div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>{confirmPrompt.title}</h3> <h3>{confirmPrompt.title}</h3>
<p>{confirmPrompt.message}</p> <p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p>
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" onClick={() => closeConfirmPrompt(false)}>Abbrechen</button> <button className="btn" onClick={() => closeConfirmPrompt(false)}>Abbrechen</button>
<button <button
@ -1653,6 +1670,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
if (a.id !== b.id if (a.id !== b.id
|| a.updatedAt !== b.updatedAt || a.updatedAt !== b.updatedAt
|| a.status !== b.status || a.status !== b.status
|| a.fileName !== b.fileName
|| a.progressPercent !== b.progressPercent || a.progressPercent !== b.progressPercent
|| a.speedBps !== b.speedBps || a.speedBps !== b.speedBps
|| a.retries !== b.retries || a.retries !== b.retries

View File

@ -16,16 +16,66 @@ afterEach(() => {
}); });
describe("container", () => { describe("container", () => {
it("rejects oversized DLC files before network access", async () => { it("skips oversized DLC files without throwing and blocking other files", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir); tempDirs.push(dir);
const filePath = path.join(dir, "oversized.dlc"); const oversizedFilePath = path.join(dir, "oversized.dlc");
fs.writeFileSync(filePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1)); fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
const fetchSpy = vi.fn(async () => new Response("should-not-run", { status: 500 })); // Create a valid mockup DLC that would be skipped if an error was thrown
const validFilePath = path.join(dir, "valid.dlc");
// Just needs to be short enough to pass file limits but fail parsing, triggering dcrypt fallback
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
const fetchSpy = vi.fn(async () => {
// Mock dcrypt response for valid.dlc
return new Response("http://example.com/file1.rar\nhttp://example.com/file2.rar", { status: 200 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch; globalThis.fetch = fetchSpy as unknown as typeof fetch;
await expect(importDlcContainers([filePath])).rejects.toThrow(/zu groß/i); const result = await importDlcContainers([oversizedFilePath, validFilePath]);
expect(fetchSpy).toHaveBeenCalledTimes(0);
// Expect the oversized to be silently skipped, and valid to be parsed into 2 packages (one per link name)
expect(result).toHaveLength(2);
expect(result[0].links).toEqual(["http://example.com/file1.rar"]);
expect(result[1].links).toEqual(["http://example.com/file2.rar"]);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("skips non-dlc files completely", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-non-"));
tempDirs.push(dir);
const txtPath = path.join(dir, "links.txt");
fs.writeFileSync(txtPath, "http://link.com/1");
const result = await importDlcContainers([txtPath]);
expect(result).toEqual([]);
});
it("falls back to dcrypt if local decryption returns empty", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir);
const filePath = path.join(dir, "fallback.dlc");
// A file large enough to trigger local decryption attempt (needs > 89 bytes to pass the slice check)
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("rc")) {
// Mock local RC service failure (returning 404 or empty string)
return new Response("", { status: 404 });
} else {
// Mock dcrypt fallback success
return new Response("http://fallback.com/1", { status: 200 });
}
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const result = await importDlcContainers([filePath]);
expect(result).toHaveLength(1);
expect(result[0].links).toEqual(["http://fallback.com/1"]);
// Should have tried both!
expect(fetchSpy).toHaveBeenCalledTimes(2);
}); });
}); });

74
tests/link-parser.test.ts Normal file
View File

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { mergePackageInputs, parseCollectorInput } from "../src/main/link-parser";
describe("link-parser", () => {
describe("mergePackageInputs", () => {
it("merges packages with the same name and preserves order", () => {
const input = [
{ name: "Package A", links: ["http://link1", "http://link2"] },
{ name: "Package B", links: ["http://link3"] },
{ name: "Package A", links: ["http://link4", "http://link1"] },
{ name: "", links: ["http://link5"] } // empty name will be inferred
];
const result = mergePackageInputs(input);
expect(result).toHaveLength(3); // Package A, Package B, and inferred 'Paket'
const pkgA = result.find(p => p.name === "Package A");
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]); // link1 deduplicated
const pkgB = result.find(p => p.name === "Package B");
expect(pkgB?.links).toEqual(["http://link3"]);
});
it("sanitizes names during merge", () => {
const input = [
{ name: "Valid_Name", links: ["http://link1"] },
{ name: "Valid?Name*", links: ["http://link2"] }
];
const result = mergePackageInputs(input);
// "Valid?Name*" becomes "Valid Name " -> trimmed to "Valid Name"
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
});
});
describe("parseCollectorInput", () => {
it("returns empty array for empty or invalid input", () => {
expect(parseCollectorInput("")).toEqual([]);
expect(parseCollectorInput("just some text without links")).toEqual([]);
expect(parseCollectorInput("ftp://notsupported")).toEqual([]);
});
it("parses and merges links from raw text", () => {
const rawText = `
Here are some links:
http://example.com/part1.rar
http://example.com/part2.rar
# package: Custom_Name
http://other.com/file1
http://other.com/file2
`;
const result = parseCollectorInput(rawText, "DefaultFallback");
// Should have 2 packages: "DefaultFallback" and "Custom_Name"
expect(result).toHaveLength(2);
const defaultPkg = result.find(p => p.name === "DefaultFallback");
expect(defaultPkg?.links).toEqual([
"http://example.com/part1.rar",
"http://example.com/part2.rar"
]);
const customPkg = result.find(p => p.name === "Custom_Name"); // sanitized!
expect(customPkg?.links).toEqual([
"http://other.com/file1",
"http://other.com/file2"
]);
});
});
});

View File

@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { MegaWebFallback } from "../src/main/mega-web-fallback";
const originalFetch = globalThis.fetch;
describe("mega-web-fallback", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("MegaWebFallback class", () => {
it("returns null when credentials are empty", async () => {
const fallback = new MegaWebFallback(() => ({ login: "", password: "" }));
const result = await fallback.unrestrict("https://mega.debrid/test");
expect(result).toBeNull();
});
it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => {
let fetchCallCount = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
fetchCallCount += 1;
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
// The POST to generate the code
return new Response(`
<div class="acp-box">
<h3>Link: https://mega.debrid/link1</h3>
<a href="javascript:processDebrid(1,'secretcode123',0)">Download</a>
</div>
`, { status: 200 });
}
if (urlStr.includes("ajax=debrid")) {
// Polling endpoint
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
const result = await fallback.unrestrict("https://mega.debrid/link1");
expect(result).not.toBeNull();
expect(result?.directUrl).toBe("https://mega.direct/123");
expect(result?.fileName).toBe("link1");
// Calls: 1. Login POST, 2. Verify GET, 3. Generate POST, 4. Polling POST
expect(fetchCallCount).toBe(4);
});
it("throws if login fails to set cookie", async () => {
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers(); // No cookie
return new Response("", { headers, status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" }));
await expect(fallback.unrestrict("http://mega.debrid/file"))
.rejects.toThrow("Mega-Web Login liefert kein Session-Cookie");
});
it("throws if login verify check fails (no form found)", async () => {
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
// Missing form!
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
await expect(fallback.unrestrict("http://mega.debrid/file"))
.rejects.toThrow("Mega-Web Login ungültig oder Session blockiert");
});
it("returns null if generation fails to find a code", async () => {
let callCount = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
callCount++;
if (urlStr.includes("form=login")) {
const headers = new Headers();
headers.append("set-cookie", "session=goodcookie; path=/");
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
if (urlStr.includes("form=debrid")) {
// The generate POST returns HTML without any codes
return new Response(`<div>No links here</div>`, { status: 200 });
}
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
const result = await fallback.unrestrict("http://mega.debrid/file");
// Generation fails -> resets cookie -> tries again -> fails again -> returns null
expect(result).toBeNull();
});
});
});