From e013c63c590ca1199b9363eb886474a999bd05e9 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 2 Mar 2026 20:32:23 +0100 Subject: [PATCH] Fix long path extraction using subst drive mapping instead of \?\ prefix WinRAR doesn't support \?\ prefix (interprets it as UNC network path). Replace with subst drive mapping: maps targetDir to a short drive letter (Z:, Y:, etc.) before extraction, then removes mapping after. This keeps total paths under 260 chars even when archives contain deep internal directory structures. Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/main/extractor.ts | 88 ++++++++++++++++++++++++++++++++--------- tests/extractor.test.ts | 8 ++-- 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index cdfec94..d22b5df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.5.16", + "version": "1.5.17", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/extractor.ts b/src/main/extractor.ts index dc12963..8a38705 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -1,30 +1,56 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import AdmZip from "adm-zip"; import { CleanupMode, ConflictMode } from "../shared/types"; import { logger } from "./logger"; import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; const DEFAULT_ARCHIVE_PASSWORDS = ["", "serienfans.org", "serienjunkies.org"]; - -/** - * On Windows, prefix an absolute path with \\?\ to bypass the 260-char MAX_PATH limit. - * WinRAR 5.x+ and 7-Zip support this prefix for long output paths. - */ -function longPathForWindows(p: string): string { - if (process.platform !== "win32") return p; - const resolved = path.resolve(p); - if (resolved.startsWith("\\\\?\\")) return resolved; - if (resolved.startsWith("\\\\")) { - // UNC path \\server\share -> \\?\UNC\server\share - return "\\\\?\\UNC\\" + resolved.slice(2); - } - return "\\\\?\\" + resolved; -} const NO_EXTRACTOR_MESSAGE = "WinRAR/UnRAR nicht gefunden. Bitte WinRAR installieren."; +// ── subst drive mapping for long paths on Windows ── +const SUBST_THRESHOLD = 100; +const activeSubstDrives = new Set(); + +function findFreeSubstDrive(): string | null { + if (process.platform !== "win32") return null; + for (let code = 90; code >= 71; code--) { // Z to G + const letter = String.fromCharCode(code); + if (activeSubstDrives.has(letter)) continue; + try { + fs.accessSync(`${letter}:\\`); + // Drive exists, skip + } catch { + return letter; + } + } + return null; +} + +interface SubstMapping { drive: string; original: string; } + +function createSubstMapping(targetDir: string): SubstMapping | null { + if (process.platform !== "win32" || targetDir.length < SUBST_THRESHOLD) return null; + const drive = findFreeSubstDrive(); + if (!drive) return null; + const result = spawnSync("subst", [`${drive}:`, targetDir], { stdio: "pipe", timeout: 5000 }); + if (result.status !== 0) { + logger.warn(`subst ${drive}: fehlgeschlagen: ${String(result.stderr || "").trim()}`); + return null; + } + activeSubstDrives.add(drive); + logger.info(`subst ${drive}: -> ${targetDir}`); + return { drive, original: targetDir }; +} + +function removeSubstMapping(mapping: SubstMapping): void { + spawnSync("subst", [`${mapping.drive}:`, "/d"], { stdio: "pipe", timeout: 5000 }); + activeSubstDrives.delete(mapping.drive); + logger.info(`subst ${mapping.drive}: entfernt`); +} + let resolvedExtractorCommand: string | null = null; let resolveFailureReason = ""; let resolveFailureAt = 0; @@ -621,14 +647,13 @@ export function buildExternalExtractArgs( const perfArgs = usePerformanceFlags && shouldUseExtractorPerformanceFlags() ? ["-idc", extractorThreadSwitch()] : []; - const longTarget = longPathForWindows(targetDir); - return ["x", overwrite, pass, "-y", ...perfArgs, archivePath, `${longTarget}${path.sep}`]; + return ["x", overwrite, pass, "-y", ...perfArgs, archivePath, `${targetDir}${path.sep}`]; } 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${longPathForWindows(targetDir)}`]; + return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`]; } async function resolveExtractorCommandInternal(): Promise { @@ -699,6 +724,31 @@ async function runExternalExtract( await fs.promises.mkdir(targetDir, { recursive: true }); + // On Windows, long targetDir + archive internal paths can exceed MAX_PATH (260 chars). + // Use "subst" to map the targetDir to a short drive letter for the extraction process. + const subst = createSubstMapping(targetDir); + const effectiveTargetDir = subst ? `${subst.drive}:` : targetDir; + + try { + return await runExternalExtractInner(command, archivePath, effectiveTargetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs); + } finally { + if (subst) removeSubstMapping(subst); + } +} + +async function runExternalExtractInner( + command: string, + archivePath: string, + targetDir: string, + conflictMode: ConflictMode, + passwordCandidates: string[], + onArchiveProgress: ((percent: number) => void) | undefined, + signal: AbortSignal | undefined, + timeoutMs: number +): Promise { + const passwords = passwordCandidates; + let lastError = ""; + let announcedStart = false; let bestPercent = 0; let usePerformanceFlags = externalExtractorSupportsPerfFlags && shouldUseExtractorPerformanceFlags(); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 498c23f..662d6fd 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -20,15 +20,14 @@ describe("extractor", () => { expect(overwriteArgs).toContain("-idc"); expect(overwriteArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(true); expect(overwriteArgs[overwriteArgs.length - 2]).toBe("archive.rar"); - const winrarTarget = process.platform === "win32" ? "\\\\?\\C:\\target\\" : "C:\\target\\"; - expect(overwriteArgs[overwriteArgs.length - 1]).toBe(winrarTarget); + expect(overwriteArgs[overwriteArgs.length - 1]).toBe("C:\\target\\"); const askArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "ask", "serienfans.org"); expect(askArgs.slice(0, 4)).toEqual(["x", "-o-", "-pserienfans.org", "-y"]); expect(askArgs).toContain("-idc"); expect(askArgs.some((value) => /^-mt\d+$/i.test(value))).toBe(true); expect(askArgs[askArgs.length - 2]).toBe("archive.rar"); - expect(askArgs[askArgs.length - 1]).toBe(winrarTarget); + expect(askArgs[askArgs.length - 1]).toBe("C:\\target\\"); const compatibilityArgs = buildExternalExtractArgs("WinRAR.exe", "archive.rar", "C:\\target", "overwrite", "", false); expect(compatibilityArgs).not.toContain("-idc"); @@ -456,8 +455,7 @@ describe("extractor", () => { expect(args7z).toContain("-aoa"); expect(args7z).toContain("-p"); expect(args7z).toContain("archive.7z"); - const sevenZTarget = process.platform === "win32" ? "-o\\\\?\\C:\\target" : "-oC:\\target"; - expect(args7z).toContain(sevenZTarget); + expect(args7z).toContain("-oC:\\target"); }); it("builds 7z args with skip conflict mode", () => {