Release v1.4.26 with remaining bug audit fixes
- AllDebrid: add HTML response detection to unrestrictLink - Cleanup: skip symlinks/junctions in all directory traversals - Blob URL: increase revoke delay from 0ms to 60s - Extractor: per-package progress file to prevent collision - ADD_CONTAINERS: reject path traversal and relative paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
06a272ccbd
commit
cbc423e4b7
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.25",
|
"version": "1.4.26",
|
||||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
|||||||
const current = stack.pop() as string;
|
const current = stack.pop() as string;
|
||||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
const full = path.join(current, entry.name);
|
const full = path.join(current, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||||
stack.push(full);
|
stack.push(full);
|
||||||
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||||
try {
|
try {
|
||||||
@ -66,7 +66,7 @@ export async function cleanupCancelledPackageArtifactsAsync(packageDir: string):
|
|||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = path.join(current, entry.name);
|
const full = path.join(current, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||||
stack.push(full);
|
stack.push(full);
|
||||||
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
} else if (entry.isFile() && isArchiveOrTempFile(full)) {
|
||||||
try {
|
try {
|
||||||
@ -96,7 +96,7 @@ export function removeDownloadLinkArtifacts(extractDir: string): number {
|
|||||||
const current = stack.pop() as string;
|
const current = stack.pop() as string;
|
||||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
const full = path.join(current, entry.name);
|
const full = path.join(current, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||||
stack.push(full);
|
stack.push(full);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -158,6 +158,14 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const full = path.join(current, entry.name);
|
const full = path.join(current, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
try {
|
||||||
|
const stat = fs.lstatSync(full);
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
dirs.push(full);
|
dirs.push(full);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
count += 1;
|
count += 1;
|
||||||
@ -171,13 +179,15 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
|
|||||||
const current = stack.pop() as string;
|
const current = stack.pop() as string;
|
||||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
const full = path.join(current, entry.name);
|
const full = path.join(current, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||||
const base = entry.name.toLowerCase();
|
const base = entry.name.toLowerCase();
|
||||||
if (SAMPLE_DIR_NAMES.has(base)) {
|
if (SAMPLE_DIR_NAMES.has(base)) {
|
||||||
sampleDirs.push(full);
|
sampleDirs.push(full);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
stack.push(full);
|
stack.push(full);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!entry.isFile()) {
|
if (!entry.isFile()) {
|
||||||
@ -202,6 +212,12 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs
|
|||||||
sampleDirs.sort((a, b) => b.length - a.length);
|
sampleDirs.sort((a, b) => b.length - a.length);
|
||||||
for (const dir of sampleDirs) {
|
for (const dir of sampleDirs) {
|
||||||
try {
|
try {
|
||||||
|
const stat = fs.lstatSync(dir);
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
fs.rmSync(dir, { force: true });
|
||||||
|
removedDirs += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const filesInDir = countFilesRecursive(dir);
|
const filesInDir = countFilesRecursive(dir);
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
removedFiles += filesInDir;
|
removedFiles += filesInDir;
|
||||||
|
|||||||
@ -444,7 +444,13 @@ class AllDebridClient {
|
|||||||
body.append("link[]", link);
|
body.append("link[]", link);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${ALL_DEBRID_API_BASE}/link/infos`, {
|
let payload: Record<string, unknown> | null = null;
|
||||||
|
let chunkResolved = false;
|
||||||
|
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||||
|
let response: Response;
|
||||||
|
let text = "";
|
||||||
|
try {
|
||||||
|
response = await fetch(`${ALL_DEBRID_API_BASE}/link/infos`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.token}`,
|
Authorization: `Bearer ${this.token}`,
|
||||||
@ -455,10 +461,24 @@ class AllDebridClient {
|
|||||||
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
signal: AbortSignal.timeout(API_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
text = await response.text();
|
||||||
const payload = asRecord(parseJson(text));
|
payload = asRecord(parseJson(text));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(parseError(response.status, text, payload));
|
const reason = parseError(response.status, text, payload);
|
||||||
|
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||||
|
await sleep(retryDelay(attempt));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text);
|
||||||
|
if (looksHtml) {
|
||||||
|
throw new Error("AllDebrid lieferte HTML statt JSON");
|
||||||
|
}
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error("AllDebrid Antwort ist kein JSON-Objekt");
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = pickString(payload, ["status"]);
|
const status = pickString(payload, ["status"]);
|
||||||
@ -467,6 +487,20 @@ class AllDebridClient {
|
|||||||
throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error");
|
throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chunkResolved = true;
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt >= REQUEST_RETRIES) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await sleep(retryDelay(attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chunkResolved || !payload) {
|
||||||
|
throw new Error("AllDebrid Link-Infos konnten nicht geladen werden");
|
||||||
|
}
|
||||||
|
|
||||||
const data = asRecord(payload?.data);
|
const data = asRecord(payload?.data);
|
||||||
const infos = Array.isArray(data?.infos) ? data.infos : [];
|
const infos = Array.isArray(data?.infos) ? data.infos : [];
|
||||||
for (let i = 0; i < infos.length; i += 1) {
|
for (let i = 0; i < infos.length; i += 1) {
|
||||||
@ -519,6 +553,12 @@ class AllDebridClient {
|
|||||||
throw new Error(reason);
|
throw new Error(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text);
|
||||||
|
if (looksHtml) {
|
||||||
|
throw new Error("AllDebrid lieferte HTML statt JSON");
|
||||||
|
}
|
||||||
|
|
||||||
const status = pickString(payload, ["status"]);
|
const status = pickString(payload, ["status"]);
|
||||||
if (status && status.toLowerCase() === "error") {
|
if (status && status.toLowerCase() === "error") {
|
||||||
const errorObj = asRecord(payload?.error);
|
const errorObj = asRecord(payload?.error);
|
||||||
|
|||||||
@ -3175,6 +3175,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
signal,
|
signal,
|
||||||
onlyArchives: readyArchives,
|
onlyArchives: readyArchives,
|
||||||
skipPostCleanup: true,
|
skipPostCleanup: true,
|
||||||
|
packageId,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
if (progress.phase === "done") {
|
if (progress.phase === "done") {
|
||||||
return;
|
return;
|
||||||
@ -3312,6 +3313,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
removeSamples: this.settings.removeSamplesAfterExtract,
|
removeSamples: this.settings.removeSamplesAfterExtract,
|
||||||
passwordList: this.settings.archivePasswordList,
|
passwordList: this.settings.archivePasswordList,
|
||||||
signal: extractAbortController.signal,
|
signal: extractAbortController.signal,
|
||||||
|
packageId,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
const label = progress.phase === "done"
|
const label = progress.phase === "done"
|
||||||
? "Entpacken 100%"
|
? "Entpacken 100%"
|
||||||
|
|||||||
@ -14,6 +14,7 @@ let resolvedExtractorCommand: string | null = null;
|
|||||||
let resolveFailureReason = "";
|
let resolveFailureReason = "";
|
||||||
let resolveFailureAt = 0;
|
let resolveFailureAt = 0;
|
||||||
let externalExtractorSupportsPerfFlags = true;
|
let externalExtractorSupportsPerfFlags = true;
|
||||||
|
let resolveExtractorCommandInFlight: Promise<string> | null = null;
|
||||||
|
|
||||||
const EXTRACTOR_RETRY_AFTER_MS = 30_000;
|
const EXTRACTOR_RETRY_AFTER_MS = 30_000;
|
||||||
const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256;
|
const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256;
|
||||||
@ -30,6 +31,7 @@ export interface ExtractOptions {
|
|||||||
onProgress?: (update: ExtractProgressUpdate) => void;
|
onProgress?: (update: ExtractProgressUpdate) => void;
|
||||||
onlyArchives?: Set<string>;
|
onlyArchives?: Set<string>;
|
||||||
skipPostCleanup?: boolean;
|
skipPostCleanup?: boolean;
|
||||||
|
packageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtractProgressUpdate {
|
export interface ExtractProgressUpdate {
|
||||||
@ -227,12 +229,15 @@ function computeExtractTimeoutMs(archivePath: string): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractProgressFilePath(packageDir: string): string {
|
function extractProgressFilePath(packageDir: string, packageId?: string): string {
|
||||||
|
if (packageId) {
|
||||||
|
return path.join(packageDir, `.rd_extract_progress_${packageId}.json`);
|
||||||
|
}
|
||||||
return path.join(packageDir, EXTRACT_PROGRESS_FILE);
|
return path.join(packageDir, EXTRACT_PROGRESS_FILE);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readExtractResumeState(packageDir: string): Set<string> {
|
function readExtractResumeState(packageDir: string, packageId?: string): Set<string> {
|
||||||
const progressPath = extractProgressFilePath(packageDir);
|
const progressPath = extractProgressFilePath(packageDir, packageId);
|
||||||
if (!fs.existsSync(progressPath)) {
|
if (!fs.existsSync(progressPath)) {
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
@ -245,10 +250,10 @@ function readExtractResumeState(packageDir: string): Set<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeExtractResumeState(packageDir: string, completedArchives: Set<string>): void {
|
function writeExtractResumeState(packageDir: string, completedArchives: Set<string>, packageId?: string): void {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
const progressPath = extractProgressFilePath(packageDir);
|
const progressPath = extractProgressFilePath(packageDir, packageId);
|
||||||
const payload: ExtractResumeState = {
|
const payload: ExtractResumeState = {
|
||||||
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
|
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
|
||||||
};
|
};
|
||||||
@ -258,9 +263,9 @@ function writeExtractResumeState(packageDir: string, completedArchives: Set<stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearExtractResumeState(packageDir: string): void {
|
function clearExtractResumeState(packageDir: string, packageId?: string): void {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(extractProgressFilePath(packageDir), { force: true });
|
fs.rmSync(extractProgressFilePath(packageDir, packageId), { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -497,7 +502,7 @@ export function buildExternalExtractArgs(
|
|||||||
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
|
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveExtractorCommand(): Promise<string> {
|
async function resolveExtractorCommandInternal(): Promise<string> {
|
||||||
if (resolvedExtractorCommand) {
|
if (resolvedExtractorCommand) {
|
||||||
return resolvedExtractorCommand;
|
return resolvedExtractorCommand;
|
||||||
}
|
}
|
||||||
@ -531,6 +536,25 @@ async function resolveExtractorCommand(): Promise<string> {
|
|||||||
throw new Error(resolveFailureReason);
|
throw new Error(resolveFailureReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveExtractorCommand(): Promise<string> {
|
||||||
|
if (resolvedExtractorCommand) {
|
||||||
|
return resolvedExtractorCommand;
|
||||||
|
}
|
||||||
|
if (resolveExtractorCommandInFlight) {
|
||||||
|
return resolveExtractorCommandInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = resolveExtractorCommandInternal();
|
||||||
|
resolveExtractorCommandInFlight = pending;
|
||||||
|
try {
|
||||||
|
return await pending;
|
||||||
|
} finally {
|
||||||
|
if (resolveExtractorCommandInFlight === pending) {
|
||||||
|
resolveExtractorCommandInFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runExternalExtract(
|
async function runExternalExtract(
|
||||||
archivePath: string,
|
archivePath: string,
|
||||||
targetDir: string,
|
targetDir: string,
|
||||||
@ -627,12 +651,38 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uncompressedSize = Number((entry as unknown as { header?: { size?: number } }).header?.size ?? NaN);
|
const header = (entry as unknown as {
|
||||||
|
header?: {
|
||||||
|
size?: number;
|
||||||
|
compressedSize?: number;
|
||||||
|
crc?: number;
|
||||||
|
dataHeader?: {
|
||||||
|
size?: number;
|
||||||
|
compressedSize?: number;
|
||||||
|
crc?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}).header;
|
||||||
|
const uncompressedSize = Number(header?.size ?? header?.dataHeader?.size ?? NaN);
|
||||||
|
const compressedSize = Number(header?.compressedSize ?? header?.dataHeader?.compressedSize ?? NaN);
|
||||||
|
const crc = Number(header?.crc ?? header?.dataHeader?.crc ?? 0);
|
||||||
|
|
||||||
if (Number.isFinite(uncompressedSize) && uncompressedSize > memoryLimitBytes) {
|
if (Number.isFinite(uncompressedSize) && uncompressedSize > memoryLimitBytes) {
|
||||||
const entryMb = Math.ceil(uncompressedSize / (1024 * 1024));
|
const entryMb = Math.ceil(uncompressedSize / (1024 * 1024));
|
||||||
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
|
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
|
||||||
throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
|
throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
|
||||||
}
|
}
|
||||||
|
if (Number.isFinite(compressedSize) && compressedSize > memoryLimitBytes) {
|
||||||
|
const entryMb = Math.ceil(compressedSize / (1024 * 1024));
|
||||||
|
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
|
||||||
|
throw new Error(`ZIP-Eintrag komprimiert zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
|
||||||
|
}
|
||||||
|
if ((!Number.isFinite(uncompressedSize) || uncompressedSize <= 0)
|
||||||
|
&& Number.isFinite(compressedSize)
|
||||||
|
&& compressedSize > 0
|
||||||
|
&& crc !== 0) {
|
||||||
|
throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker");
|
||||||
|
}
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||||
// TOCTOU note: There is a small race between existsSync and writeFileSync below.
|
// TOCTOU note: There is a small race between existsSync and writeFileSync below.
|
||||||
@ -872,9 +922,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
if (!options.onlyArchives) {
|
if (!options.onlyArchives) {
|
||||||
const existingResume = readExtractResumeState(options.packageDir);
|
const existingResume = readExtractResumeState(options.packageDir, options.packageId);
|
||||||
if (existingResume.size > 0 && hasAnyEntries(options.targetDir)) {
|
if (existingResume.size > 0 && hasAnyEntries(options.targetDir)) {
|
||||||
clearExtractResumeState(options.packageDir);
|
clearExtractResumeState(options.packageDir, options.packageId);
|
||||||
logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`);
|
logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`);
|
||||||
options.onProgress?.({
|
options.onProgress?.({
|
||||||
current: existingResume.size,
|
current: existingResume.size,
|
||||||
@ -885,7 +935,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
});
|
});
|
||||||
return { extracted: existingResume.size, failed: 0, lastError: "" };
|
return { extracted: existingResume.size, failed: 0, lastError: "" };
|
||||||
}
|
}
|
||||||
clearExtractResumeState(options.packageDir);
|
clearExtractResumeState(options.packageDir, options.packageId);
|
||||||
}
|
}
|
||||||
logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`);
|
logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`);
|
||||||
return { extracted: 0, failed: 0, lastError: "" };
|
return { extracted: 0, failed: 0, lastError: "" };
|
||||||
@ -893,7 +943,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
|
|
||||||
const conflictMode = effectiveConflictMode(options.conflictMode);
|
const conflictMode = effectiveConflictMode(options.conflictMode);
|
||||||
let passwordCandidates = archivePasswords(options.passwordList || "");
|
let passwordCandidates = archivePasswords(options.passwordList || "");
|
||||||
const resumeCompleted = readExtractResumeState(options.packageDir);
|
const resumeCompleted = readExtractResumeState(options.packageDir, options.packageId);
|
||||||
const resumeCompletedAtStart = resumeCompleted.size;
|
const resumeCompletedAtStart = resumeCompleted.size;
|
||||||
const allCandidateNames = new Set(allCandidates.map((archivePath) => path.basename(archivePath)));
|
const allCandidateNames = new Set(allCandidates.map((archivePath) => path.basename(archivePath)));
|
||||||
for (const archiveName of Array.from(resumeCompleted.values())) {
|
for (const archiveName of Array.from(resumeCompleted.values())) {
|
||||||
@ -902,9 +952,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resumeCompleted.size > 0) {
|
if (resumeCompleted.size > 0) {
|
||||||
writeExtractResumeState(options.packageDir, resumeCompleted);
|
writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||||||
} else {
|
} else {
|
||||||
clearExtractResumeState(options.packageDir);
|
clearExtractResumeState(options.packageDir, options.packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(path.basename(archivePath)));
|
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(path.basename(archivePath)));
|
||||||
@ -1000,7 +1050,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
extracted += 1;
|
extracted += 1;
|
||||||
extractedArchives.add(archivePath);
|
extractedArchives.add(archivePath);
|
||||||
resumeCompleted.add(archiveName);
|
resumeCompleted.add(archiveName);
|
||||||
writeExtractResumeState(options.packageDir, resumeCompleted);
|
writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||||||
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||||||
archivePercent = 100;
|
archivePercent = 100;
|
||||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
@ -1052,7 +1102,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (failed === 0 && resumeCompleted.size >= allCandidates.length) {
|
if (failed === 0 && resumeCompleted.size >= allCandidates.length) {
|
||||||
clearExtractResumeState(options.packageDir);
|
clearExtractResumeState(options.packageDir, options.packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.skipPostCleanup && options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) {
|
if (!options.skipPostCleanup && options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) {
|
||||||
@ -1074,9 +1124,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
|
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
if (resumeCompleted.size > 0) {
|
if (resumeCompleted.size > 0) {
|
||||||
writeExtractResumeState(options.packageDir, resumeCompleted);
|
writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
||||||
} else {
|
} else {
|
||||||
clearExtractResumeState(options.packageDir);
|
clearExtractResumeState(options.packageDir, options.packageId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -197,7 +197,11 @@ function registerIpcHandlers(): void {
|
|||||||
validateString(payload?.rawText, "rawText");
|
validateString(payload?.rawText, "rawText");
|
||||||
return controller.addLinks(payload);
|
return controller.addLinks(payload);
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? []));
|
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(".."));
|
||||||
|
return controller.addContainers(safePaths);
|
||||||
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
|
ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
|
||||||
ipcMain.handle(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => {
|
ipcMain.handle(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => {
|
||||||
validateString(packageId, "packageId");
|
validateString(packageId, "packageId");
|
||||||
|
|||||||
@ -98,6 +98,17 @@ export function reorderPackageOrderByDrop(order: string[], draggedPackageId: str
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sortPackageOrderByName(order: string[], packages: Record<string, PackageEntry>, descending: boolean): string[] {
|
||||||
|
const sorted = [...order];
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const nameA = (packages[a]?.name ?? "").toLowerCase();
|
||||||
|
const nameB = (packages[b]?.name ?? "").toLowerCase();
|
||||||
|
const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" });
|
||||||
|
return descending ? -cmp : cmp;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
export function App(): ReactElement {
|
export function App(): ReactElement {
|
||||||
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
const [snapshot, setSnapshot] = useState<UiSnapshot>(emptySnapshot);
|
||||||
const [tab, setTab] = useState<Tab>("collector");
|
const [tab, setTab] = useState<Tab>("collector");
|
||||||
@ -120,6 +131,7 @@ export function App(): ReactElement {
|
|||||||
const draggedPackageIdRef = useRef<string | null>(null);
|
const draggedPackageIdRef = useRef<string | null>(null);
|
||||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||||
const [downloadSearch, setDownloadSearch] = useState("");
|
const [downloadSearch, setDownloadSearch] = useState("");
|
||||||
|
const [downloadsSortDescending, setDownloadsSortDescending] = useState(false);
|
||||||
const [showAllPackages, setShowAllPackages] = useState(false);
|
const [showAllPackages, setShowAllPackages] = useState(false);
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
const actionBusyRef = useRef(false);
|
const actionBusyRef = useRef(false);
|
||||||
@ -127,7 +139,6 @@ export function App(): ReactElement {
|
|||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
||||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||||
const startConflictGlobalPolicyRef = useRef<Extract<DuplicatePolicy, "skip" | "overwrite"> | null>(null);
|
|
||||||
|
|
||||||
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
||||||
|
|
||||||
@ -456,7 +467,7 @@ export function App(): ReactElement {
|
|||||||
const conflicts = await window.rd.getStartConflicts();
|
const conflicts = await window.rd.getStartConflicts();
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
let overwritten = 0;
|
let overwritten = 0;
|
||||||
let rememberedPolicy = startConflictGlobalPolicyRef.current;
|
let rememberedPolicy: Extract<DuplicatePolicy, "skip" | "overwrite"> | null = null;
|
||||||
|
|
||||||
for (const conflict of conflicts) {
|
for (const conflict of conflicts) {
|
||||||
let decisionPolicy = rememberedPolicy;
|
let decisionPolicy = rememberedPolicy;
|
||||||
@ -468,7 +479,6 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
decisionPolicy = decision.policy;
|
decisionPolicy = decision.policy;
|
||||||
if (decision.applyToAll) {
|
if (decision.applyToAll) {
|
||||||
startConflictGlobalPolicyRef.current = decision.policy;
|
|
||||||
rememberedPolicy = decision.policy;
|
rememberedPolicy = decision.policy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -558,7 +568,7 @@ export function App(): ReactElement {
|
|||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = "rd-queue-export.json";
|
a.download = "rd-queue-export.json";
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
showToast("Queue exportiert");
|
showToast("Queue exportiert");
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
showToast(`Export fehlgeschlagen: ${String(error)}`, 2600);
|
showToast(`Export fehlgeschlagen: ${String(error)}`, 2600);
|
||||||
@ -855,6 +865,7 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="downloads-toolbar">
|
<div className="downloads-toolbar">
|
||||||
|
<div className="downloads-toolbar-actions">
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
disabled={packages.length === 0}
|
disabled={packages.length === 0}
|
||||||
@ -872,33 +883,18 @@ export function App(): ReactElement {
|
|||||||
{allPackagesCollapsed ? "Alles ausklappen" : "Alles einklappen"}
|
{allPackagesCollapsed ? "Alles ausklappen" : "Alles einklappen"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className={`btn${downloadsSortDescending ? " btn-active" : ""}`}
|
||||||
disabled={packages.length < 2}
|
disabled={packages.length < 2}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const sorted = [...snapshot.session.packageOrder].sort((a, b) => {
|
const nextDescending = !downloadsSortDescending;
|
||||||
const nameA = (snapshot.session.packages[a]?.name ?? "").toLowerCase();
|
setDownloadsSortDescending(nextDescending);
|
||||||
const nameB = (snapshot.session.packages[b]?.name ?? "").toLowerCase();
|
const sorted = sortPackageOrderByName(snapshot.session.packageOrder, snapshot.session.packages, nextDescending);
|
||||||
return nameA.localeCompare(nameB);
|
|
||||||
});
|
|
||||||
void window.rd.reorderPackages(sorted);
|
void window.rd.reorderPackages(sorted);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
A-Z
|
{downloadsSortDescending ? "Z-A" : "A-Z"}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
disabled={packages.length < 2}
|
|
||||||
onClick={() => {
|
|
||||||
const sorted = [...snapshot.session.packageOrder].sort((a, b) => {
|
|
||||||
const nameA = (snapshot.session.packages[a]?.name ?? "").toLowerCase();
|
|
||||||
const nameB = (snapshot.session.packages[b]?.name ?? "").toLowerCase();
|
|
||||||
return nameB.localeCompare(nameA);
|
|
||||||
});
|
|
||||||
void window.rd.reorderPackages(sorted);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Z-A
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
className="search-input"
|
className="search-input"
|
||||||
type="search"
|
type="search"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user