Release v1.4.27 with bug audit hardening fixes

This commit is contained in:
Sucukdeluxe
2026-02-28 14:12:16 +01:00
parent cbc423e4b7
commit 8700db4a37
27 changed files with 1322 additions and 119 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ export const APP_VERSION: string = packageJson.version;
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
export const DLC_SERVICE_URL = "http://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
+24 -5
View File
@@ -5,6 +5,8 @@ import { DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./c
import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils";
import { ParsedPackageInput } from "../shared/types";
const MAX_DLC_FILE_BYTES = 8 * 1024 * 1024;
function decodeDcryptPayload(responseText: string): unknown {
let text = String(responseText || "").trim();
const m = text.match(/<textarea[^>]*>([\s\S]*?)<\/textarea>/i);
@@ -62,6 +64,14 @@ function decryptRcPayload(base64Rc: string): Buffer {
return Buffer.concat([decipher.update(rcBytes), decipher.final()]);
}
function readDlcFileWithLimit(filePath: string): Buffer {
const stat = fs.statSync(filePath);
if (stat.size <= 0 || stat.size > MAX_DLC_FILE_BYTES) {
throw new Error(`DLC-Datei ungültig oder zu groß (${Math.floor(stat.size)} B)`);
}
return fs.readFileSync(filePath);
}
function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
const packages: ParsedPackageInput[] = [];
const packageRegex = /<package\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/package>/gi;
@@ -104,7 +114,7 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
}
async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]> {
const content = fs.readFileSync(filePath, "ascii").trim();
const content = readDlcFileWithLimit(filePath).toString("ascii").trim();
if (content.length < 89) {
return [];
}
@@ -129,10 +139,19 @@ async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]>
decipher.setAutoPadding(false);
let decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
const pad = decrypted[decrypted.length - 1];
if (pad > 0 && pad <= 16) {
decrypted = decrypted.subarray(0, decrypted.length - pad);
if (decrypted.length === 0) {
throw new Error("DLC-Entschlüsselung lieferte keine Daten");
}
const pad = decrypted[decrypted.length - 1];
if (pad <= 0 || pad > 16 || pad > decrypted.length) {
throw new Error("Ungültiges DLC-Padding");
}
for (let index = 1; index <= pad; index += 1) {
if (decrypted[decrypted.length - index] !== pad) {
throw new Error("Ungültiges DLC-Padding");
}
}
decrypted = decrypted.subarray(0, decrypted.length - pad);
const xmlData = Buffer.from(decrypted.toString("utf8"), "base64").toString("utf8");
return parsePackagesFromDlcXml(xmlData);
@@ -140,7 +159,7 @@ async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]>
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
const fileName = path.basename(filePath);
const blob = new Blob([fs.readFileSync(filePath)]);
const blob = new Blob([new Uint8Array(readDlcFileWithLimit(filePath))]);
const form = new FormData();
form.set("dlcfile", blob, fileName);
+29 -10
View File
@@ -50,6 +50,27 @@ function retryDelay(attempt: number): number {
return Math.min(5000, 400 * 2 ** attempt);
}
function readHttpStatusFromErrorText(text: string): number {
const match = String(text || "").match(/HTTP\s+(\d{3})/i);
return match ? Number(match[1]) : 0;
}
function isRetryableErrorText(text: string): boolean {
const status = readHttpStatusFromErrorText(text);
if (status === 429 || status >= 500) {
return true;
}
const lower = String(text || "").toLowerCase();
return lower.includes("timeout")
|| lower.includes("network")
|| lower.includes("fetch failed")
|| lower.includes("aborted")
|| lower.includes("econnreset")
|| lower.includes("enotfound")
|| lower.includes("etimedout")
|| lower.includes("html statt json");
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
@@ -286,15 +307,12 @@ async function resolveRapidgatorFilename(link: string): Promise<string> {
function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] {
const linkParam = encodeURIComponent(link);
const authParam = encodeURIComponent(token);
const safeToken = String(token || "").trim();
const useAuthHeader = Boolean(safeToken);
return [
{
url: `${BEST_DEBRID_API_BASE}/generateLink?link=${linkParam}`,
useAuthHeader: true
},
{
url: `${BEST_DEBRID_API_BASE}/generateLink?auth=${authParam}&link=${linkParam}`,
useAuthHeader: false
useAuthHeader
}
];
}
@@ -402,7 +420,7 @@ class BestDebridClient {
throw new Error("BestDebrid Antwort ohne Download-Link");
} catch (error) {
lastError = compactErrorText(error);
if (attempt >= REQUEST_RETRIES) {
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break;
}
await sleep(retryDelay(attempt));
@@ -490,7 +508,8 @@ class AllDebridClient {
chunkResolved = true;
break;
} catch (error) {
if (attempt >= REQUEST_RETRIES) {
const errorText = compactErrorText(error);
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
throw error;
}
await sleep(retryDelay(attempt));
@@ -579,7 +598,7 @@ class AllDebridClient {
};
} catch (error) {
lastError = compactErrorText(error);
if (attempt >= REQUEST_RETRIES) {
if (attempt >= REQUEST_RETRIES || !isRetryableErrorText(lastError)) {
break;
}
await sleep(retryDelay(attempt));
@@ -738,7 +757,7 @@ export class DebridService {
return Boolean(this.settings.token.trim());
}
if (provider === "megadebrid") {
return Boolean(this.settings.megaLogin.trim() && this.settings.megaPassword.trim());
return Boolean(this.settings.megaLogin.trim() && this.settings.megaPassword.trim() && this.options.megaWebUnrestrict);
}
if (provider === "alldebrid") {
return Boolean(this.settings.allDebridToken.trim());
+42 -8
View File
@@ -655,6 +655,9 @@ export class DownloadManager extends EventEmitter {
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear();
this.speedEvents = [];
this.speedEventsHead = 0;
this.speedBytesLastWindow = 0;
this.packagePostProcessTasks.clear();
this.packagePostProcessAbortControllers.clear();
this.hybridExtractRequeue.clear();
@@ -798,11 +801,17 @@ export class DownloadManager extends EventEmitter {
active.abortController.abort("cancel");
}
this.releaseTargetPath(itemId);
this.runItemIds.delete(itemId);
this.runOutcomes.delete(itemId);
this.itemContributedBytes.delete(itemId);
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
}
delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
this.runPackageIds.delete(packageId);
this.runCompletedPackages.delete(packageId);
this.hybridExtractRequeue.delete(packageId);
this.persistSoon();
this.emitState(true);
return { skipped: true, overwritten: false };
@@ -846,6 +855,11 @@ export class DownloadManager extends EventEmitter {
item.fullStatus = "Wartet";
item.updatedAt = nowMs();
item.targetPath = path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url)));
this.runOutcomes.delete(itemId);
this.itemContributedBytes.delete(itemId);
if (this.session.running) {
this.runItemIds.add(itemId);
}
}
pkg.status = "queued";
pkg.updatedAt = nowMs();
@@ -1294,6 +1308,7 @@ export class DownloadManager extends EventEmitter {
this.session.reconnectReason = "";
this.speedEvents = [];
this.speedBytesLastWindow = 0;
this.speedEventsHead = 0;
this.lastGlobalProgressBytes = 0;
this.lastGlobalProgressAt = nowMs();
this.summary = null;
@@ -1319,6 +1334,7 @@ export class DownloadManager extends EventEmitter {
this.consecutiveReconnects = 0;
this.speedEvents = [];
this.speedBytesLastWindow = 0;
this.speedEventsHead = 0;
this.lastGlobalProgressBytes = 0;
this.lastGlobalProgressAt = nowMs();
this.globalSpeedLimitQueue = Promise.resolve();
@@ -1326,7 +1342,13 @@ export class DownloadManager extends EventEmitter {
this.summary = null;
this.persistSoon();
this.emitState(true);
this.ensureScheduler();
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);
});
}
public stop(): void {
@@ -1396,6 +1418,7 @@ export class DownloadManager extends EventEmitter {
this.speedEvents = [];
this.speedBytesLastWindow = 0;
this.speedEventsHead = 0;
this.runItemIds.clear();
this.runPackageIds.clear();
this.runOutcomes.clear();
@@ -1599,6 +1622,9 @@ export class DownloadManager extends EventEmitter {
private recordSpeed(bytes: number): void {
const now = nowMs();
if (bytes > 0 && this.consecutiveReconnects > 0) {
this.consecutiveReconnects = 0;
}
const bucket = now - (now % 120);
const last = this.speedEvents[this.speedEvents.length - 1];
if (last && last.at === bucket) {
@@ -3363,7 +3389,9 @@ export class DownloadManager extends EventEmitter {
}
} catch (error) {
const reasonRaw = String(error || "");
if (reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout")) {
const isExtractAbort = reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout");
let timeoutHandled = false;
if (isExtractAbort) {
if (timedOut) {
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`);
@@ -3373,6 +3401,7 @@ export class DownloadManager extends EventEmitter {
}
pkg.status = "failed";
pkg.updatedAt = nowMs();
timeoutHandled = true;
} else {
for (const entry of completedItems) {
if (/^Entpacken/i.test(entry.fullStatus || "")) {
@@ -3386,13 +3415,15 @@ export class DownloadManager extends EventEmitter {
return;
}
}
const reason = compactErrorText(error);
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
for (const entry of completedItems) {
entry.fullStatus = `Entpack-Fehler: ${reason}`;
entry.updatedAt = nowMs();
if (!timeoutHandled) {
const reason = compactErrorText(error);
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
for (const entry of completedItems) {
entry.fullStatus = `Entpack-Fehler: ${reason}`;
entry.updatedAt = nowMs();
}
pkg.status = "failed";
}
pkg.status = "failed";
} finally {
clearTimeout(extractDeadline);
if (signal) {
@@ -3500,6 +3531,9 @@ export class DownloadManager extends EventEmitter {
this.reservedTargetPaths.clear();
this.claimedTargetPathByItem.clear();
this.itemContributedBytes.clear();
this.speedEvents = [];
this.speedEventsHead = 0;
this.speedBytesLastWindow = 0;
this.globalSpeedLimitQueue = Promise.resolve();
this.globalSpeedLimitNextAt = 0;
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
+66 -13
View File
@@ -18,6 +18,7 @@ let resolveExtractorCommandInFlight: Promise<string> | null = null;
const EXTRACTOR_RETRY_AFTER_MS = 30_000;
const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256;
const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000;
export interface ExtractOptions {
packageDir: string;
@@ -63,6 +64,10 @@ export function pathSetKey(filePath: string): string {
return process.platform === "win32" ? filePath.toLowerCase() : filePath;
}
function archiveNameKey(fileName: string): string {
return process.platform === "win32" ? String(fileName || "").toLowerCase() : String(fileName || "");
}
function archiveSortKey(filePath: string): string {
const fileName = path.basename(filePath).toLowerCase();
return fileName
@@ -244,7 +249,7 @@ function readExtractResumeState(packageDir: string, packageId?: string): Set<str
try {
const payload = JSON.parse(fs.readFileSync(progressPath, "utf8")) as Partial<ExtractResumeState>;
const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : [];
return new Set(names.map((value) => String(value || "").trim()).filter(Boolean));
return new Set(names.map((value) => archiveNameKey(String(value || "").trim())).filter(Boolean));
} catch {
return new Set<string>();
}
@@ -255,7 +260,9 @@ function writeExtractResumeState(packageDir: string, completedArchives: Set<stri
fs.mkdirSync(packageDir, { recursive: true });
const progressPath = extractProgressFilePath(packageDir, packageId);
const payload: ExtractResumeState = {
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
completedArchives: Array.from(completedArchives)
.map((name) => archiveNameKey(name))
.sort((a, b) => a.localeCompare(b))
};
fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8");
} catch (error) {
@@ -457,10 +464,24 @@ function runExtractCommand(
});
child.on("close", (code) => {
if (code === 0 || code === 1) {
if (code === 0) {
finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" });
return;
}
if (code === 1) {
const lowered = output.toLowerCase();
const warningOnly = !lowered.includes("crc failed")
&& !lowered.includes("checksum error")
&& !lowered.includes("wrong password")
&& !lowered.includes("cannot open")
&& !lowered.includes("fatal error")
&& !lowered.includes("unexpected end of archive")
&& !lowered.includes("error:");
if (warningOnly) {
finish({ ok: true, missingCommand: false, aborted: false, timedOut: false, errorText: "" });
return;
}
}
const cleaned = cleanErrorText(output);
finish({
ok: false,
@@ -521,7 +542,7 @@ async function resolveExtractorCommandInternal(): Promise<string> {
continue;
}
const probeArgs = command.toLowerCase().includes("winrar") ? ["-?"] : ["?"];
const probe = await runExtractCommand(command, probeArgs);
const probe = await runExtractCommand(command, probeArgs, undefined, undefined, EXTRACTOR_PROBE_TIMEOUT_MS);
if (!probe.missingCommand) {
resolvedExtractorCommand = command;
resolveFailureReason = "";
@@ -634,13 +655,35 @@ async function runExternalExtract(
throw new Error(lastError || "Entpacken fehlgeschlagen");
}
function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode): void {
function isZipSafetyGuardError(error: unknown): boolean {
const text = String(error || "").toLowerCase();
return text.includes("zip-eintrag zu groß")
|| text.includes("zip-eintrag komprimiert zu groß")
|| text.includes("zip-eintrag ohne sichere groessenangabe")
|| text.includes("path traversal");
}
function shouldFallbackToExternalZip(error: unknown): boolean {
if (isZipSafetyGuardError(error)) {
return false;
}
const text = String(error || "").toLowerCase();
if (text.includes("aborted:extract") || text.includes("extract_aborted")) {
return false;
}
return true;
}
function extractZipArchive(archivePath: string, targetDir: string, conflictMode: ConflictMode, signal?: AbortSignal): void {
const mode = effectiveConflictMode(conflictMode);
const memoryLimitBytes = zipEntryMemoryLimitBytes();
const zip = new AdmZip(archivePath);
const entries = zip.getEntries();
const resolvedTarget = path.resolve(targetDir);
for (const entry of entries) {
if (signal?.aborted) {
throw new Error("aborted:extract");
}
const outputPath = path.resolve(targetDir, entry.entryName);
if (!outputPath.startsWith(resolvedTarget + path.sep) && outputPath !== resolvedTarget) {
logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`);
@@ -700,10 +743,16 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
candidate = path.join(parsed.dir, `${parsed.name} (${n})${parsed.ext}`);
n += 1;
}
if (signal?.aborted) {
throw new Error("aborted:extract");
}
fs.writeFileSync(candidate, entry.getData());
continue;
}
}
if (signal?.aborted) {
throw new Error("aborted:extract");
}
fs.writeFileSync(outputPath, entry.getData());
}
}
@@ -945,7 +994,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
let passwordCandidates = archivePasswords(options.passwordList || "");
const resumeCompleted = readExtractResumeState(options.packageDir, options.packageId);
const resumeCompletedAtStart = resumeCompleted.size;
const allCandidateNames = new Set(allCandidates.map((archivePath) => path.basename(archivePath)));
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
for (const archiveName of Array.from(resumeCompleted.values())) {
if (!allCandidateNames.has(archiveName)) {
resumeCompleted.delete(archiveName);
@@ -957,13 +1006,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
clearExtractResumeState(options.packageDir, options.packageId);
}
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(path.basename(archivePath)));
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(archiveNameKey(path.basename(archivePath))));
let extracted = candidates.length - pendingCandidates.length;
let failed = 0;
let lastError = "";
const extractedArchives = new Set<string>();
for (const archivePath of candidates) {
if (resumeCompleted.has(path.basename(archivePath))) {
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
extractedArchives.add(archivePath);
}
}
@@ -1002,7 +1051,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (options.signal?.aborted) {
throw new Error("aborted:extract");
}
const archiveName = path.basename(archivePath);
const archiveName = path.basename(archivePath);
const archiveResumeKey = archiveNameKey(archiveName);
const archiveStartedAt = Date.now();
let archivePercent = 0;
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0);
@@ -1023,16 +1073,19 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
} catch (error) {
if (isNoExtractorError(String(error))) {
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal);
} else {
throw error;
}
}
} else {
try {
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal);
archivePercent = 100;
} catch {
} catch (error) {
if (!shouldFallbackToExternalZip(error)) {
throw error;
}
const usedPassword = await runExternalExtract(archivePath, options.targetDir, "overwrite", passwordCandidates, (value) => {
archivePercent = Math.max(archivePercent, value);
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
@@ -1049,7 +1102,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
}
extracted += 1;
extractedArchives.add(archivePath);
resumeCompleted.add(archiveName);
resumeCompleted.add(archiveResumeKey);
writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
archivePercent = 100;
+16 -2
View File
@@ -41,7 +41,17 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
return map;
}
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
const manifestFiles = fs.readdirSync(packageDir, { withFileTypes: true })
.filter((entry) => {
if (!entry.isFile()) {
return false;
}
const ext = path.extname(entry.name).toLowerCase();
return patterns.some(([pattern]) => pattern === ext);
})
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
for (const entry of manifestFiles) {
if (!entry.isFile()) {
continue;
}
@@ -70,7 +80,11 @@ export function readHashManifest(packageDir: string): Map<string, ParsedHashEntr
...parsed,
algorithm: hit[1]
};
map.set(parsed.fileName.toLowerCase(), normalized);
const key = parsed.fileName.toLowerCase();
if (map.has(key)) {
continue;
}
map.set(key, normalized);
}
}
return map;
+10 -5
View File
@@ -5,6 +5,7 @@ import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc";
import { logger } from "./logger";
import { APP_NAME } from "./constants";
import { extractHttpLinksFromText } from "./utils";
/* ── IPC validation helpers ────────────────────────────────────── */
function validateString(value: unknown, name: string): string {
@@ -39,6 +40,7 @@ let tray: Tray | null = null;
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
let lastClipboardText = "";
const controller = new AppController();
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
function isDevMode(): boolean {
return process.env.NODE_ENV === "development";
@@ -115,21 +117,24 @@ function destroyTray(): void {
}
function extractLinksFromText(text: string): string[] {
const matches = text.match(/https?:\/\/[^\s<>"']+/gi);
return matches ? Array.from(new Set(matches)) : [];
return extractHttpLinksFromText(text);
}
function normalizeClipboardText(text: string): string {
return String(text || "").slice(0, CLIPBOARD_MAX_TEXT_CHARS);
}
function startClipboardWatcher(): void {
if (clipboardTimer) {
return;
}
lastClipboardText = clipboard.readText().slice(0, 50000);
lastClipboardText = normalizeClipboardText(clipboard.readText());
clipboardTimer = setInterval(() => {
const text = clipboard.readText();
const text = normalizeClipboardText(clipboard.readText());
if (text === lastClipboardText || !text.trim()) {
return;
}
lastClipboardText = text.slice(0, 50000);
lastClipboardText = text;
const links = extractLinksFromText(text);
if (links.length > 0 && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IPC_CHANNELS.CLIPBOARD_DETECTED, links);
+19 -3
View File
@@ -16,7 +16,18 @@ function retryDelay(attempt: number): number {
return Math.min(5000, 400 * 2 ** attempt);
}
function parseErrorBody(status: number, body: string): string {
function looksLikeHtmlResponse(contentType: string, body: string): boolean {
const type = String(contentType || "").toLowerCase();
if (type.includes("text/html") || type.includes("application/xhtml+xml")) {
return true;
}
return /^\s*<(!doctype\s+html|html\b)/i.test(String(body || ""));
}
function parseErrorBody(status: number, body: string, contentType: string): string {
if (looksLikeHtmlResponse(contentType, body)) {
return `Real-Debrid lieferte HTML statt JSON (HTTP ${status})`;
}
const clean = compactErrorText(body);
return clean || `HTTP ${status}`;
}
@@ -45,8 +56,9 @@ export class RealDebridClient {
});
const text = await response.text();
const contentType = String(response.headers.get("content-type") || "");
if (!response.ok) {
const parsed = parseErrorBody(response.status, text);
const parsed = parseErrorBody(response.status, text, contentType);
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
await sleep(retryDelay(attempt));
continue;
@@ -54,11 +66,15 @@ export class RealDebridClient {
throw new Error(parsed);
}
if (looksLikeHtmlResponse(contentType, text)) {
throw new Error("Real-Debrid lieferte HTML statt JSON");
}
let payload: Record<string, unknown>;
try {
payload = JSON.parse(text) as Record<string, unknown>;
} catch {
throw new Error(`Ungültige JSON-Antwort: ${text.slice(0, 120)}`);
throw new Error("Ungültige JSON-Antwort von Real-Debrid");
}
const directUrl = String(payload.download || payload.link || "").trim();
if (!directUrl) {
+189 -36
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { AppSettings, BandwidthScheduleEntry, SessionState } from "../shared/types";
import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, DownloadStatus, PackageEntry, SessionState } from "../shared/types";
import { defaultSettings } from "./constants";
import { logger } from "./logger";
@@ -12,6 +12,10 @@ const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]);
const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]);
const VALID_SPEED_MODES = new Set(["global", "per_download"]);
const VALID_THEMES = new Set(["dark", "light"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
]);
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]);
function asText(value: unknown): string {
return String(value ?? "").trim();
@@ -148,24 +152,171 @@ function ensureBaseDir(baseDir: string): void {
fs.mkdirSync(baseDir, { recursive: true });
}
export function loadSettings(paths: StoragePaths): AppSettings {
ensureBaseDir(paths.baseDir);
if (!fs.existsSync(paths.configFile)) {
return defaultSettings();
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
function readSettingsFile(filePath: string): AppSettings | null {
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 parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
const merged = normalizeSettings({
...defaultSettings(),
...parsed
});
return sanitizeCredentialPersistence(merged);
} catch (error) {
logger.error(`Konfiguration konnte nicht geladen werden: ${String(error)}`);
} catch {
return null;
}
}
function normalizeLoadedSession(raw: unknown): SessionState {
const fallback = emptySession();
const parsed = asRecord(raw);
if (!parsed) {
return fallback;
}
const now = Date.now();
const itemsById: Record<string, DownloadItem> = {};
const rawItems = asRecord(parsed.items) ?? {};
for (const [entryId, rawItem] of Object.entries(rawItems)) {
const item = asRecord(rawItem);
if (!item) {
continue;
}
const id = asText(item.id) || entryId;
const packageId = asText(item.packageId);
const url = asText(item.url);
if (!id || !packageId || !url) {
continue;
}
const statusRaw = asText(item.status) as DownloadStatus;
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
const providerRaw = asText(item.provider) as DebridProvider;
itemsById[id] = {
id,
packageId,
url,
provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null,
status,
retries: clampNumber(item.retries, 0, 0, 1_000_000),
speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000),
downloadedBytes: clampNumber(item.downloadedBytes, 0, 0, 10_000_000_000_000),
totalBytes: item.totalBytes == null ? null : clampNumber(item.totalBytes, 0, 0, 10_000_000_000_000),
progressPercent: clampNumber(item.progressPercent, 0, 0, 100),
fileName: asText(item.fileName) || "download.bin",
targetPath: asText(item.targetPath),
resumable: item.resumable === undefined ? true : Boolean(item.resumable),
attempts: clampNumber(item.attempts, 0, 0, 10_000),
lastError: asText(item.lastError),
fullStatus: asText(item.fullStatus),
createdAt: clampNumber(item.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
updatedAt: clampNumber(item.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
};
}
const packagesById: Record<string, PackageEntry> = {};
const rawPackages = asRecord(parsed.packages) ?? {};
for (const [entryId, rawPkg] of Object.entries(rawPackages)) {
const pkg = asRecord(rawPkg);
if (!pkg) {
continue;
}
const id = asText(pkg.id) || entryId;
if (!id) {
continue;
}
const statusRaw = asText(pkg.status) as DownloadStatus;
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : [];
packagesById[id] = {
id,
name: asText(pkg.name) || "Paket",
outputDir: asText(pkg.outputDir),
extractDir: asText(pkg.extractDir),
status,
itemIds: rawItemIds
.map((value) => asText(value))
.filter((value) => value.length > 0),
cancelled: Boolean(pkg.cancelled),
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
};
}
for (const [itemId, item] of Object.entries(itemsById)) {
if (!packagesById[item.packageId]) {
delete itemsById[itemId];
}
}
for (const pkg of Object.values(packagesById)) {
pkg.itemIds = pkg.itemIds.filter((itemId) => {
const item = itemsById[itemId];
return Boolean(item) && item.packageId === pkg.id;
});
}
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
const packageOrder = rawOrder
.map((entry) => asText(entry))
.filter((id) => id in packagesById);
for (const packageId of Object.keys(packagesById)) {
if (!packageOrder.includes(packageId)) {
packageOrder.push(packageId);
}
}
return {
...fallback,
version: clampNumber(parsed.version, fallback.version, 1, 10),
packageOrder,
packages: packagesById,
items: itemsById,
runStartedAt: clampNumber(parsed.runStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
totalDownloadedBytes: clampNumber(parsed.totalDownloadedBytes, 0, 0, Number.MAX_SAFE_INTEGER),
summaryText: asText(parsed.summaryText),
reconnectUntil: clampNumber(parsed.reconnectUntil, 0, 0, Number.MAX_SAFE_INTEGER),
reconnectReason: asText(parsed.reconnectReason),
paused: Boolean(parsed.paused),
running: Boolean(parsed.running),
updatedAt: clampNumber(parsed.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
};
}
export function loadSettings(paths: StoragePaths): AppSettings {
ensureBaseDir(paths.baseDir);
if (!fs.existsSync(paths.configFile)) {
return defaultSettings();
}
const loaded = readSettingsFile(paths.configFile);
if (loaded) {
return loaded;
}
const backupFile = `${paths.configFile}.bak`;
const backupLoaded = fs.existsSync(backupFile) ? readSettingsFile(backupFile) : null;
if (backupLoaded) {
logger.warn("Konfiguration defekt, Backup-Datei wird verwendet");
try {
const payload = JSON.stringify(backupLoaded, null, 2);
const tempPath = `${paths.configFile}.tmp`;
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.configFile);
} catch {
// ignore restore write failure
}
return backupLoaded;
}
logger.error("Konfiguration konnte nicht geladen werden (auch Backup fehlgeschlagen)");
return defaultSettings();
}
function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void {
@@ -221,14 +372,8 @@ export function loadSession(paths: StoragePaths): SessionState {
return emptySession();
}
try {
const parsed = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as Partial<SessionState>;
const session: SessionState = {
...emptySession(),
...parsed,
packages: parsed.packages ?? {},
items: parsed.items ?? {},
packageOrder: parsed.packageOrder ?? []
};
const parsed = JSON.parse(fs.readFileSync(paths.sessionFile, "utf8")) as unknown;
const session = normalizeLoadedSession(parsed);
// Reset transient fields that may be stale from a previous crash
const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
@@ -257,29 +402,32 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
}
let asyncSaveRunning = false;
let asyncSaveQueued: { paths: StoragePaths; session: SessionState } | null = null;
let asyncSaveQueued: { paths: StoragePaths; payload: string } | null = null;
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
async function writeSessionPayload(paths: StoragePaths, payload: string): Promise<void> {
await fs.promises.mkdir(paths.baseDir, { recursive: true });
const tempPath = `${paths.sessionFile}.tmp`;
await fsp.writeFile(tempPath, payload, "utf8");
try {
await fsp.rename(tempPath, paths.sessionFile);
} catch (renameError: unknown) {
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 {
throw renameError;
}
}
}
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string): Promise<void> {
if (asyncSaveRunning) {
asyncSaveQueued = { paths, session };
asyncSaveQueued = { paths, payload };
return;
}
asyncSaveRunning = true;
try {
await fs.promises.mkdir(paths.baseDir, { recursive: true });
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
const tempPath = `${paths.sessionFile}.tmp`;
await fsp.writeFile(tempPath, payload, "utf8");
try {
await fsp.rename(tempPath, paths.sessionFile);
} catch (renameError: unknown) {
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 {
throw renameError;
}
}
await writeSessionPayload(paths, payload);
} catch (error) {
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
} finally {
@@ -287,7 +435,12 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat
if (asyncSaveQueued) {
const queued = asyncSaveQueued;
asyncSaveQueued = null;
void saveSessionAsync(queued.paths, queued.session);
void saveSessionPayloadAsync(queued.paths, queued.payload);
}
}
}
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
await saveSessionPayloadAsync(paths, payload);
}
+97 -9
View File
@@ -1,6 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
@@ -20,6 +21,7 @@ const UPDATE_USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
type ReleaseAsset = {
name: string;
browser_download_url: string;
digest: string;
};
export function normalizeUpdateRepo(repo: string): string {
@@ -28,6 +30,17 @@ export function normalizeUpdateRepo(repo: string): string {
return DEFAULT_UPDATE_REPO;
}
const isValidRepoPart = (value: string): boolean => {
const part = String(value || "").trim();
if (!part || part === "." || part === "..") {
return false;
}
if (part.includes("..")) {
return false;
}
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(part);
};
const normalizeParts = (input: string): string => {
const cleaned = input
.replace(/^https?:\/\/(?:www\.)?github\.com\//i, "")
@@ -37,7 +50,11 @@ export function normalizeUpdateRepo(repo: string): string {
.replace(/^\/+|\/+$/g, "");
const parts = cleaned.split("/").filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]}/${parts[1]}`;
const owner = parts[0];
const repository = parts[1];
if (isValidRepoPart(owner) && isValidRepoPart(repository)) {
return `${owner}/${repository}`;
}
}
return "";
};
@@ -70,6 +87,31 @@ function timeoutController(ms: number): { signal: AbortSignal; clear: () => void
};
}
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) => {
timer = setTimeout(() => {
void response.body?.cancel().catch(() => undefined);
reject(new Error(`timeout:${timeoutMs}`));
}, timeoutMs);
});
try {
const payload = await Promise.race([
response.json().catch(() => null) as Promise<unknown>,
timeoutPromise
]);
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return null;
}
return payload as Record<string, unknown>;
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
function getDownloadBodyIdleTimeoutMs(): number {
const fromEnv = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 1000 && fromEnv <= 30 * 60 * 1000) {
@@ -116,7 +158,8 @@ function readReleaseAssets(payload: Record<string, unknown>): ReleaseAsset[] {
return assets
.map((asset) => ({
name: String(asset.name || ""),
browser_download_url: String(asset.browser_download_url || "")
browser_download_url: String(asset.browser_download_url || ""),
digest: String(asset.digest || "").trim()
}))
.filter((asset) => asset.name && asset.browser_download_url);
}
@@ -145,10 +188,15 @@ function parseReleasePayload(payload: Record<string, unknown>, fallback: UpdateC
latestTag,
releaseUrl,
setupAssetUrl: setup?.browser_download_url || "",
setupAssetName: setup?.name || ""
setupAssetName: setup?.name || "",
setupAssetDigest: setup?.digest || ""
};
}
function isDraftOrPrereleaseRelease(payload: Record<string, unknown>): boolean {
return Boolean(payload.draft) || Boolean(payload.prerelease);
}
async function fetchReleasePayload(safeRepo: string, endpoint: string): Promise<{ ok: boolean; status: number; payload: Record<string, unknown> | null }> {
const timeout = timeoutController(RELEASE_FETCH_TIMEOUT_MS);
let response: Response;
@@ -164,7 +212,7 @@ async function fetchReleasePayload(safeRepo: string, endpoint: string): Promise<
timeout.clear();
}
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
const payload = await readJsonWithTimeout(response, RELEASE_FETCH_TIMEOUT_MS);
return {
ok: response.ok,
status: response.status,
@@ -245,7 +293,40 @@ function deriveUpdateFileName(check: UpdateCheckResult, url: string): string {
}
}
async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Promise<{ setupAssetUrl: string; setupAssetName: string } | null> {
function normalizeSha256Digest(raw: string): string {
const text = String(raw || "").trim();
const prefixed = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
if (prefixed) {
return prefixed[1].toLowerCase();
}
const plain = text.match(/^([a-fA-F0-9]{64})$/);
return plain ? plain[1].toLowerCase() : "";
}
async function sha256File(filePath: string): Promise<string> {
const hash = crypto.createHash("sha256");
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
return await new Promise<string>((resolve, reject) => {
stream.on("data", (chunk: string | Buffer) => {
hash.update(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
});
stream.on("error", reject);
stream.on("end", () => resolve(hash.digest("hex").toLowerCase()));
});
}
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");
}
const actualDigest = await sha256File(targetPath);
if (actualDigest !== expectedDigest) {
throw new Error("Update-Integritätsprüfung fehlgeschlagen (SHA256 mismatch)");
}
}
async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Promise<{ setupAssetUrl: string; setupAssetName: string; setupAssetDigest: string } | null> {
const endpointCandidates = uniqueStrings([
tagHint ? `releases/tags/${encodeURIComponent(tagHint)}` : "",
"releases/latest"
@@ -257,13 +338,17 @@ async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Prom
if (!release.ok || !release.payload) {
continue;
}
if (isDraftOrPrereleaseRelease(release.payload)) {
continue;
}
const setup = pickSetupAsset(readReleaseAssets(release.payload));
if (!setup) {
continue;
}
return {
setupAssetUrl: setup.browser_download_url,
setupAssetName: setup.name
setupAssetName: setup.name,
setupAssetDigest: setup.digest
};
} catch {
// ignore and continue with next endpoint candidate
@@ -433,16 +518,18 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
let effectiveCheck: UpdateCheckResult = {
...check,
setupAssetUrl: String(check.setupAssetUrl || ""),
setupAssetName: String(check.setupAssetName || "")
setupAssetName: String(check.setupAssetName || ""),
setupAssetDigest: String(check.setupAssetDigest || "")
};
if (!effectiveCheck.setupAssetUrl) {
if (!effectiveCheck.setupAssetUrl || !effectiveCheck.setupAssetDigest) {
const refreshed = await resolveSetupAssetFromApi(safeRepo, effectiveCheck.latestTag);
if (refreshed) {
effectiveCheck = {
...effectiveCheck,
setupAssetUrl: refreshed.setupAssetUrl,
setupAssetName: refreshed.setupAssetName
setupAssetName: refreshed.setupAssetName,
setupAssetDigest: refreshed.setupAssetDigest
};
}
}
@@ -457,6 +544,7 @@ export async function installLatestUpdate(repo: string, prechecked?: UpdateCheck
try {
await downloadFromCandidates(candidates, targetPath);
await verifyDownloadedInstaller(targetPath, String(effectiveCheck.setupAssetDigest || ""));
const child = spawn(targetPath, [], {
detached: true,
stdio: "ignore"
+30
View File
@@ -63,6 +63,33 @@ export function isHttpLink(value: string): boolean {
}
}
export function extractHttpLinksFromText(text: string): string[] {
const matches = String(text || "").match(/https?:\/\/[^\s<>"']+/gi) ?? [];
const seen = new Set<string>();
const links: string[] = [];
for (const match of matches) {
let candidate = String(match || "").trim();
while (candidate.length > 0 && /[)\],.!?;:]+$/.test(candidate)) {
if (candidate.endsWith(")")) {
const openCount = (candidate.match(/\(/g) || []).length;
const closeCount = (candidate.match(/\)/g) || []).length;
if (closeCount <= openCount) {
break;
}
}
candidate = candidate.slice(0, -1);
}
if (!candidate || !isHttpLink(candidate) || seen.has(candidate)) {
continue;
}
seen.add(candidate);
links.push(candidate);
}
return links;
}
export function humanSize(bytes: number): string {
const value = Number(bytes);
if (!Number.isFinite(value) || value < 0) {
@@ -84,6 +111,9 @@ export function humanSize(bytes: number): string {
export function filenameFromUrl(url: string): string {
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return "download.bin";
}
const queryName = parsed.searchParams.get("filename")
|| parsed.searchParams.get("file")
|| parsed.searchParams.get("name")
+19 -7
View File
@@ -128,6 +128,7 @@ export function App(): ReactElement {
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
const activeCollectorTabRef = useRef(activeCollectorTab);
const activeTabRef = useRef<Tab>(tab);
const packageOrderRef = useRef<string[]>([]);
const draggedPackageIdRef = useRef<string | null>(null);
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
const [downloadSearch, setDownloadSearch] = useState("");
@@ -150,6 +151,10 @@ export function App(): ReactElement {
activeTabRef.current = tab;
}, [tab]);
useEffect(() => {
packageOrderRef.current = snapshot.session.packageOrder;
}, [snapshot.session.packageOrder]);
const showToast = (message: string, timeoutMs = 2200): void => {
setStatusToast(message);
if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); }
@@ -647,24 +652,28 @@ export function App(): ReactElement {
};
const movePackage = useCallback((packageId: string, direction: "up" | "down") => {
const order = [...snapshot.session.packageOrder];
const currentOrder = packageOrderRef.current;
const order = [...currentOrder];
const idx = order.indexOf(packageId);
if (idx < 0) { return; }
const target = direction === "up" ? idx - 1 : idx + 1;
if (target < 0 || target >= order.length) { return; }
[order[idx], order[target]] = [order[target], order[idx]];
packageOrderRef.current = order;
void window.rd.reorderPackages(order);
}, [snapshot.session.packageOrder]);
}, []);
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
const nextOrder = reorderPackageOrderByDrop(snapshot.session.packageOrder, draggedPackageId, targetPackageId);
const unchanged = nextOrder.length === snapshot.session.packageOrder.length
&& nextOrder.every((id, index) => id === snapshot.session.packageOrder[index]);
const currentOrder = packageOrderRef.current;
const nextOrder = reorderPackageOrderByDrop(currentOrder, draggedPackageId, targetPackageId);
const unchanged = nextOrder.length === currentOrder.length
&& nextOrder.every((id, index) => id === currentOrder[index]);
if (unchanged) {
return;
}
packageOrderRef.current = nextOrder;
void window.rd.reorderPackages(nextOrder);
}, [snapshot.session.packageOrder]);
}, []);
const addCollectorTab = (): void => {
const id = `tab-${nextCollectorId++}`;
@@ -888,7 +897,9 @@ export function App(): ReactElement {
onClick={() => {
const nextDescending = !downloadsSortDescending;
setDownloadsSortDescending(nextDescending);
const sorted = sortPackageOrderByName(snapshot.session.packageOrder, snapshot.session.packages, nextDescending);
const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder;
const sorted = sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
packageOrderRef.current = sorted;
void window.rd.reorderPackages(sorted);
}}
>
@@ -1021,6 +1032,7 @@ export function App(): ReactElement {
</div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtract} onChange={(e) => setBool("autoExtract", e.target.checked)} /> Auto-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (4SF/4SJ)</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
<label>Passwortliste (eine Zeile pro Passwort)</label>
<textarea
+16 -1
View File
@@ -278,12 +278,22 @@ body,
.downloads-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.downloads-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.downloads-toolbar .search-input {
margin-left: auto;
}
.search-input {
width: min(360px, 100%);
background: var(--field);
@@ -729,6 +739,11 @@ td {
align-items: flex-start;
}
.downloads-toolbar .search-input {
width: 100%;
margin-left: 0;
}
.settings-toolbar-actions {
width: 100%;
}
+2 -1
View File
@@ -19,7 +19,7 @@ export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light";
export interface BandwidthScheduleEntry {
id?: string;
id: string;
startHour: number;
endHour: number;
speedLimitKbps: number;
@@ -200,6 +200,7 @@ export interface UpdateCheckResult {
releaseUrl: string;
setupAssetUrl?: string;
setupAssetName?: string;
setupAssetDigest?: string;
error?: string;
}