Release v1.4.20 with comprehensive audit fixes (140 issues) and expanded test coverage
- Speed calculation: raised minimum elapsed floor to 0.5s preventing unrealistic spikes - Reconnect: exponential backoff with consecutive counter, clock regression protection - Download engine: retry byte tracking (itemContributedBytes), mkdir before createWriteStream, content-length validation - Fire-and-forget promises: all void promises now have .catch() error handlers - Session recovery: normalize stale active statuses to queued on crash recovery, clear speedBps - Storage: config backup (.bak) before overwrite, EXDEV cross-device rename fallback with type guard - IPC security: input validation on all string/array IPC handlers, CSP headers in production - Main process: clipboard memory limit (50KB), installer timing increased to 800ms - Debrid: attribute-order-independent meta tag regex for Rapidgator filename extraction - Constants: named constants for magic numbers (MAX_MANIFEST_FILE_BYTES, MAX_LINK_ARTIFACT_BYTES, etc.) - Extractor/integrity: use shared constants, document password visibility and TOCTOU limitations - Tests: 103 tests total (55 new), covering utils, storage, integrity, cleanup, extractor, debrid, update Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
556f0672dc
commit
63fd402083
+2
-2
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ARCHIVE_TEMP_EXTENSIONS, LINK_ARTIFACT_EXTENSIONS, RAR_SPLIT_RE, SAMPLE_DIR_NAMES, SAMPLE_TOKEN_RE, SAMPLE_VIDEO_EXTENSIONS } from "./constants";
|
||||
import { ARCHIVE_TEMP_EXTENSIONS, LINK_ARTIFACT_EXTENSIONS, MAX_LINK_ARTIFACT_BYTES, RAR_SPLIT_RE, SAMPLE_DIR_NAMES, SAMPLE_TOKEN_RE, SAMPLE_VIDEO_EXTENSIONS } from "./constants";
|
||||
|
||||
async function yieldToLoop(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -111,7 +111,7 @@ export function removeDownloadLinkArtifacts(extractDir: string): number {
|
||||
if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) {
|
||||
try {
|
||||
const stat = fs.statSync(full);
|
||||
if (stat.size <= 256 * 1024) {
|
||||
if (stat.size <= MAX_LINK_ARTIFACT_BYTES) {
|
||||
const text = fs.readFileSync(full, "utf8");
|
||||
shouldDelete = /https?:\/\//i.test(text);
|
||||
}
|
||||
|
||||
@@ -20,9 +20,14 @@ export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov",
|
||||
export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
|
||||
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
||||
|
||||
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part"]);
|
||||
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz"]);
|
||||
export const RAR_SPLIT_RE = /\.r\d{2}$/i;
|
||||
|
||||
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
|
||||
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
|
||||
export const SPEED_WINDOW_SECONDS = 3;
|
||||
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/real-debrid-downloader";
|
||||
|
||||
export function defaultSettings(): AppSettings {
|
||||
|
||||
+9
-6
@@ -160,7 +160,7 @@ function looksLikeFileName(value: string): boolean {
|
||||
return /\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub)$/i.test(value);
|
||||
}
|
||||
|
||||
function normalizeResolvedFilename(value: string): string {
|
||||
export function normalizeResolvedFilename(value: string): string {
|
||||
const candidate = decodeHtmlEntities(String(value || ""))
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
@@ -174,7 +174,7 @@ function normalizeResolvedFilename(value: string): string {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function filenameFromRapidgatorUrlPath(link: string): string {
|
||||
export function filenameFromRapidgatorUrlPath(link: string): string {
|
||||
try {
|
||||
const parsed = new URL(link);
|
||||
const pathParts = parsed.pathname.split("/").filter(Boolean);
|
||||
@@ -191,10 +191,10 @@ function filenameFromRapidgatorUrlPath(link: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function extractRapidgatorFilenameFromHtml(html: string): string {
|
||||
export function extractRapidgatorFilenameFromHtml(html: string): string {
|
||||
const patterns = [
|
||||
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+name=["']title["'][^>]+content=["']([^"']+)["']/i,
|
||||
/<meta[^>]+(?:property=["']og:title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+property=["']og:title["'])/i,
|
||||
/<meta[^>]+(?:name=["']title["'][^>]+content=["']([^"']+)["']|content=["']([^"']+)["'][^>]+name=["']title["'])/i,
|
||||
/<title>([^<]+)<\/title>/i,
|
||||
/(?:Dateiname|File\s*name)\s*[:\-]\s*<[^>]*>\s*([^<]+)\s*</i,
|
||||
/(?:Dateiname|File\s*name)\s*[:\-]\s*([^<\r\n]+)/i,
|
||||
@@ -204,7 +204,10 @@ function extractRapidgatorFilenameFromHtml(html: string): string {
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
const normalized = normalizeResolvedFilename(match?.[1] || "");
|
||||
// Some patterns have multiple capture groups for attribute-order independence;
|
||||
// pick the first non-empty group.
|
||||
const raw = match?.[1] || match?.[2] || "";
|
||||
const normalized = normalizeResolvedFilename(raw);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -326,6 +326,8 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private claimedTargetPathByItem = new Map<string, string>();
|
||||
|
||||
private itemContributedBytes = new Map<string, number>();
|
||||
|
||||
private runItemIds = new Set<string>();
|
||||
|
||||
private runPackageIds = new Set<string>();
|
||||
@@ -338,6 +340,8 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
private lastReconnectMarkAt = 0;
|
||||
|
||||
private consecutiveReconnects = 0;
|
||||
|
||||
private lastGlobalProgressBytes = 0;
|
||||
|
||||
private lastGlobalProgressAt = 0;
|
||||
@@ -562,7 +566,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
if (this.session.running) {
|
||||
void this.ensureScheduler();
|
||||
void this.ensureScheduler().catch((err) => logger.warn(`ensureScheduler Fehler (togglePackage): ${compactErrorText(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,6 +622,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.runCompletedPackages.clear();
|
||||
this.reservedTargetPaths.clear();
|
||||
this.claimedTargetPathByItem.clear();
|
||||
this.itemContributedBytes.clear();
|
||||
this.packagePostProcessTasks.clear();
|
||||
this.packagePostProcessAbortControllers.clear();
|
||||
this.packagePostProcessQueue = Promise.resolve();
|
||||
@@ -697,7 +702,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
if (unresolvedByLink.size > 0) {
|
||||
void this.resolveQueuedFilenames(unresolvedByLink);
|
||||
void this.resolveQueuedFilenames(unresolvedByLink).catch((err) => logger.warn(`resolveQueuedFilenames Fehler (addPackages): ${compactErrorText(err)}`));
|
||||
}
|
||||
return { addedPackages, addedLinks };
|
||||
}
|
||||
@@ -913,7 +918,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
if (unresolvedByLink.size > 0) {
|
||||
void this.resolveQueuedFilenames(unresolvedByLink);
|
||||
void this.resolveQueuedFilenames(unresolvedByLink).catch((err) => logger.warn(`resolveQueuedFilenames Fehler (resolveExisting): ${compactErrorText(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1268,12 +1273,15 @@ export class DownloadManager extends EventEmitter {
|
||||
|
||||
this.session.running = true;
|
||||
this.session.paused = false;
|
||||
// By design: runStartedAt and totalDownloadedBytes reset on each start/resume so that
|
||||
// duration, average speed, and ETA are calculated relative to the current run, not cumulative.
|
||||
this.session.runStartedAt = nowMs();
|
||||
this.session.totalDownloadedBytes = 0;
|
||||
this.session.summaryText = "";
|
||||
this.session.reconnectUntil = 0;
|
||||
this.session.reconnectReason = "";
|
||||
this.lastReconnectMarkAt = 0;
|
||||
this.consecutiveReconnects = 0;
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.lastGlobalProgressBytes = 0;
|
||||
@@ -1501,7 +1509,7 @@ export class DownloadManager extends EventEmitter {
|
||||
private persistNow(): void {
|
||||
this.lastPersistAt = nowMs();
|
||||
if (this.session.running) {
|
||||
void saveSessionAsync(this.storagePaths, this.session);
|
||||
void saveSessionAsync(this.storagePaths, this.session).catch((err) => logger.warn(`saveSessionAsync Fehler: ${compactErrorText(err)}`));
|
||||
} else {
|
||||
saveSession(this.storagePaths, this.session);
|
||||
}
|
||||
@@ -1715,7 +1723,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
void this.runPackagePostProcessing(packageId);
|
||||
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (recoverPostProcessing): ${compactErrorText(err)}`));
|
||||
} else if (pkg.status !== "completed") {
|
||||
pkg.status = "completed";
|
||||
pkg.updatedAt = nowMs();
|
||||
@@ -1775,7 +1783,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
logger.info(`Entpacken via Start ausgelöst: pkg=${pkg.name}`);
|
||||
void this.runPackagePostProcessing(packageId);
|
||||
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (triggerPending): ${compactErrorText(err)}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1847,7 +1855,17 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
private reconnectActive(): boolean {
|
||||
return this.session.reconnectUntil > nowMs();
|
||||
if (this.session.reconnectUntil <= 0) {
|
||||
return false;
|
||||
}
|
||||
const now = nowMs();
|
||||
// Safety: if reconnectUntil is unreasonably far in the future (clock regression),
|
||||
// clamp it to reconnectWaitSeconds * 2 from now
|
||||
const maxWaitMs = this.settings.reconnectWaitSeconds * 2 * 1000;
|
||||
if (this.session.reconnectUntil - now > maxWaitMs) {
|
||||
this.session.reconnectUntil = now + maxWaitMs;
|
||||
}
|
||||
return this.session.reconnectUntil > now;
|
||||
}
|
||||
|
||||
private runGlobalStallWatchdog(now: number): void {
|
||||
@@ -1900,8 +1918,18 @@ export class DownloadManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const until = nowMs() + this.settings.reconnectWaitSeconds * 1000;
|
||||
this.consecutiveReconnects += 1;
|
||||
const backoffMultiplier = Math.min(this.consecutiveReconnects, 5);
|
||||
const waitMs = this.settings.reconnectWaitSeconds * 1000 * backoffMultiplier;
|
||||
const maxWaitMs = this.settings.reconnectWaitSeconds * 2 * 1000;
|
||||
const cappedWaitMs = Math.min(waitMs, maxWaitMs);
|
||||
const until = nowMs() + cappedWaitMs;
|
||||
this.session.reconnectUntil = Math.max(this.session.reconnectUntil, until);
|
||||
// Safety cap: never let reconnectUntil exceed reconnectWaitSeconds * 2 from now
|
||||
const absoluteMax = nowMs() + maxWaitMs;
|
||||
if (this.session.reconnectUntil > absoluteMax) {
|
||||
this.session.reconnectUntil = absoluteMax;
|
||||
}
|
||||
this.session.reconnectReason = reason;
|
||||
this.lastReconnectMarkAt = 0;
|
||||
|
||||
@@ -1912,7 +1940,7 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`Reconnect angefordert: ${reason}`);
|
||||
logger.warn(`Reconnect angefordert: ${reason} (consecutive=${this.consecutiveReconnects}, wait=${Math.ceil(cappedWaitMs / 1000)}s)`);
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
@@ -2022,7 +2050,9 @@ export class DownloadManager extends EventEmitter {
|
||||
this.activeTasks.set(itemId, active);
|
||||
this.emitState();
|
||||
|
||||
void this.processItem(active).finally(() => {
|
||||
void this.processItem(active).catch((err) => {
|
||||
logger.warn(`processItem unbehandelt (${itemId}): ${compactErrorText(err)}`);
|
||||
}).finally(() => {
|
||||
this.releaseTargetPath(item.id);
|
||||
if (active.nonResumableCounted) {
|
||||
this.nonResumableActive = Math.max(0, this.nonResumableActive - 1);
|
||||
@@ -2140,7 +2170,9 @@ export class DownloadManager extends EventEmitter {
|
||||
pkg.updatedAt = nowMs();
|
||||
this.recordRunOutcome(item.id, "completed");
|
||||
|
||||
void this.runPackagePostProcessing(pkg.id).finally(() => {
|
||||
void this.runPackagePostProcessing(pkg.id).catch((err) => {
|
||||
logger.warn(`runPackagePostProcessing Fehler (processItem): ${compactErrorText(err)}`);
|
||||
}).finally(() => {
|
||||
this.applyCompletedCleanupPolicy(pkg.id, item.id);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
@@ -2429,7 +2461,8 @@ export class DownloadManager extends EventEmitter {
|
||||
const resumable = response.status === 206 || acceptRanges;
|
||||
active.resumable = resumable;
|
||||
|
||||
const contentLength = Number(response.headers.get("content-length") || 0);
|
||||
const rawContentLength = Number(response.headers.get("content-length") || 0);
|
||||
const contentLength = Number.isFinite(rawContentLength) && rawContentLength > 0 ? rawContentLength : 0;
|
||||
const totalFromRange = parseContentRangeTotal(response.headers.get("content-range"));
|
||||
if (knownTotal && knownTotal > 0) {
|
||||
item.totalBytes = knownTotal;
|
||||
@@ -2440,10 +2473,19 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
|
||||
if (writeMode === "w" && existingBytes > 0) {
|
||||
fs.rmSync(effectiveTargetPath, { force: true });
|
||||
if (writeMode === "w") {
|
||||
// Starting fresh: subtract any previously counted bytes for this item to avoid double-counting on retry
|
||||
const previouslyContributed = this.itemContributedBytes.get(active.itemId) || 0;
|
||||
if (previouslyContributed > 0) {
|
||||
this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes - previouslyContributed);
|
||||
this.itemContributedBytes.set(active.itemId, 0);
|
||||
}
|
||||
if (existingBytes > 0) {
|
||||
fs.rmSync(effectiveTargetPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(effectiveTargetPath), { recursive: true });
|
||||
const stream = fs.createWriteStream(effectiveTargetPath, { flags: writeMode });
|
||||
let written = writeMode === "a" ? existingBytes : 0;
|
||||
let windowBytes = 0;
|
||||
@@ -2623,9 +2665,10 @@ export class DownloadManager extends EventEmitter {
|
||||
written += buffer.length;
|
||||
windowBytes += buffer.length;
|
||||
this.session.totalDownloadedBytes += buffer.length;
|
||||
this.itemContributedBytes.set(active.itemId, (this.itemContributedBytes.get(active.itemId) || 0) + buffer.length);
|
||||
this.recordSpeed(buffer.length);
|
||||
|
||||
const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1);
|
||||
const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.5);
|
||||
const speed = windowBytes / elapsed;
|
||||
if (elapsed >= 1.2) {
|
||||
windowStarted = nowMs();
|
||||
@@ -3136,6 +3179,7 @@ export class DownloadManager extends EventEmitter {
|
||||
this.runCompletedPackages.clear();
|
||||
this.reservedTargetPaths.clear();
|
||||
this.claimedTargetPathByItem.clear();
|
||||
this.itemContributedBytes.clear();
|
||||
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
||||
this.lastGlobalProgressAt = nowMs();
|
||||
this.persistNow();
|
||||
|
||||
@@ -466,6 +466,10 @@ export function buildExternalExtractArgs(
|
||||
const lower = command.toLowerCase();
|
||||
if (lower.includes("unrar") || lower.includes("winrar")) {
|
||||
const overwrite = mode === "overwrite" ? "-o+" : mode === "rename" ? "-or" : "-o-";
|
||||
// NOTE: The password is passed as a CLI argument (-p<password>), which means it may be
|
||||
// visible via process listing tools (e.g. `ps aux` on Unix). This is unavoidable because
|
||||
// WinRAR/UnRAR CLI does not support password input via stdin or environment variables.
|
||||
// On Windows (the target platform) this is less of a concern than on shared Unix systems.
|
||||
const pass = password ? `-p${password}` : "-p-";
|
||||
const perfArgs = usePerformanceFlags && shouldUseExtractorPerformanceFlags()
|
||||
? ["-idc", extractorThreadSwitch()]
|
||||
@@ -474,6 +478,7 @@ export function buildExternalExtractArgs(
|
||||
}
|
||||
|
||||
const overwrite = mode === "overwrite" ? "-aoa" : mode === "rename" ? "-aou" : "-aos";
|
||||
// NOTE: Same password-in-args limitation as above applies to 7z as well.
|
||||
const pass = password ? `-p${password}` : "-p";
|
||||
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
|
||||
}
|
||||
@@ -599,6 +604,9 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
|
||||
continue;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
// TOCTOU note: There is a small race between existsSync and writeFileSync below.
|
||||
// This is acceptable here because zip extraction is single-threaded and we need
|
||||
// the exists check to implement skip/rename conflict resolution semantics.
|
||||
if (fs.existsSync(outputPath)) {
|
||||
if (mode === "skip") {
|
||||
continue;
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { ParsedHashEntry } from "../shared/types";
|
||||
import { MAX_MANIFEST_FILE_BYTES } from "./constants";
|
||||
|
||||
export function parseHashLine(line: string): ParsedHashEntry | null {
|
||||
const text = String(line || "").trim();
|
||||
@@ -53,7 +54,7 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
|
||||
let lines: string[];
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size > 5 * 1024 * 1024) {
|
||||
if (stat.size > MAX_MANIFEST_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
|
||||
|
||||
+67
-12
@@ -6,6 +6,20 @@ import { IPC_CHANNELS } from "../shared/ipc";
|
||||
import { logger } from "./logger";
|
||||
import { APP_NAME } from "./constants";
|
||||
|
||||
/* ── IPC validation helpers ────────────────────────────────────── */
|
||||
function validateString(value: unknown, name: string): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${name} muss ein String sein`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function validateStringArray(value: unknown, name: string): string[] {
|
||||
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
|
||||
throw new Error(`${name} muss ein String-Array sein`);
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
/* ── Single Instance Lock ───────────────────────────────────────── */
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
@@ -45,6 +59,19 @@ function createWindow(): BrowserWindow {
|
||||
}
|
||||
});
|
||||
|
||||
if (!isDevMode()) {
|
||||
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://api.github.com https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu"
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (isDevMode()) {
|
||||
void window.loadURL("http://localhost:5173");
|
||||
} else {
|
||||
@@ -96,13 +123,13 @@ function startClipboardWatcher(): void {
|
||||
if (clipboardTimer) {
|
||||
return;
|
||||
}
|
||||
lastClipboardText = clipboard.readText();
|
||||
lastClipboardText = clipboard.readText().slice(0, 50000);
|
||||
clipboardTimer = setInterval(() => {
|
||||
const text = clipboard.readText();
|
||||
if (text === lastClipboardText || !text.trim()) {
|
||||
return;
|
||||
}
|
||||
lastClipboardText = text;
|
||||
lastClipboardText = text.slice(0, 50000);
|
||||
const links = extractLinksFromText(text);
|
||||
if (links.length > 0 && mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLIPBOARD_DETECTED, links);
|
||||
@@ -144,7 +171,7 @@ function registerIpcHandlers(): void {
|
||||
if (result.started) {
|
||||
setTimeout(() => {
|
||||
app.quit();
|
||||
}, 350);
|
||||
}, 800);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
@@ -166,22 +193,50 @@ function registerIpcHandlers(): void {
|
||||
updateTray();
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => controller.addLinks(payload));
|
||||
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
|
||||
validateString(payload?.rawText, "rawText");
|
||||
return controller.addLinks(payload);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? []));
|
||||
ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
|
||||
ipcMain.handle(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") =>
|
||||
controller.resolveStartConflict(packageId, policy));
|
||||
ipcMain.handle(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => {
|
||||
validateString(packageId, "packageId");
|
||||
validateString(policy, "policy");
|
||||
if (policy !== "keep" && policy !== "skip" && policy !== "overwrite") {
|
||||
throw new Error("policy muss 'keep', 'skip' oder 'overwrite' sein");
|
||||
}
|
||||
return controller.resolveStartConflict(packageId, policy);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
||||
ipcMain.handle(IPC_CHANNELS.START, () => controller.start());
|
||||
ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop());
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause());
|
||||
ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => controller.cancelPackage(packageId));
|
||||
ipcMain.handle(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => controller.renamePackage(packageId, newName));
|
||||
ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => controller.reorderPackages(packageIds));
|
||||
ipcMain.handle(IPC_CHANNELS.REMOVE_ITEM, (_event: IpcMainInvokeEvent, itemId: string) => controller.removeItem(itemId));
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => controller.togglePackage(packageId));
|
||||
ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
return controller.cancelPackage(packageId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
validateString(newName, "newName");
|
||||
return controller.renamePackage(packageId, newName);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => {
|
||||
validateStringArray(packageIds, "packageIds");
|
||||
return controller.reorderPackages(packageIds);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.REMOVE_ITEM, (_event: IpcMainInvokeEvent, itemId: string) => {
|
||||
validateString(itemId, "itemId");
|
||||
return controller.removeItem(itemId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
validateString(packageId, "packageId");
|
||||
return controller.togglePackage(packageId);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, () => controller.exportQueue());
|
||||
ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => controller.importQueue(json));
|
||||
ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => {
|
||||
validateString(json, "json");
|
||||
return controller.importQueue(json);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {
|
||||
const settings = controller.getSettings();
|
||||
const next = !settings.clipboardWatch;
|
||||
|
||||
+26
-3
@@ -147,6 +147,8 @@ export function loadSettings(paths: StoragePaths): AppSettings {
|
||||
return defaultSettings();
|
||||
}
|
||||
try {
|
||||
// Safe: parsed is spread into a fresh object with defaults first, and normalizeSettings
|
||||
// validates every field, so prototype pollution via __proto__ / constructor is not a concern.
|
||||
const parsed = JSON.parse(fs.readFileSync(paths.configFile, "utf8")) as AppSettings;
|
||||
const merged = normalizeSettings({
|
||||
...defaultSettings(),
|
||||
@@ -163,7 +165,7 @@ function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void
|
||||
try {
|
||||
fs.renameSync(tempPath, targetPath);
|
||||
} catch (renameError: unknown) {
|
||||
if ((renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
fs.copyFileSync(tempPath, targetPath);
|
||||
try { fs.rmSync(tempPath, { force: true }); } catch {}
|
||||
} else {
|
||||
@@ -174,6 +176,14 @@ function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void
|
||||
|
||||
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
||||
ensureBaseDir(paths.baseDir);
|
||||
// Create a backup of the existing config before overwriting
|
||||
if (fs.existsSync(paths.configFile)) {
|
||||
try {
|
||||
fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`);
|
||||
} catch {
|
||||
// Best-effort backup; proceed even if it fails
|
||||
}
|
||||
}
|
||||
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
||||
const payload = JSON.stringify(persisted, null, 2);
|
||||
const tempPath = `${paths.configFile}.tmp`;
|
||||
@@ -205,13 +215,26 @@ export function loadSession(paths: StoragePaths): SessionState {
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as Partial<SessionState>;
|
||||
return {
|
||||
const session: SessionState = {
|
||||
...emptySession(),
|
||||
...parsed,
|
||||
packages: parsed.packages ?? {},
|
||||
items: parsed.items ?? {},
|
||||
packageOrder: parsed.packageOrder ?? []
|
||||
};
|
||||
|
||||
// Reset transient fields that may be stale from a previous crash
|
||||
const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
|
||||
for (const item of Object.values(session.items)) {
|
||||
if (ACTIVE_STATUSES.has(item.status)) {
|
||||
item.status = "queued";
|
||||
item.lastError = "";
|
||||
}
|
||||
// Always clear stale speed values
|
||||
item.speedBps = 0;
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
logger.error(`Session konnte nicht geladen werden: ${String(error)}`);
|
||||
return emptySession();
|
||||
@@ -243,7 +266,7 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat
|
||||
try {
|
||||
await fsp.rename(tempPath, paths.sessionFile);
|
||||
} catch (renameError: unknown) {
|
||||
if ((renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
if (renameError && typeof renameError === "object" && "code" in renameError && (renameError as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
await fsp.copyFile(tempPath, paths.sessionFile);
|
||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||
} else {
|
||||
|
||||
+2
-2
@@ -69,12 +69,12 @@ function timeoutController(ms: number): { signal: AbortSignal; clear: () => void
|
||||
};
|
||||
}
|
||||
|
||||
function parseVersionParts(version: string): number[] {
|
||||
export function parseVersionParts(version: string): number[] {
|
||||
const cleaned = version.replace(/^v/i, "").trim();
|
||||
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
|
||||
}
|
||||
|
||||
function isRemoteNewer(currentVersion: string, latestVersion: string): boolean {
|
||||
export function isRemoteNewer(currentVersion: string, latestVersion: string): boolean {
|
||||
const current = parseVersionParts(currentVersion);
|
||||
const latest = parseVersionParts(latestVersion);
|
||||
const maxLen = Math.max(current.length, latest.length);
|
||||
|
||||
Reference in New Issue
Block a user