diff --git a/package-lock.json b/package-lock.json index bc7247a..2400cf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.4.29", + "version": "1.4.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.4.29", + "version": "1.4.30", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index fc24180..2411462 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.29", + "version": "1.4.30", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/debrid.ts b/src/main/debrid.ts index edd096d..4448f00 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -5,7 +5,7 @@ import { RealDebridClient, UnrestrictedLink } from "./realdebrid"; import { compactErrorText, filenameFromUrl, looksLikeOpaqueFilename, sleep } from "./utils"; const API_TIMEOUT_MS = 30000; -const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29"; +const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.30"; const RAPIDGATOR_SCAN_MAX_BYTES = 512 * 1024; const BEST_DEBRID_API_BASE = "https://bestdebrid.com/api/v1"; diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 570bbba..56a3a68 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -343,6 +343,13 @@ function isNoExtractorError(errorText: string): boolean { return String(errorText || "").toLowerCase().includes("nicht gefunden"); } +function isUnsupportedArchiveFormatError(errorText: string): boolean { + const text = String(errorText || "").toLowerCase(); + return text.includes("kein rar-archiv") + || text.includes("not a rar archive") + || text.includes("is not a rar archive"); +} + function isUnsupportedExtractorSwitchError(errorText: string): boolean { const text = String(errorText || "").toLowerCase(); return text.includes("unknown switch") @@ -705,17 +712,26 @@ async function runExternalExtract( } function isZipSafetyGuardError(error: unknown): boolean { + const text = String(error || "").toLowerCase(); + return text.includes("path traversal") + || text.includes("zip-eintrag verdächtig groß") + || text.includes("zip-eintrag verdaechtig gross"); +} + +function isZipInternalLimitError(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"); + || text.includes("zip-eintrag ohne sichere groessenangabe"); } function shouldFallbackToExternalZip(error: unknown): boolean { if (isZipSafetyGuardError(error)) { return false; } + if (isZipInternalLimitError(error)) { + return true; + } const text = String(error || "").toLowerCase(); if (text.includes("aborted:extract") || text.includes("extract_aborted")) { return false; @@ -1190,11 +1206,18 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (!shouldFallbackToExternalZip(error)) { throw error; } - const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { - archivePercent = Math.max(archivePercent, value); - emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); - }, options.signal); - passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); + try { + const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { + archivePercent = Math.max(archivePercent, value); + emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); + }, options.signal); + passwordCandidates = prioritizePassword(passwordCandidates, usedPassword); + } catch (externalError) { + if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) { + throw error; + } + throw externalError; + } } } } else { diff --git a/src/main/main.ts b/src/main/main.ts index 08b845e..287c88b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -34,7 +34,8 @@ function validateStringArray(value: unknown, name: string): string[] { /* ── Single Instance Lock ───────────────────────────────────────── */ const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { - app.quit(); + app.exit(0); + process.exit(0); } /* ── Unhandled error protection ─────────────────────────────────── */ diff --git a/src/main/realdebrid.ts b/src/main/realdebrid.ts index 2cf75b5..d289da8 100644 --- a/src/main/realdebrid.ts +++ b/src/main/realdebrid.ts @@ -1,7 +1,7 @@ import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; import { compactErrorText, sleep } from "./utils"; -const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.29"; +const DEBRID_USER_AGENT = "RD-Node-Downloader/1.4.30"; export interface UnrestrictedLink { fileName: string; diff --git a/src/main/storage.ts b/src/main/storage.ts index f1d50d4..d3e9b3d 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -59,9 +59,6 @@ function normalizeBandwidthSchedules(raw: unknown): BandwidthScheduleEntry[] { function normalizeAbsoluteDir(value: unknown, fallback: string): string { const text = asText(value); - if (/^\/[\s\S]+/.test(text)) { - return text.replace(/\\/g, "/"); - } if (!text || !path.isAbsolute(text)) { return path.resolve(fallback); } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a8bf410..6334f35 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -116,11 +116,42 @@ export function sortPackageOrderByName(order: string[], packages: Record(emptySnapshot); const [tab, setTab] = useState("collector"); const [statusToast, setStatusToast] = useState(""); const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); + const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); + const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); const [settingsDirty, setSettingsDirty] = useState(false); const settingsDirtyRef = useRef(false); const settingsDraftRevisionRef = useRef(0); @@ -138,6 +169,9 @@ export function App(): ReactElement { const activeCollectorTabRef = useRef(activeCollectorTab); const activeTabRef = useRef(tab); const packageOrderRef = useRef([]); + const serverPackageOrderRef = useRef([]); + const pendingPackageOrderRef = useRef(null); + const pendingPackageOrderAtRef = useRef(0); const draggedPackageIdRef = useRef(null); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); @@ -153,6 +187,8 @@ export function App(): ReactElement { const startConflictResolverRef = useRef<((result: { policy: Extract; applyToAll: boolean } | null) => void) | null>(null); const [confirmPrompt, setConfirmPrompt] = useState(null); const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null); + const confirmQueueRef = useRef void }>>([]); + const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; @@ -169,9 +205,37 @@ export function App(): ReactElement { }, [tab]); useEffect(() => { - packageOrderRef.current = snapshot.session.packageOrder; + const incoming = snapshot.session.packageOrder; + serverPackageOrderRef.current = incoming; + + const pending = pendingPackageOrderRef.current; + if (!pending) { + packageOrderRef.current = incoming; + return; + } + + if (sameStringArray(pending, incoming)) { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = incoming; + return; + } + + const maxOptimisticHoldMs = 1500; + if (Date.now() - pendingPackageOrderAtRef.current >= maxOptimisticHoldMs) { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = incoming; + return; + } + + packageOrderRef.current = pending; }, [snapshot.session.packageOrder]); + useEffect(() => { + setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); + }, [settingsDraft.speedLimitKbps]); + const showToast = useCallback((message: string, timeoutMs = 2200): void => { setStatusToast(message); if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } @@ -181,6 +245,15 @@ export function App(): ReactElement { }, timeoutMs); }, []); + const clearImportQueueFocusListener = useCallback((): void => { + const handler = importQueueFocusHandlerRef.current; + if (!handler) { + return; + } + window.removeEventListener("focus", handler); + importQueueFocusHandlerRef.current = null; + }, []); + useEffect(() => { let unsubscribe: (() => void) | null = null; let unsubClipboard: (() => void) | null = null; @@ -243,6 +316,7 @@ export function App(): ReactElement { if (stateFlushTimerRef.current) { clearTimeout(stateFlushTimerRef.current); } if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } if (actionUnlockTimerRef.current) { clearTimeout(actionUnlockTimerRef.current); } + clearImportQueueFocusListener(); if (startConflictResolverRef.current) { const resolver = startConflictResolverRef.current; startConflictResolverRef.current = null; @@ -253,10 +327,14 @@ export function App(): ReactElement { confirmResolverRef.current = null; resolver(false); } + while (confirmQueueRef.current.length > 0) { + const request = confirmQueueRef.current.shift(); + request?.resolve(false); + } if (unsubscribe) { unsubscribe(); } if (unsubClipboard) { unsubClipboard(); } }; - }, []); + }, [clearImportQueueFocusListener]); const downloadsTabActive = tab === "downloads"; const deferredDownloadSearch = useDeferredValue(downloadSearch); @@ -449,6 +527,9 @@ export function App(): ReactElement { message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`, confirmLabel: "Jetzt installieren" }); + if (!mountedRef.current) { + return; + } if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; } const install = await window.rd.installUpdate(); if (!mountedRef.current) { @@ -507,21 +588,34 @@ export function App(): ReactElement { }); }; - const closeConfirmPrompt = (confirmed: boolean): void => { + const pumpConfirmQueue = useCallback((): void => { + if (confirmResolverRef.current) { + return; + } + const next = confirmQueueRef.current.shift(); + if (!next) { + return; + } + confirmResolverRef.current = next.resolve; + setConfirmPrompt(next.prompt); + }, []); + + const closeConfirmPrompt = useCallback((confirmed: boolean): void => { const resolver = confirmResolverRef.current; confirmResolverRef.current = null; setConfirmPrompt(null); if (resolver) { resolver(confirmed); } - }; + pumpConfirmQueue(); + }, [pumpConfirmQueue]); - const askConfirmPrompt = (prompt: ConfirmPromptState): Promise => { + const askConfirmPrompt = useCallback((prompt: ConfirmPromptState): Promise => { return new Promise((resolve) => { - confirmResolverRef.current = resolve; - setConfirmPrompt(prompt); + confirmQueueRef.current.push({ prompt, resolve }); + pumpConfirmQueue(); }); - }; + }, [pumpConfirmQueue]); const onStartDownloads = async (): Promise => { await performQuickAction(async () => { @@ -665,14 +759,14 @@ export function App(): ReactElement { }; const onWindowFocus = (): void => { - window.removeEventListener("focus", onWindowFocus); + clearImportQueueFocusListener(); if (!input.files || input.files.length === 0) { releasePickerBusy(); } }; input.onchange = async () => { - window.removeEventListener("focus", onWindowFocus); + clearImportQueueFocusListener(); const file = input.files?.[0]; if (!file) { releasePickerBusy(); @@ -688,6 +782,8 @@ export function App(): ReactElement { }); }; + clearImportQueueFocusListener(); + importQueueFocusHandlerRef.current = onWindowFocus; window.addEventListener("focus", onWindowFocus, { once: true }); input.click(); }; @@ -760,8 +856,13 @@ export function App(): ReactElement { if (target < 0 || target >= order.length) { return; } [order[idx], order[target]] = [order[target], order[idx]]; setDownloadsSortDescending(false); - packageOrderRef.current = order; + pendingPackageOrderRef.current = [...order]; + pendingPackageOrderAtRef.current = Date.now(); + packageOrderRef.current = [...order]; void window.rd.reorderPackages(order).catch((error) => { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = serverPackageOrderRef.current; showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); @@ -775,8 +876,13 @@ export function App(): ReactElement { return; } setDownloadsSortDescending(false); - packageOrderRef.current = nextOrder; + pendingPackageOrderRef.current = [...nextOrder]; + pendingPackageOrderAtRef.current = Date.now(); + packageOrderRef.current = [...nextOrder]; void window.rd.reorderPackages(nextOrder).catch((error) => { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = serverPackageOrderRef.current; showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); }); }, [showToast]); @@ -874,6 +980,33 @@ export function App(): ReactElement { }, [showToast]); const schedules = settingsDraft.bandwidthSchedules ?? []; + + useEffect(() => { + setScheduleSpeedInputs((prev) => { + const syncFromSettings = !settingsDirtyRef.current; + let changed = false; + const next: Record = {}; + for (let index = 0; index < schedules.length; index += 1) { + const schedule = schedules[index]; + const key = schedule.id || `schedule-${index}`; + const normalized = formatMbpsInputFromKbps(schedule.speedLimitKbps); + if (syncFromSettings || !Object.prototype.hasOwnProperty.call(prev, key)) { + next[key] = normalized; + if (prev[key] !== normalized) { + changed = true; + } + } else { + next[key] = prev[key]; + } + } + const prevKeys = Object.keys(prev); + if (prevKeys.length !== Object.keys(next).length) { + changed = true; + } + return changed ? next : prev; + }); + }, [schedules, settingsDirty]); + const addSchedule = (): void => { settingsDraftRevisionRef.current += 1; settingsDirtyRef.current = true; @@ -1066,7 +1199,10 @@ export function App(): ReactElement { 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); + void window.rd.reorderPackages(sorted).catch((error) => { + packageOrderRef.current = serverPackageOrderRef.current; + showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); + }); }} > {downloadsSortDescending ? "Z-A" : "A-Z"} @@ -1221,8 +1357,17 @@ export function App(): ReactElement { type="number" min={0} step={0.1} - value={Number((settingsDraft.speedLimitKbps / 1024).toFixed(2))} - onChange={(e) => setSpeedLimitMbps(Number(e.target.value) || 0)} + value={speedLimitInput} + onChange={(event) => setSpeedLimitInput(event.target.value)} + onBlur={(event) => { + const parsed = parseMbpsInput(event.target.value); + if (parsed === null) { + setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); + return; + } + setSpeedLimitMbps(parsed); + setSpeedLimitInput(formatMbpsInputFromKbps(Math.floor(parsed * 1024))); + }} disabled={!settingsDraft.speedLimitEnabled} /> @@ -1243,25 +1388,42 @@ export function App(): ReactElement {

Bandbreitenplanung

- {schedules.map((s, i) => ( -
- updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" /> - - - updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" /> - Uhr - updateSchedule(i, "speedLimitKbps", Math.floor((Number(e.target.value) || 0) * 1024))} - title="MB/s (0=unbegrenzt)" - /> - MB/s - updateSchedule(i, "enabled", e.target.checked)} /> - -
- ))} + {schedules.map((s, i) => { + const scheduleKey = s.id || `schedule-${i}`; + const speedInput = scheduleSpeedInputs[scheduleKey] ?? formatMbpsInputFromKbps(s.speedLimitKbps); + return ( +
+ updateSchedule(i, "startHour", Number(e.target.value))} title="Von (Stunde)" /> + - + updateSchedule(i, "endHour", Number(e.target.value))} title="Bis (Stunde)" /> + Uhr + { + const nextText = event.target.value; + setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: nextText })); + }} + onBlur={(event) => { + const parsed = parseMbpsInput(event.target.value); + if (parsed === null) { + setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: formatMbpsInputFromKbps(s.speedLimitKbps) })); + return; + } + const nextKbps = Math.floor(parsed * 1024); + setScheduleSpeedInputs((prev) => ({ ...prev, [scheduleKey]: formatMbpsInputFromKbps(nextKbps) })); + updateSchedule(i, "speedLimitKbps", nextKbps); + }} + title="MB/s (0=unbegrenzt)" + /> + MB/s + updateSchedule(i, "enabled", e.target.checked)} /> + +
+ ); + })} diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index e21761a..662d6fd 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -555,7 +555,7 @@ describe("extractor", () => { expect(targets.has(r02)).toBe(true); }); - it("does not fallback to external extractor when ZIP safety guard triggers", async () => { + it("keeps original ZIP size guard error when external fallback is unavailable", async () => { const previousLimit = process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB; process.env.RD_ZIP_ENTRY_MEMORY_LIMIT_MB = "8"; diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 1bd9c52..1063415 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -409,7 +409,7 @@ describe("settings storage", () => { // Old fields should be preserved expect(loaded.token).toBe("my-token"); - expect(loaded.outputDir).toBe("/custom/output"); + expect(loaded.outputDir).toBe(path.resolve("/custom/output")); // Missing new fields should get default values expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);