Release v1.4.31 with full bug-audit hardening
Build and Release / build (push) Has been cancelled

This commit is contained in:
Sucukdeluxe
2026-03-01 00:33:26 +01:00
parent 6ae687f3ab
commit 6ac56c0a77
20 changed files with 802 additions and 116 deletions
+11 -3
View File
@@ -18,7 +18,7 @@ import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger";
import { MegaWebFallback } from "./mega-web-fallback";
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> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@@ -44,6 +44,8 @@ export class AppController {
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
private autoResumePending = false;
public constructor() {
configureLogger(this.storagePaths.baseDir);
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 hasConflicts = this.manager.getStartConflicts().length > 0;
if (hasPending && this.hasAnyProviderToken(this.settings) && !hasConflicts) {
this.manager.start();
logger.info("Auto-Resume beim Start aktiviert");
this.autoResumePending = true;
logger.info("Auto-Resume beim Start vorgemerkt");
} else if (hasPending && hasConflicts) {
logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt");
}
@@ -91,6 +93,11 @@ export class AppController {
this.onStateHandler = handler;
if (handler) {
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 {
abortActiveUpdateDownload();
this.manager.prepareForShutdown();
this.megaWebFallback.dispose();
logger.info("App beendet");
+9 -3
View File
@@ -28,7 +28,9 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
const stack = [packageDir];
while (stack.length > 0) {
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);
if (entry.isDirectory() && !entry.isSymbolicLink()) {
stack.push(full);
@@ -94,7 +96,9 @@ export function removeDownloadLinkArtifacts(extractDir: string): number {
const stack = [extractDir];
while (stack.length > 0) {
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);
if (entry.isDirectory() && !entry.isSymbolicLink()) {
stack.push(full);
@@ -177,7 +181,9 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
while (stack.length > 0) {
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);
if (entry.isDirectory() || entry.isSymbolicLink()) {
const base = entry.name.toLowerCase();
+2 -2
View File
@@ -196,7 +196,7 @@ export async function importDlcContainers(filePaths: string[]): Promise<ParsedPa
packages = await decryptDlcLocal(filePath);
} catch (error) {
if (/zu groß|ungültig/i.test(compactErrorText(error))) {
throw error;
continue;
}
packages = [];
}
@@ -205,7 +205,7 @@ export async function importDlcContainers(filePaths: string[]): Promise<ParsedPa
packages = await decryptDlcViaDcrypt(filePath);
} catch (error) {
if (/zu groß|ungültig/i.test(compactErrorText(error))) {
throw error;
continue;
}
packages = [];
}
+20 -3
View File
@@ -1,11 +1,11 @@
import { AppSettings, DebridFallbackProvider, DebridProvider } from "../shared/types";
import { REQUEST_RETRIES } from "./constants";
import { APP_VERSION, REQUEST_RETRIES } from "./constants";
import { logger } from "./logger";
import { RealDebridClient, UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils";
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 BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1";
@@ -781,6 +781,15 @@ class AllDebridClient {
if (!directUrl) {
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 {
fileName: pickString(data, ["filename"]) || filenameFromUrl(link),
@@ -850,7 +859,11 @@ export class DebridService {
for (const [link, fileName] of infos.entries()) {
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
}
}
@@ -922,6 +935,10 @@ export class DebridService {
providerLabel: PROVIDER_LABELS[provider]
};
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || /aborted/i.test(errorText)) {
throw error;
}
attempts.push(`${PROVIDER_LABELS[provider]}: ${compactErrorText(error)}`);
}
}
+134 -23
View File
@@ -32,8 +32,11 @@ type ActiveTask = {
abortController: AbortController;
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "none";
resumable: boolean;
speedEvents: Array<{ at: number; bytes: number }>;
nonResumableCounted: boolean;
freshRetryUsed?: boolean;
stallRetries?: number;
genericErrorRetries?: number;
unrestrictRetries?: number;
};
const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 30000;
@@ -362,6 +365,13 @@ export class DownloadManager extends EventEmitter {
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 = {}) {
super();
this.settings = settings;
@@ -543,6 +553,7 @@ export class DownloadManager extends EventEmitter {
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
this.retryAfterByItem.delete(itemId);
this.retryStateByItem.delete(itemId);
this.dropItemContribution(itemId);
if (!hasActiveTask) {
this.releaseTargetPath(itemId);
@@ -685,6 +696,7 @@ export class DownloadManager extends EventEmitter {
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.retryStateByItem.clear();
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear();
@@ -698,6 +710,7 @@ export class DownloadManager extends EventEmitter {
this.summary = null;
this.nonResumableActive = 0;
this.retryAfterByItem.clear();
this.retryStateByItem.clear();
this.persistNow();
this.emitState(true);
}
@@ -1291,6 +1304,8 @@ export class DownloadManager extends EventEmitter {
if (!pkg) {
return;
}
pkg.cancelled = true;
pkg.updatedAt = nowMs();
const packageName = pkg.name;
const outputDir = pkg.outputDir;
const itemIds = [...pkg.itemIds];
@@ -1333,7 +1348,25 @@ export class DownloadManager extends EventEmitter {
}
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.emitState(true);
}
@@ -1349,6 +1382,25 @@ export class DownloadManager extends EventEmitter {
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
});
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.runPackageIds.clear();
this.runOutcomes.clear();
@@ -1431,6 +1483,10 @@ export class DownloadManager extends EventEmitter {
public prepareForShutdown(): void {
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
this.clearPersistTimer();
if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer);
this.stateEmitTimer = null;
}
this.session.running = false;
this.session.paused = false;
this.session.reconnectUntil = 0;
@@ -1934,7 +1990,7 @@ export class DownloadManager extends EventEmitter {
const success = items.filter((item) => item.status === "completed").length;
const failed = items.filter((item) => item.status === "failed").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;
}
const needsExtraction = items.some((item) =>
@@ -1965,6 +2021,7 @@ export class DownloadManager extends EventEmitter {
this.packagePostProcessTasks.delete(packageId);
for (const itemId of itemIds) {
this.retryAfterByItem.delete(itemId);
this.retryStateByItem.delete(itemId);
this.releaseTargetPath(itemId);
this.dropItemContribution(itemId);
delete this.session.items[itemId];
@@ -1996,7 +2053,7 @@ export class DownloadManager extends EventEmitter {
continue;
}
if (this.reconnectActive() && (this.nonResumableActive > 0 || this.activeTasks.size === 0)) {
if (this.reconnectActive()) {
const markNow = nowMs();
if (markNow - this.lastReconnectMarkAt >= 900) {
this.lastReconnectMarkAt = markNow;
@@ -2185,7 +2242,27 @@ export class DownloadManager extends EventEmitter {
}
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 {
@@ -2239,6 +2316,12 @@ export class DownloadManager extends EventEmitter {
item.attempts = 0;
active.abortController = new AbortController();
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,
// so the retry backoff never blocks a worker.
this.retryAfterByItem.set(item.id, nowMs() + waitMs);
@@ -2268,7 +2351,6 @@ export class DownloadManager extends EventEmitter {
abortController: new AbortController(),
abortReason: "none",
resumable: true,
speedEvents: [],
nonResumableCounted: false
};
this.activeTasks.set(itemId, active);
@@ -2277,7 +2359,9 @@ export class DownloadManager extends EventEmitter {
void this.processItem(active).catch((err) => {
logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`);
}).finally(() => {
this.releaseTargetPath(item.id);
if (!this.retryAfterByItem.has(item.id)) {
this.releaseTargetPath(item.id);
}
if (active.nonResumableCounted) {
this.nonResumableActive = Math.max(0, this.nonResumableActive - 1);
}
@@ -2294,10 +2378,17 @@ export class DownloadManager extends EventEmitter {
return;
}
let freshRetryUsed = false;
let stallRetries = 0;
let genericErrorRetries = 0;
let unrestrictRetries = 0;
const retryState = this.retryStateByItem.get(item.id) || {
freshRetryUsed: false,
stallRetries: 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 maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES);
while (true) {
@@ -2430,8 +2521,12 @@ export class DownloadManager extends EventEmitter {
}
this.persistSoon();
this.emitState();
this.retryStateByItem.delete(item.id);
return;
} catch (error) {
if (this.session.items[item.id] !== item) {
return;
}
const reason = active.abortReason;
const claimedTargetPath = this.claimedTargetPathByItem.get(item.id) || "";
if (reason === "cancel") {
@@ -2449,6 +2544,7 @@ export class DownloadManager extends EventEmitter {
item.progressPercent = 0;
item.totalBytes = null;
this.dropItemContribution(item.id);
this.retryStateByItem.delete(item.id);
} else if (reason === "stop") {
item.status = "cancelled";
item.fullStatus = "Gestoppt";
@@ -2459,11 +2555,13 @@ export class DownloadManager extends EventEmitter {
item.totalBytes = null;
this.dropItemContribution(item.id);
}
this.retryStateByItem.delete(item.id);
} else if (reason === "shutdown") {
item.status = "queued";
item.speedBps = 0;
const activePkg = this.session.packages[item.packageId];
item.fullStatus = activePkg && !activePkg.enabled ? "Paket gestoppt" : "Wartet";
this.retryStateByItem.delete(item.id);
} else if (reason === "reconnect") {
item.status = "queued";
item.speedBps = 0;
@@ -2473,10 +2571,10 @@ export class DownloadManager extends EventEmitter {
item.speedBps = 0;
item.fullStatus = "Paket gestoppt";
} else if (reason === "stall") {
stallRetries += 1;
if (stallRetries <= 2) {
active.stallRetries += 1;
if (active.stallRetries <= 2) {
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 = "";
this.persistSoon();
this.emitState();
@@ -2486,9 +2584,10 @@ export class DownloadManager extends EventEmitter {
item.lastError = "Download hing wiederholt";
item.fullStatus = `Fehler: ${item.lastError}`;
this.recordRunOutcome(item.id, "failed");
this.retryStateByItem.delete(item.id);
} else {
const errorText = compactErrorText(error);
const shouldFreshRetry = !freshRetryUsed && isFetchFailure(errorText);
const shouldFreshRetry = !active.freshRetryUsed && isFetchFailure(errorText);
const isHttp416 = /(^|\D)416(\D|$)/.test(errorText);
if (isHttp416) {
try {
@@ -2509,10 +2608,11 @@ export class DownloadManager extends EventEmitter {
item.updatedAt = nowMs();
this.persistSoon();
this.emitState();
this.retryStateByItem.delete(item.id);
return;
}
if (shouldFreshRetry) {
freshRetryUsed = true;
active.freshRetryUsed = true;
item.retries += 1;
try {
fs.rmSync(item.targetPath, { force: true });
@@ -2530,20 +2630,20 @@ export class DownloadManager extends EventEmitter {
return;
}
if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) {
unrestrictRetries += 1;
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 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;
this.persistSoon();
this.emitState();
return;
}
if (genericErrorRetries < maxGenericErrorRetries) {
genericErrorRetries += 1;
if (active.genericErrorRetries < maxGenericErrorRetries) {
active.genericErrorRetries += 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;
this.persistSoon();
this.emitState();
@@ -2554,6 +2654,7 @@ export class DownloadManager extends EventEmitter {
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
item.fullStatus = `Fehler: ${item.lastError}`;
this.retryStateByItem.delete(item.id);
}
item.speedBps = 0;
item.updatedAt = nowMs();
@@ -2653,6 +2754,7 @@ export class DownloadManager extends EventEmitter {
} catch {
// ignore
}
this.dropItemContribution(active.itemId);
item.downloadedBytes = 0;
item.totalBytes = knownTotal && knownTotal > 0 ? knownTotal : null;
item.progressPercent = 0;
@@ -2665,6 +2767,8 @@ export class DownloadManager extends EventEmitter {
await sleep(280 * attempt);
continue;
}
lastError = "HTTP 416";
throw new Error(lastError);
}
const text = await response.text();
lastError = `HTTP ${response.status}`;
@@ -3001,6 +3105,7 @@ export class DownloadManager extends EventEmitter {
private recoverRetryableItems(trigger: "startup" | "start"): number {
let recovered = 0;
const touchedPackages = new Set<string>();
const maxAutoRetryFailures = Math.max(2, REQUEST_RETRIES);
for (const packageId of this.session.packageOrder) {
const pkg = this.session.packages[packageId];
@@ -3010,7 +3115,7 @@ export class DownloadManager extends EventEmitter {
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item || item.status === "cancelled") {
if (!item || item.status === "cancelled" || this.activeTasks.has(itemId)) {
continue;
}
@@ -3018,6 +3123,9 @@ export class DownloadManager extends EventEmitter {
const hasZeroByteArchive = this.hasZeroByteArchiveArtifact(item);
if (item.status === "failed") {
if (!is416Failure && !hasZeroByteArchive && item.retries >= maxAutoRetryFailures) {
continue;
}
this.queueItemForRetry(item, {
hardReset: is416Failure || hasZeroByteArchive,
reason: is416Failure
@@ -3062,6 +3170,7 @@ export class DownloadManager extends EventEmitter {
}
private queueItemForRetry(item: DownloadItem, options: { hardReset: boolean; reason: string }): void {
this.retryStateByItem.delete(item.id);
const targetPath = String(item.targetPath || "").trim();
if (options.hardReset && targetPath) {
try {
@@ -3787,6 +3896,8 @@ export class DownloadManager extends EventEmitter {
this.runPackageIds.clear();
this.runOutcomes.clear();
this.runCompletedPackages.clear();
this.retryAfterByItem.clear();
this.retryStateByItem.clear();
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear();
+16 -9
View File
@@ -1111,6 +1111,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
}
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 || "");
const resumeCompleted = readExtractResumeState(options.packageDir, options.packageId);
const resumeCompletedAtStart = resumeCompleted.size;
@@ -1154,15 +1157,19 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
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)));
}
options.onProgress({
current,
total,
percent,
archiveName,
archivePercent,
elapsedMs,
phase
});
try {
options.onProgress({
current,
total,
percent,
archiveName,
archivePercent,
elapsedMs,
phase
});
} catch (error) {
logger.warn(`onProgress callback Fehler unterdrückt: ${cleanErrorText(String(error))}`);
}
};
emitProgress(extracted, "", "extracting");
+37 -18
View File
@@ -4,6 +4,17 @@ import crypto from "node:crypto";
import { ParsedHashEntry } from "../shared/types";
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 {
const text = String(line || "").trim();
if (!text || text.startsWith(";")) {
@@ -30,6 +41,12 @@ export function parseHashLine(line: string): ParsedHashEntry | null {
}
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 patterns: Array<[string, "crc32" | "md5" | "sha1"]> = [
[".sfv", "crc32"],
@@ -80,24 +97,28 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
...parsed,
algorithm: hit[1]
};
const key = parsed.fileName.toLowerCase();
const key = normalizeManifestKey(parsed.fileName);
if (map.has(key)) {
continue;
}
map.set(key, normalized);
}
}
manifestCache.set(cacheKey, { at: Date.now(), entries: new Map(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 {
let crc = seed ^ -1;
for (let i = 0; i < data.length; i += 1) {
let c = (crc ^ data[i]) & 0xff;
for (let j = 0; j < 8; j += 1) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
}
crc = (crc >>> 8) ^ c;
for (let i = 0; i < data.length; i++) {
crc = (crc >>> 8) ^ crcTable[(crc ^ data[i]) & 0xff];
}
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> {
if (algorithm === "crc32") {
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
return await new Promise<string>((resolve, reject) => {
let crc = 0;
stream.on("data", (chunk: string | Buffer) => {
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
crc = crc32Buffer(buffer, crc);
});
stream.on("error", reject);
stream.on("end", () => resolve(((crc >>> 0).toString(16)).padStart(8, "0").toLowerCase()));
});
let crc = 0;
for await (const chunk of stream) {
crc = crc32Buffer(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), crc);
await new Promise(r => setImmediate(r));
}
return (crc >>> 0).toString(16).padStart(8, "0").toLowerCase();
}
const hash = crypto.createHash(algorithm);
@@ -130,8 +148,9 @@ export async function validateFileAgainstManifest(filePath: string, packageDir:
if (manifest.size === 0) {
return { ok: true, message: "Kein Hash verfügbar" };
}
const key = path.basename(filePath).toLowerCase();
const entry = manifest.get(key);
const keyByBaseName = normalizeManifestKey(path.basename(filePath));
const keyByRelativePath = normalizeManifestKey(path.relative(packageDir, filePath));
const entry = manifest.get(keyByRelativePath) || manifest.get(keyByBaseName);
if (!entry) {
return { ok: true, message: "Kein Hash für Datei" };
}
+13 -6
View File
@@ -6,7 +6,7 @@ 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;
const rotateCheckAtByFile = new Map<string, number>();
let pendingLines: string[] = [];
let pendingChars = 0;
@@ -97,10 +97,11 @@ function scheduleFlush(immediate = false): void {
function rotateIfNeeded(filePath: string): void {
try {
const now = Date.now();
const lastRotateCheckAt = rotateCheckAtByFile.get(filePath) || 0;
if (now - lastRotateCheckAt < 60_000) {
return;
}
lastRotateCheckAt = now;
rotateCheckAtByFile.set(filePath, now);
const stat = fs.statSync(filePath);
if (stat.size < LOG_MAX_FILE_BYTES) {
return;
@@ -123,25 +124,31 @@ async function flushAsync(): Promise<void> {
}
flushInFlight = true;
const chunk = pendingLines.join("");
pendingLines = [];
pendingChars = 0;
const linesSnapshot = pendingLines.slice();
const chunk = linesSnapshot.join("");
try {
rotateIfNeeded(logFilePath);
const primary = await appendChunk(logFilePath, chunk);
let wroteAny = primary.ok;
if (fallbackLogFilePath) {
rotateIfNeeded(fallbackLogFilePath);
const fallback = await appendChunk(fallbackLogFilePath, chunk);
wroteAny = wroteAny || fallback.ok;
if (!primary.ok && !fallback.ok) {
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
}
} else if (!primary.ok) {
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
}
if (wroteAny) {
pendingLines = pendingLines.slice(linesSnapshot.length);
pendingChars = Math.max(0, pendingChars - chunk.length);
}
} finally {
flushInFlight = false;
if (pendingLines.length > 0) {
scheduleFlush(true);
scheduleFlush();
}
}
}
+31 -14
View File
@@ -94,6 +94,22 @@ function createWindow(): BrowserWindow {
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 {
if (tray) {
return;
@@ -132,11 +148,22 @@ function extractLinksFromText(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 || "");
if (normalized.length <= CLIPBOARD_MAX_TEXT_CHARS) {
return normalized;
}
const truncated = normalized.slice(0, CLIPBOARD_MAX_TEXT_CHARS);
const truncated = truncateUnicodeSafe(normalized, CLIPBOARD_MAX_TEXT_CHARS);
const lastBreak = Math.max(
truncated.lastIndexOf("\n"),
truncated.lastIndexOf("\r"),
@@ -237,7 +264,7 @@ function registerIpcHandlers(): void {
});
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => {
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);
});
ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
@@ -333,24 +360,14 @@ app.on("second-instance", () => {
app.whenReady().then(() => {
registerIpcHandlers();
mainWindow = createWindow();
bindMainWindowLifecycle(mainWindow);
updateClipboardWatcher();
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", () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
bindMainWindowLifecycle(mainWindow);
}
});
});
+33 -4
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";
const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.30";
const DEBRID_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
export interface UnrestrictedLink {
fileName: string;
@@ -72,6 +72,35 @@ function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number):
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 {
const type = String(contentType || "").toLowerCase();
if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
@@ -116,7 +145,7 @@ export class RealDebridClient {
if (!response.ok) {
const parsed = parseErrorBody(response.status, text, contentType);
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
await sleep(retryDelayForResponse(response, attempt));
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
throw new Error(parsed);
@@ -153,7 +182,7 @@ export class RealDebridClient {
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break;
}
await sleep(retryDelay(attempt));
await sleepWithSignal(retryDelay(attempt), signal);
}
}
+89 -8
View File
@@ -18,6 +18,8 @@ const RETRIES_PER_CANDIDATE = 3;
const RETRY_DELAY_MS = 1500;
const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
let activeUpdateAbortController: AbortController | null = null;
type ReleaseAsset = {
name: 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> {
let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
@@ -280,13 +289,25 @@ function shouldTryNextDownloadCandidate(error: unknown): boolean {
}
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();
if (fromName) {
return fromName;
return sanitizeUpdateAssetFileName(fromName);
}
try {
const parsed = new URL(url);
return path.basename(parsed.pathname || "update.exe") || "update.exe";
return sanitizeUpdateAssetFileName(parsed.pathname || "update.exe");
} catch {
return "update.exe";
}
@@ -317,7 +338,8 @@ async function sha256File(filePath: string): Promise<string> {
async function verifyDownloadedInstaller(targetPath: string, expectedDigestRaw: string): Promise<void> {
const expectedDigest = normalizeSha256Digest(expectedDigestRaw);
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);
if (actualDigest !== expectedDigest) {
@@ -378,6 +400,10 @@ export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult
}
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}`);
const timeout = timeoutController(CONNECT_TIMEOUT_MS);
let response: Response;
@@ -387,7 +413,7 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
"User-Agent": UPDATE_USER_AGENT
},
redirect: "follow",
signal: timeout.signal
signal: combineSignals(timeout.signal, shutdownSignal)
});
} finally {
timeout.clear();
@@ -463,13 +489,38 @@ async function downloadFile(url: string, targetPath: string): Promise<void> {
logger.info(`Update-Download abgeschlossen: ${targetPath}`);
}
async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
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> {
const shutdownSignal = activeUpdateAbortController?.signal;
let lastError: unknown;
for (let attempt = 1; attempt <= RETRIES_PER_CANDIDATE; attempt += 1) {
if (shutdownSignal?.aborted) {
throw new Error("aborted:update_shutdown");
}
try {
await downloadFile(url, targetPath);
return;
@@ -482,7 +533,7 @@ async function downloadWithRetries(url: string, targetPath: string): Promise<voi
}
if (attempt < RETRIES_PER_CANDIDATE && isRetryableDownloadError(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;
}
break;
@@ -492,10 +543,14 @@ async function downloadWithRetries(url: string, targetPath: string): Promise<voi
}
async function downloadFromCandidates(candidates: string[], targetPath: string): Promise<void> {
const shutdownSignal = activeUpdateAbortController?.signal;
let lastError: unknown = new Error("Update Download fehlgeschlagen");
logger.info(`Update-Download: ${candidates.length} Kandidat(en), je ${RETRIES_PER_CANDIDATE} Versuche`);
for (let index = 0; index < candidates.length; index += 1) {
if (shutdownSignal?.aborted) {
throw new Error("aborted:update_shutdown");
}
const candidate = candidates[index];
try {
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> {
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 check = prechecked && !prechecked.error
? prechecked
@@ -551,15 +612,24 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
}
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 {
if (updateAbortController.signal.aborted) {
throw new Error("aborted:update_shutdown");
}
await downloadFromCandidates(candidates, targetPath);
if (updateAbortController.signal.aborted) {
throw new Error("aborted:update_shutdown");
}
await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || ""));
const child = spawn(targetPath, [], {
detached: true,
stdio: "ignore"
});
child.once("error", (spawnError) => {
logger.error(`Update-Installer Start fehlgeschlagen: ${compactErrorText(spawnError)}`);
});
child.unref();
return { started: true, message: "Update-Installer gestartet" };
} catch (error) {
@@ -571,5 +641,16 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
const releaseUrl = String(effectiveCheck.releaseUrl || "").trim();
const hint = releaseUrl ? ` Manuell: ${releaseUrl}` : "";
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");
}
+31 -11
View File
@@ -20,10 +20,11 @@ export function compactErrorText(message: unknown, maxLen = 220): string {
if (!raw) {
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.slice(0, maxLen - 3)}...`;
return `${raw.slice(0, safeMaxLen - 3)}...`;
}
export function sanitizeFilename(name: string): string {
@@ -71,25 +72,41 @@ export function extractHttpLinksFromText(text: string): string[] {
for (const match of matches) {
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) {
const lastChar = candidate[candidate.length - 1];
if (![")", "]", ",", ".", "!", "?", ";", ":"].includes(lastChar)) {
break;
}
if (lastChar === ")") {
const openCount = (candidate.match(/\(/g) || []).length;
const closeCount = (candidate.match(/\)/g) || []).length;
if (closeCount <= openCount) {
if (closeParen <= openParen) {
break;
}
}
if (lastChar === "]") {
const openCount = (candidate.match(/\[/g) || []).length;
const closeCount = (candidate.match(/\]/g) || []).length;
if (closeCount <= openCount) {
if (closeBracket <= openBracket) {
break;
}
}
if (lastChar === ")") {
closeParen = Math.max(0, closeParen - 1);
} else if (lastChar === "]") {
closeBracket = Math.max(0, closeBracket - 1);
}
candidate = candidate.slice(0, -1);
}
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[] {
const lines = String(rawText || "").split(/\r?\n/);
const packages: ParsedPackageInput[] = [];
let currentName = sanitizeFilename(defaultPackageName || "Paket");
let currentName = String(defaultPackageName || "").trim();
let currentLinks: string[] = [];
const flush = (): void => {
const links = uniquePreserveOrder(currentLinks.filter((line) => isHttpLink(line)));
if (links.length > 0) {
const normalizedCurrentName = String(currentName || "").trim();
packages.push({
name: sanitizeFilename(currentName || inferPackageNameFromLinks(links)),
name: normalizedCurrentName
? sanitizeFilename(normalizedCurrentName)
: inferPackageNameFromLinks(links),
links
});
}
@@ -204,7 +224,7 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
const marker = text.match(/^#\s*package\s*:\s*(.+)$/i);
if (marker) {
flush();
currentName = sanitizeFilename(marker[1]);
currentName = String(marker[1] || "").trim();
continue;
}
currentLinks.push(text);
+21 -3
View File
@@ -80,6 +80,9 @@ function formatSpeedMbps(speedBps: 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 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; }
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 unsubClipboard: (() => void) | null = null;
void window.rd.getSnapshot().then((state) => {
if (!mountedRef.current) {
return;
}
setSnapshot(state);
setSettingsDraft(state.settings);
settingsDirtyRef.current = false;
@@ -265,6 +271,9 @@ export function App(): ReactElement {
applyTheme(state.settings.theme);
if (state.settings.autoUpdateCheck) {
void window.rd.checkUpdates().then((result) => {
if (!mountedRef.current) {
return;
}
void handleUpdateResult(result, "startup");
}).catch(() => undefined);
}
@@ -717,7 +726,8 @@ export function App(): ReactElement {
showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600);
});
} 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));
setTab("collector");
showToast("Links per Drag-and-Drop eingefügt");
@@ -748,6 +758,7 @@ export function App(): ReactElement {
return;
}
actionBusyRef.current = true;
setActionBusy(true);
const input = document.createElement("input");
@@ -755,7 +766,8 @@ export function App(): ReactElement {
input.accept = ".json";
const releasePickerBusy = (): void => {
setActionBusy(actionBusyRef.current);
actionBusyRef.current = false;
setActionBusy(false);
};
const onWindowFocus = (): void => {
@@ -1198,8 +1210,12 @@ export function App(): ReactElement {
setDownloadsSortDescending(nextDescending);
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
pendingPackageOrderRef.current = [...sorted];
pendingPackageOrderAtRef.current = Date.now();
packageOrderRef.current = sorted;
void window.rd.reorderPackages(sorted).catch((error) => {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = serverPackageOrderRef.current;
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${settingsDraft.theme === "light" ? " btn-active" : ""}`} onClick={() => {
const next = settingsDraft.theme === "dark" ? "light" : "dark";
settingsDraftRevisionRef.current += 1;
settingsDirtyRef.current = true;
setSettingsDirty(true);
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-card" onClick={(event) => event.stopPropagation()}>
<h3>{confirmPrompt.title}</h3>
<p>{confirmPrompt.message}</p>
<p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p>
<div className="modal-actions">
<button className="btn" onClick={() => closeConfirmPrompt(false)}>Abbrechen</button>
<button
@@ -1653,6 +1670,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
if (a.id !== b.id
|| a.updatedAt !== b.updatedAt
|| a.status !== b.status
|| a.fileName !== b.fileName
|| a.progressPercent !== b.progressPercent
|| a.speedBps !== b.speedBps
|| a.retries !== b.retries