diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index 5011015..a0b9a8b 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -67,6 +67,7 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält - Provider-Reihenfolge, Hoster-Overrides, Kontostatus, Tageslimits, Cooldowns und optionale Fallbacks bestimmen das Routing. - Real-Debrid und Debrid-Link unterstützen Kontopools; Auswahl und Rotation berücksichtigen Aktivität, Fairness, Limits und Fehlerklassen. - Item-Pipeline: Vorprüfung und Recovery, Provider-Auswahl, Unrestrict mit Timeout, sichere Zielpfadreservierung, Speicherplatzreservierung, HTTP-Download mit Range-Resume, optionale Integritätsprüfung, Abschluss und Paket-Postprocessing. +- Optionaler Proxy-Segmentdownload teilt neue Dateien ab 8 MiB in 2 bis 32 exakte HTTP-Range-Bereiche und lädt sie parallel über verschiedene authentifizierte HTTP-CONNECT-Proxys. Bestehende Teil-Dateien und aktive Geschwindigkeitslimits bleiben beim bisherigen Direktdownload; bei fehlender Range-Unterstützung, ungültiger Liste oder Proxy-/Segmentfehlern wird automatisch auf diesen Pfad zurückgefallen. - Download-Retries unterscheiden Netzwerk-, Range-, Hoster-, Provider-, Konto-, Quota-, Disk- und permanente Linkfehler. Stop, Pause, Shutdown und Neustart besitzen getrennte Park- und Abbruchpfade. - Die Queue priorisiert hoch vor normal vor niedrig, beachtet globale und providerbezogene Parallelitätsgrenzen und schützt sich mit Scheduler-Generation, Heartbeat und Stall-Watchdogs gegen veraltete Tasks. @@ -98,6 +99,11 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält ## Aktuelle unveröffentlichte Änderung +- Unter „Geschwindigkeit“ kann der Proxy-Segmentdownload aktiviert, eine externe Proxy-Textdatei ausgewählt und die Zahl der Verbindungen pro Download zwischen 2 und 32 festgelegt werden. Bei fünf parallelen Downloads und 16 Segmenten sind damit bis zu 80 Proxy-Verbindungen möglich; `maxParallel` bleibt die vorhandene globale Downloadgrenze. +- Unterstützt werden insbesondere `Benutzer:Passwort@Host:Port`, Proxy-URLs, `Host:Port` und `Host:Port:Benutzer:Passwort`. Die Liste wird nur im Main-Prozess gelesen und anhand von Dateigröße und Änderungszeit gecacht. Proxy-Zugangsdaten werden nicht protokolliert, nicht an den Renderer übertragen und nicht in Git übernommen; lediglich der lokale Listenpfad ist eine normale Einstellung. +- Jeder Proxy-Download prüft zuerst `Range: bytes=0-1`, übernimmt die autoritative HTTP-Gesamtgröße, reserviert eine zielnahe temporäre Datei und schreibt jedes Segment positionsgenau. Die Zwei-Byte-Probe umgeht ein reproduziertes Real-Debrid-Verhalten, bei dem nur `bytes=0-0` trotz 206-Header die gesamte Datei streamt. Segmente versuchen bei Verbindungs-, Authentifizierungs-, Timeout- oder Range-Fehlern andere Proxys. Erst nach vollständiger Größenprüfung und `datasync` wird die Datei atomar zum Ziel umbenannt. +- Abbruch und Segmentfehler beenden alle zugehörigen Tunnel, entfernen die temporäre Datei und rechnen nur eindeutigen Segmentfortschritt zurück. Tatsächlich empfangener Providerverkehr bleibt für Statistik und Geschwindigkeitsmessung erhalten. Ein fehlgeschlagener Proxy-Versuch startet anschließend den unveränderten Direktdownload neu. +- Der bestehende Range-Resume-Pfad für Teil-Dateien wurde nicht ersetzt. Ein aktives Geschwindigkeitslimit überspringt die Proxy-Segmentierung, damit das konfigurierte Limit nicht umgangen wird. - Ein Start-/Beenden-Fehler nach Real-Debrid-Web-Downloads wurde behoben. Der persistente unsichtbare Web-Generator blieb nach dem Schließen des Hauptfensters geöffnet, verhinderte dadurch `window-all-closed` und hielt den Single-Instance-Lock. Weitere Starts wurden als Zweitinstanz sofort beendet, während der alte `second-instance`-Handler ohne Hauptfenster nichts tat. - Das Hauptfenster ist unter Windows nun ausdrücklich Besitzer des App-Lebenszyklus: Nach einem natürlichen, vom Renderer bestätigten Schließen wird der kontrollierte Shutdown auch bei verbleibenden Providerfenstern gestartet. Der normale `beforeunload`-Pfad bleibt erhalten, damit noch nicht übertragene Linksammler-Änderungen synchron gesichert werden können. - `second-instance` und `activate` stellen ein vorhandenes Hauptfenster wieder her oder erzeugen ein fehlendes neu. Ein Start während eines bereits laufenden Shutdowns plant genau einen Relaunch nach dem Prozessende ein. @@ -142,6 +148,14 @@ npm exec -- tsc --noEmit ## Verifizierungen vom 31. August 2026 +- Proxy-Parser gegen die bereitgestellte externe Liste geprüft: 1.000 von 1.000 Einträgen gültig; dabei wurden weder Proxy-Verbindungen aufgebaut noch Listeneinträge ausgegeben. +- Proxy-Segmenttests: 5 von 5 erfolgreich. Abgedeckt sind vier parallele authentifizierte CONNECT-Proxys mit exakten Byte-Bereichen und identischem Dateiergebnis, Ersatz eines abgelehnten Proxys, Direkt-Fallback ohne Restdatei bei ignoriertem Range-Header sowie Abbruch mit Fortschritts-Rückrechnung und Temp-Bereinigung. +- Reale Proxy-Prüfung mit einer 930.376.313 Byte großen Real-Debrid-RAR-Datei: 16 Verbindungen, vollständiger Segmenttransfer in rund 11,7 Sekunden mit 76,09 MiB/s mittlerem Nutzdurchsatz und zwischenzeitlich rund 94 MiB/s; Gesamtlauf einschließlich `datasync` und SHA-256 dauerte 13,19 Sekunden. Der zusätzliche Prüfverkehr betrug exakt 2 Byte. +- Die reale Testdatei besitzt SHA-256 `f60f70a03cc49f544aab97d66254cb39801d7bbad827d1db935ef8b25c8b544f`, eine gültige RAR-Signatur und ein fehlerfrei lesbares Inhaltsverzeichnis. 32 verteilte 64-Byte-Stichproben an Anfang und Ende aller 16 Segmente stimmten mit frischen Range-Antworten über 32 verschiedene Listeneinträge überein. +- Settings-/Storage-Regressionen decken Aktivierung, Pfad, Wertebereich 2 bis 32, Dateiauswahl und die Darstellung von 80 möglichen Tunneln bei 5 × 16 ab. +- Vollständiger Client-Lauf nach der Proxy-Änderung: 139 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.668 Tests erfolgreich und 4 übersprungen. Nur die zwei bekannten Symlink-Fixtures in `public-release-metadata.test.ts` scheiterten vor ihrer Produktassertion mit Windows-`EPERM`. +- Finaler Wiederholungslauf ohne die zwei privilegierten Symlink-Fixtures: 139 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.646 Tests erfolgreich und 4 übersprungen. +- Nach der Proxy-Änderung erfolgreich: TypeScript, Main-Build, Renderer-Build und Self-Check. Die bekannte Vite-Warnung zum nun rund 575 KiB großen Renderer-Chunk bleibt bestehen. - Root-Installation mit `npm ci`: erfolgreich. - Backup-API-Installation mit `npm ci --prefix services/backup-api`: erfolgreich. - TypeScript mit `npm exec -- tsc --noEmit`: erfolgreich. @@ -171,6 +185,8 @@ npm exec -- tsc --noEmit - `npm audit` meldet aktuell 36 Treffer: 2 niedrig, 5 mittel, 25 hoch und 4 kritisch. Direkte Treffer betreffen unter anderem Electron 31.7.7, Vite 6.4.1, Vitest 2.1.9, concurrently 9.2.1 und electron-builder 25.1.8. Ein Upgrade braucht einen eigenen getesteten Migrationsschritt; kein automatisches `npm audit fix` ausführen. - Der separate Backup-API-Baum hat aktuell 0 Audit-Treffer. - Windows ohne Developer Mode beziehungsweise passende Berechtigung kann die zwei Symlink-basierten Public-Release-Metadaten-Tests nicht ausführen. +- Proxy-Bündelung garantiert keine lineare Addition der Einzelgeschwindigkeiten. Ergebnis und Stabilität hängen unter anderem von Proxy-Latenz, Proxy-Bandbreite, Provider-/CDN-IP-Bindung, Range-Unterstützung, Zielserver-Limits, Dateigröße und lokaler Schreibgeschwindigkeit ab. Nach einem späten Segmentfehler kann vor dem Direkt-Fallback zusätzlicher Providerverkehr angefallen sein. +- Die bereitgestellte Proxy-Liste wurde mit dem ausdrücklich bereitgestellten Real-Debrid-Testlink erfolgreich real geprüft. Die Messung gilt nur für diesen Link, diesen Zeitpunkt und einen parallelen Download; daraus folgt noch keine Garantie für fünf gleichzeitige Downloads, andere CDN-Knoten oder dauerhafte Proxy-Leistung. - Der Arbeitsordner enthält `&`; ohne PowerShell-7-Skriptshell können npm-`cmd`-Shims fehlschlagen. - Es gibt keine `.github`-Workflows und damit keine serverseitige CI-Absicherung im GitHub-Repository. - Der Renderer-Bundle-Chunk liegt über Vites 500-KiB-Warnschwelle. @@ -181,7 +197,8 @@ npm exec -- tsc --noEmit ## Nächste sinnvolle Schritte 1. Für eine Auslieferung der aktuellen Fehlerkorrekturen zuerst Stand, Ziel, Tests und Rollback-Weg nennen und Saschas ausdrückliches Release-/Deployment-Go abwarten. -2. Nach einer freigegebenen Serverinstallation den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern. -3. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen. -4. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe. -5. Optional Developer Mode für lokale Symlink-Tests bereitstellen oder die Test-Fixtures plattformgerecht ohne privilegierte Symlinks gestalten. +2. Vor einer Auslieferung den Proxy-Modus mit einer unkritischen großen Testdatei zunächst bei 2 bis 4, danach bei 16 Verbindungen messen und dabei Erfolgsquote, Zusatzverkehr, Zielserver-Kompatibilität, Festplattenlast sowie Netto-MB/s vergleichen. Erst anschließend `maxParallel` auf 5 erhöhen. +3. Nach einer freigegebenen Serverinstallation den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern. +4. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen. +5. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe. +6. Optional Developer Mode für lokale Symlink-Tests bereitstellen oder die Test-Fixtures plattformgerecht ohne privilegierte Symlinks gestalten. diff --git a/package-lock.json b/package-lock.json index 5d44e6c..53b5998 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,10 @@ "name": "multi-debrid-downloader", "version": "2.0.74", "license": "MIT", - "dependencies": { - "adm-zip": "0.6.0", - "react": "^18.3.1", + "dependencies": { + "adm-zip": "0.6.0", + "https-proxy-agent": "7.0.6", + "react": "^18.3.1", "react-dom": "^18.3.1", "uuid": "11.1.1" }, @@ -2266,7 +2267,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -3637,7 +3637,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5062,7 +5061,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -5977,7 +5975,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { diff --git a/package.json b/package.json index 8b20a3a..d5bef76 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,10 @@ "release:win": "npm run build && electron-builder --publish never --win nsis portable", "verify:release": "node scripts/verify_public_release.mjs" }, - "dependencies": { - "adm-zip": "0.6.0", - "react": "^18.3.1", + "dependencies": { + "adm-zip": "0.6.0", + "https-proxy-agent": "7.0.6", + "react": "^18.3.1", "react-dom": "^18.3.1", "uuid": "11.1.1" }, diff --git a/src/main/constants.ts b/src/main/constants.ts index 1d25e77..dcec092 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -105,8 +105,11 @@ export function defaultSettings(): AppSettings { retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, - speedLimitMode: "global", - updateRepo: DEFAULT_UPDATE_REPO, + speedLimitMode: "global", + proxyDownloadEnabled: false, + proxyListPath: "", + proxyConnectionsPerDownload: 16, + updateRepo: DEFAULT_UPDATE_REPO, autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index b238d3b..2b18b66 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -61,7 +61,8 @@ function releaseTlsSkip(): void { } } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; -import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; +import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; +import { downloadWithProxySegments, type ProxyFallbackReason } from "./proxy-segmented-download"; import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, isRealDebridAccountBlockedForStart, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo, type ExtractProgressUpdate } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; @@ -10915,10 +10916,11 @@ export class DownloadManager extends EventEmitter { const maxAttemptsBySetting = configuredRetryLimit <= 0 ? Number.MAX_SAFE_INTEGER : configuredRetryLimit + 1; const maxAttempts = Math.max(1, Math.min(MAX_SAME_DIRECT_URL_ATTEMPTS, maxAttemptsBySetting)); - let lastError = ""; - let effectiveTargetPath = targetPath; - let resumeRewindBytesNextAttempt = 0; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let lastError = ""; + let effectiveTargetPath = targetPath; + let resumeRewindBytesNextAttempt = 0; + let proxyAttempted = false; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { let existingBytes = 0; try { const stat = await fs.promises.stat(effectiveTargetPath); @@ -10989,14 +10991,130 @@ export class DownloadManager extends EventEmitter { rangeHeader: headers.Range || "" }); - while (this.reconnectActive()) { + while (this.reconnectActive()) { if (active.abortController.signal.aborted) { throw new Error(`aborted:${active.abortReason}`); } - await sleep(250); - } - - let response: Response; + await sleep(250); + } + + if (!proxyAttempted && attempt === 1 && existingBytes === 0 && this.settings.proxyDownloadEnabled) { + proxyAttempted = true; + if (this.settings.speedLimitEnabled) { + logAttemptEvent("INFO", "Proxy-Segmentierung wegen aktivem Geschwindigkeitslimit übersprungen", { + attempt + }); + } else { + const proxyReasonLabels: Record = { + proxy_file_unavailable: "Proxy-Liste nicht verfügbar", + no_valid_proxies: "Keine gültigen Proxys in der Liste", + range_unsupported: "Server unterstützt keine Byte-Bereiche", + file_too_small: "Datei ist für mehrere Segmente zu klein", + proxy_unavailable: "Proxys nicht erreichbar", + segment_failed: "Proxy-Segmente konnten nicht vollständig geladen werden" + }; + let proxyWindowBytes = 0; + let proxyWindowStartedAt = nowMs(); + let lastProxyUiEmitAt = 0; + item.fullStatus = `Proxy-Download startet (${this.settings.proxyConnectionsPerDownload} Verbindungen)`; + item.updatedAt = nowMs(); + this.emitState(); + logAttemptEvent("INFO", "Proxy-Segmentdownload startet", { + attempt, + connections: this.settings.proxyConnectionsPerDownload, + proxyListConfigured: Boolean(this.settings.proxyListPath) + }); + const proxyResult = await downloadWithProxySegments({ + directUrl, + targetPath: effectiveTargetPath, + proxyListPath: this.settings.proxyListPath, + connections: this.settings.proxyConnectionsPerDownload, + signal: active.abortController.signal, + skipTlsVerify, + waitWhilePaused: async () => { + while (this.session.paused && this.session.running && !active.abortController.signal.aborted) { + item.status = "paused"; + item.fullStatus = "Pausiert"; + item.updatedAt = nowMs(); + this.emitState(); + await sleep(120); + } + if (active.abortController.signal.aborted) { + throw new Error(`aborted:${active.abortReason}`); + } + }, + onTrafficBytes: (bytes) => { + proxyWindowBytes += bytes; + this.recordProviderDownloadedBytes( + item.provider, + bytes, + item.providerAccountId, + item.providerAccountLabel + ); + this.recordSpeed(bytes, item.packageId); + }, + onProgress: (deltaBytes, downloadedBytes, totalBytes) => { + this.session.totalDownloadedBytes = Math.max(0, this.session.totalDownloadedBytes + deltaBytes); + this.sessionDownloadedBytes = Math.max(0, this.sessionDownloadedBytes + deltaBytes); + this.settings.totalDownloadedAllTime = Math.max(0, Number(this.settings.totalDownloadedAllTime || 0) + deltaBytes); + this.itemContributedBytes.set( + active.itemId, + Math.max(0, (this.itemContributedBytes.get(active.itemId) || 0) + deltaBytes) + ); + const nowTick = nowMs(); + const elapsed = Math.max((nowTick - proxyWindowStartedAt) / 1000, 0.2); + item.status = this.session.paused ? "paused" : "downloading"; + item.downloadedBytes = downloadedBytes; + item.totalBytes = totalBytes; + item.progressPercent = Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100))); + item.speedBps = this.session.paused ? 0 : Math.max(0, Math.floor(proxyWindowBytes / elapsed)); + item.fullStatus = this.session.paused + ? "Pausiert" + : `Proxy-Download läuft (${this.settings.proxyConnectionsPerDownload} Verbindungen)`; + if (elapsed >= 0.5) { + proxyWindowStartedAt = nowTick; + proxyWindowBytes = 0; + } + if (nowTick - lastProxyUiEmitAt >= DOWNLOAD_LIVE_UPDATE_INTERVAL_MS || deltaBytes < 0) { + item.updatedAt = nowTick; + this.emitState(); + lastProxyUiEmitAt = nowTick; + } + } + }); + if (proxyResult.status === "completed") { + active.resumable = true; + item.status = "downloading"; + item.downloadedBytes = proxyResult.totalBytes; + item.totalBytes = proxyResult.totalBytes; + item.progressPercent = 100; + item.speedBps = 0; + item.fullStatus = "Finalisierend..."; + item.updatedAt = nowMs(); + this.emitState(); + logAttemptEvent("INFO", "Proxy-Segmentdownload abgeschlossen", { + attempt, + connections: proxyResult.connections, + totalBytes: proxyResult.totalBytes, + targetPath: effectiveTargetPath + }); + return { resumable: true }; + } + item.status = "downloading"; + item.downloadedBytes = 0; + item.progressPercent = 0; + item.speedBps = 0; + item.fullStatus = "Direktdownload wird gestartet"; + item.updatedAt = nowMs(); + this.emitState(); + logAttemptEvent("WARN", "Proxy-Segmentdownload nicht verwendet, Direktdownload folgt", { + attempt, + reason: proxyReasonLabels[proxyResult.reason] + }); + } + } + + let response: Response; const connectTimeoutMs = getDownloadConnectTimeoutMs(); let connectTimer: NodeJS.Timeout | null = null; const connectAbortController = new AbortController(); diff --git a/src/main/main.ts b/src/main/main.ts index 040949e..99c754e 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -938,14 +938,25 @@ function registerIpcHandlers(): void { throw error; } }); - handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => { + handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => { const options = { properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory"> }; const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); - return result.canceled ? null : result.filePaths[0] || null; - }); - handleTrusted(IPC_CHANNELS.PICK_CONTAINERS, async () => { + return result.canceled ? null : result.filePaths[0] || null; + }); + handleTrusted(IPC_CHANNELS.PICK_PROXY_LIST, async () => { + const options = { + properties: ["openFile"] as Array<"openFile">, + filters: [ + { name: "Proxy-Listen", extensions: ["txt", "list"] }, + { name: "Alle Dateien", extensions: ["*"] } + ] + }; + const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); + return result.canceled ? null : result.filePaths[0] || null; + }); + handleTrusted(IPC_CHANNELS.PICK_CONTAINERS, async () => { const options = { properties: ["openFile", "multiSelections"] as Array<"openFile" | "multiSelections">, filters: [ diff --git a/src/main/proxy-segmented-download.ts b/src/main/proxy-segmented-download.ts new file mode 100644 index 0000000..c59013e --- /dev/null +++ b/src/main/proxy-segmented-download.ts @@ -0,0 +1,691 @@ +import fs from "node:fs"; +import http from "node:http"; +import https from "node:https"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { IncomingMessage } from "node:http"; +import { HttpsProxyAgent } from "https-proxy-agent"; + +const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; +const DEFAULT_IDLE_TIMEOUT_MS = 45_000; +const DEFAULT_MIN_SEGMENT_BYTES = 4 * 1024 * 1024; +const DEFAULT_PROXY_ATTEMPTS = 3; +const PROXY_PROBE_END = 1; +const MAX_REDIRECTS = 5; +const MAX_PROXY_FILE_BYTES = 8 * 1024 * 1024; +const DISK_ERROR_CODES = new Set(["ENOSPC", "EDQUOT", "EACCES", "EPERM", "EROFS", "EIO", "ENODEV"]); + +interface ProxyEndpoint { + id: number; + url: string; + authorization: string; +} + +interface CachedProxyFile { + mtimeMs: number; + size: number; + proxies: ProxyEndpoint[]; +} + +interface ParsedRange { + start: number; + end: number; + total: number; +} + +interface RangeResponse { + response: IncomingMessage; + dispose: () => void; +} + +interface Segment { + index: number; + start: number; + end: number; +} + +export interface ProxySegmentedDownloadOptions { + directUrl: string; + targetPath: string; + proxyListPath: string; + connections: number; + signal: AbortSignal; + skipTlsVerify?: boolean; + minSegmentBytes?: number; + connectTimeoutMs?: number; + idleTimeoutMs?: number; + proxyAttempts?: number; + waitWhilePaused?: () => Promise; + onTrafficBytes?: (bytes: number) => void; + onProgress?: (deltaBytes: number, downloadedBytes: number, totalBytes: number) => void; +} + +export type ProxySegmentedDownloadResult = + | { status: "completed"; totalBytes: number; connections: number } + | { status: "fallback"; reason: ProxyFallbackReason }; + +export type ProxyFallbackReason = + | "proxy_file_unavailable" + | "no_valid_proxies" + | "range_unsupported" + | "file_too_small" + | "proxy_unavailable" + | "segment_failed"; + +const proxyFileCache = new Map(); + +function parseProxyLine(rawLine: string, id: number): ProxyEndpoint | null { + const value = rawLine.trim(); + if (!value || value.startsWith("#") || /\s/.test(value)) { + return null; + } + + let candidate = value; + if (!/^https?:\/\//i.test(candidate)) { + const parts = candidate.split(":"); + if (!candidate.includes("@") && parts.length === 4 && /^\d+$/.test(parts[1])) { + const [host, port, username, password] = parts; + candidate = `http://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`; + } else if (candidate.includes("@")) { + const separator = candidate.lastIndexOf("@"); + const credentials = candidate.slice(0, separator); + const address = candidate.slice(separator + 1); + const credentialSeparator = credentials.indexOf(":"); + if (credentialSeparator < 1 || !address) { + return null; + } + candidate = `http://${encodeURIComponent(credentials.slice(0, credentialSeparator))}:${encodeURIComponent(credentials.slice(credentialSeparator + 1))}@${address}`; + } else { + candidate = `http://${candidate}`; + } + } + + try { + const parsed = new URL(candidate); + if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || !parsed.hostname || !parsed.port) { + return null; + } + const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return null; + } + let authorization = ""; + if (parsed.username || parsed.password) { + const username = decodeURIComponent(parsed.username); + const password = decodeURIComponent(parsed.password); + authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; + parsed.username = ""; + parsed.password = ""; + } + parsed.pathname = ""; + parsed.search = ""; + parsed.hash = ""; + return { id, url: parsed.toString(), authorization }; + } catch { + return null; + } +} + +export function parseProxyList(content: string): number { + return content + .replace(/^\uFEFF/, "") + .split(/\r?\n/) + .reduce((count, line, index) => count + (parseProxyLine(line, index) ? 1 : 0), 0); +} + +async function loadProxyFile(filePath: string): Promise< + { status: "ok"; proxies: ProxyEndpoint[] } + | { status: "unavailable" } + | { status: "empty" } +> { + const normalizedPath = path.resolve(filePath.trim()); + try { + const stat = await fs.promises.stat(normalizedPath); + if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_PROXY_FILE_BYTES) { + return { status: stat.size <= 0 ? "empty" : "unavailable" }; + } + const cached = proxyFileCache.get(normalizedPath); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return cached.proxies.length > 0 + ? { status: "ok", proxies: cached.proxies } + : { status: "empty" }; + } + const content = await fs.promises.readFile(normalizedPath, "utf8"); + const seen = new Set(); + const proxies = content + .replace(/^\uFEFF/, "") + .split(/\r?\n/) + .map((line, index) => parseProxyLine(line, index)) + .filter((proxy): proxy is ProxyEndpoint => { + if (!proxy) return false; + const key = `${proxy.url}\0${proxy.authorization}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + proxyFileCache.set(normalizedPath, { mtimeMs: stat.mtimeMs, size: stat.size, proxies }); + return proxies.length > 0 ? { status: "ok", proxies } : { status: "empty" }; + } catch { + return { status: "unavailable" }; + } +} + +function parseContentRange(value: string | undefined): ParsedRange | null { + const match = /^bytes\s+(\d+)-(\d+)\/(\d+)$/i.exec(String(value || "").trim()); + if (!match) { + return null; + } + const start = Number(match[1]); + const end = Number(match[2]); + const total = Number(match[3]); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || !Number.isSafeInteger(total) + || start < 0 || end < start || total <= end) { + return null; + } + return { start, end, total }; +} + +function abortError(): Error { + return new Error("aborted:proxy_download"); +} + +function isDiskError(error: unknown): boolean { + const code = error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code || "") + : ""; + return DISK_ERROR_CODES.has(code); +} + +async function openRangeResponse( + target: URL, + proxy: ProxyEndpoint, + start: number, + end: number, + signal: AbortSignal, + skipTlsVerify: boolean, + connectTimeoutMs: number, + idleTimeoutMs: number, + redirectCount = 0 +): Promise { + if (signal.aborted) { + throw abortError(); + } + + const agent = new HttpsProxyAgent(proxy.url, { + keepAlive: false, + headers: proxy.authorization ? { "Proxy-Authorization": proxy.authorization } : {} + }); + const requestOptions: https.RequestOptions = { + method: "GET", + headers: { + Accept: "*/*", + Range: `bytes=${start}-${end}`, + "User-Agent": "Multi-Debrid-Downloader" + }, + agent, + rejectUnauthorized: !skipTlsVerify + }; + + return new Promise((resolve, reject) => { + let settled = false; + let responseRef: IncomingMessage | null = null; + const requestFn: typeof http.request = target.protocol === "https:" ? https.request : http.request; + const request = requestFn(target, requestOptions, (response) => { + responseRef = response; + clearTimeout(connectTimer); + const status = response.statusCode || 0; + const location = response.headers.location; + if (status >= 300 && status < 400 && location) { + settled = true; + response.resume(); + response.once("end", () => { + cleanup(); + if (redirectCount >= MAX_REDIRECTS) { + reject(new Error("proxy_redirect_limit")); + return; + } + let redirected: URL; + try { + redirected = new URL(location, target); + if (redirected.protocol !== "http:" && redirected.protocol !== "https:") { + reject(new Error("proxy_redirect_invalid")); + return; + } + } catch { + reject(new Error("proxy_redirect_invalid")); + return; + } + openRangeResponse( + redirected, + proxy, + start, + end, + signal, + skipTlsVerify, + connectTimeoutMs, + idleTimeoutMs, + redirectCount + 1 + ).then(resolve, reject); + }); + return; + } + settled = true; + response.setTimeout(idleTimeoutMs, () => response.destroy(new Error("proxy_idle_timeout"))); + response.once("close", cleanup); + resolve({ + response, + dispose: () => { + cleanup(); + response.destroy(); + } + }); + }); + + const cleanup = (): void => { + clearTimeout(connectTimer); + signal.removeEventListener("abort", onAbort); + agent.destroy(); + }; + const onAbort = (): void => { + request.destroy(abortError()); + responseRef?.destroy(abortError()); + }; + const connectTimer = setTimeout(() => request.destroy(new Error("proxy_connect_timeout")), connectTimeoutMs); + signal.addEventListener("abort", onAbort, { once: true }); + request.once("error", (error) => { + cleanup(); + if (!settled) { + reject(signal.aborted ? abortError() : error); + } + }); + request.setTimeout(idleTimeoutMs, () => request.destroy(new Error("proxy_idle_timeout"))); + request.end(); + }); +} + +async function consumeProbe(response: IncomingMessage, expectedBytes: number, onTrafficBytes?: (bytes: number) => void): Promise { + let received = 0; + for await (const rawChunk of response) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk); + received += chunk.length; + onTrafficBytes?.(chunk.length); + if (received > expectedBytes) { + throw new Error("proxy_probe_overflow"); + } + } + return received; +} + +async function probeRangeSupport( + target: URL, + proxies: ProxyEndpoint[], + signal: AbortSignal, + options: Required>, + onTrafficBytes?: (bytes: number) => void +): Promise< + { status: "ok"; totalBytes: number } + | { status: "unsupported" } + | { status: "unavailable" } +> { + const attempts = Math.min(proxies.length, options.proxyAttempts); + for (let index = 0; index < attempts; index += 1) { + let opened: RangeResponse | null = null; + try { + opened = await openRangeResponse( + target, + proxies[Math.floor((index * proxies.length) / attempts)], + 0, + PROXY_PROBE_END, + signal, + options.skipTlsVerify, + options.connectTimeoutMs, + options.idleTimeoutMs + ); + const status = opened.response.statusCode || 0; + if (status === 200) { + opened.dispose(); + return { status: "unsupported" }; + } + const range = parseContentRange(opened.response.headers["content-range"]); + const expectedEnd = range ? Math.min(PROXY_PROBE_END, range.total - 1) : -1; + const expectedBytes = expectedEnd + 1; + const contentLength = Number(opened.response.headers["content-length"] || 0); + if (status !== 206 + || !range + || range.start !== 0 + || range.end !== expectedEnd + || (contentLength > 0 && contentLength !== expectedBytes)) { + opened.dispose(); + continue; + } + const received = await consumeProbe(opened.response, expectedBytes, onTrafficBytes); + opened.dispose(); + if (received === expectedBytes) { + return { status: "ok", totalBytes: range.total }; + } + } catch (error) { + opened?.dispose(); + if (signal.aborted) { + throw abortError(); + } + if (isDiskError(error)) { + throw error; + } + } + } + return { status: "unavailable" }; +} + +function buildSegments(totalBytes: number, count: number): Segment[] { + const baseSize = Math.floor(totalBytes / count); + const remainder = totalBytes % count; + let start = 0; + return Array.from({ length: count }, (_, index) => { + const size = baseSize + (index < remainder ? 1 : 0); + const segment = { index, start, end: start + size - 1 }; + start += size; + return segment; + }); +} + +class ProxyPool { + private cursor = 0; + private active = new Set(); + private cooldownUntil = new Map(); + + public constructor(private readonly proxies: ProxyEndpoint[]) {} + + public acquire(excluded: ReadonlySet): ProxyEndpoint | null { + const now = Date.now(); + const select = (allowActive: boolean, allowCooldown: boolean): ProxyEndpoint | null => { + for (let offset = 0; offset < this.proxies.length; offset += 1) { + const index = (this.cursor + offset) % this.proxies.length; + const proxy = this.proxies[index]; + if (excluded.has(proxy.id) + || (!allowActive && this.active.has(proxy.id)) + || (!allowCooldown && (this.cooldownUntil.get(proxy.id) || 0) > now)) { + continue; + } + this.cursor = (index + 1) % this.proxies.length; + this.active.add(proxy.id); + return proxy; + } + return null; + }; + return select(false, false) || select(false, true) || select(true, true); + } + + public release(proxy: ProxyEndpoint, succeeded: boolean): void { + this.active.delete(proxy.id); + if (succeeded) { + this.cooldownUntil.delete(proxy.id); + } else { + this.cooldownUntil.set(proxy.id, Date.now() + 30_000); + } + } +} + +async function writeBufferAt(handle: fs.promises.FileHandle, buffer: Buffer, position: number): Promise { + let offset = 0; + while (offset < buffer.length) { + const result = await handle.write(buffer, offset, buffer.length - offset, position + offset); + if (result.bytesWritten <= 0) { + throw new Error("proxy_disk_write_zero"); + } + offset += result.bytesWritten; + } +} + +async function downloadSegmentOnce( + target: URL, + tempPath: string, + segment: Segment, + proxy: ProxyEndpoint, + signal: AbortSignal, + options: Required>, + callbacks: Pick, + totalBytes: number, + currentProgress: () => number +): Promise { + const opened = await openRangeResponse( + target, + proxy, + segment.start, + segment.end, + signal, + options.skipTlsVerify, + options.connectTimeoutMs, + options.idleTimeoutMs + ); + const response = opened.response; + const expectedBytes = segment.end - segment.start + 1; + const range = parseContentRange(response.headers["content-range"]); + const contentLength = Number(response.headers["content-length"] || 0); + if ((response.statusCode || 0) !== 206 + || !range + || range.start !== segment.start + || range.end !== segment.end + || range.total !== totalBytes + || (contentLength > 0 && contentLength !== expectedBytes)) { + opened.dispose(); + throw new Error("proxy_range_mismatch"); + } + + let handle: fs.promises.FileHandle; + try { + handle = await fs.promises.open(tempPath, "r+"); + } catch (error) { + opened.dispose(); + throw error; + } + let received = 0; + try { + for await (const rawChunk of response) { + if (signal.aborted) { + throw abortError(); + } + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk); + callbacks.onTrafficBytes?.(chunk.length); + if (received + chunk.length > expectedBytes) { + throw new Error("proxy_segment_overflow"); + } + if (callbacks.waitWhilePaused) { + response.setTimeout(0); + await callbacks.waitWhilePaused(); + response.setTimeout(options.idleTimeoutMs); + } + await writeBufferAt(handle, chunk, segment.start + received); + received += chunk.length; + callbacks.onProgress?.(chunk.length, currentProgress() + chunk.length, totalBytes); + } + if (received !== expectedBytes) { + throw new Error("proxy_segment_underflow"); + } + return received; + } finally { + opened.dispose(); + await handle.close(); + } +} + +export async function downloadWithProxySegments(options: ProxySegmentedDownloadOptions): Promise { + const proxyPath = String(options.proxyListPath || "").trim(); + if (!proxyPath) { + return { status: "fallback", reason: "proxy_file_unavailable" }; + } + const loaded = await loadProxyFile(proxyPath); + if (loaded.status === "unavailable") { + return { status: "fallback", reason: "proxy_file_unavailable" }; + } + if (loaded.status === "empty") { + return { status: "fallback", reason: "no_valid_proxies" }; + } + + let target: URL; + try { + target = new URL(options.directUrl); + if (target.protocol !== "http:" && target.protocol !== "https:") { + return { status: "fallback", reason: "proxy_unavailable" }; + } + } catch { + return { status: "fallback", reason: "proxy_unavailable" }; + } + + const normalized = { + skipTlsVerify: Boolean(options.skipTlsVerify), + connectTimeoutMs: Math.max(1_000, Math.floor(options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)), + idleTimeoutMs: Math.max(2_000, Math.floor(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS)), + proxyAttempts: Math.max(1, Math.min(10, Math.floor(options.proxyAttempts ?? DEFAULT_PROXY_ATTEMPTS))) + }; + const probe = await probeRangeSupport(target, loaded.proxies, options.signal, normalized, options.onTrafficBytes); + if (probe.status === "unsupported") { + return { status: "fallback", reason: "range_unsupported" }; + } + if (probe.status === "unavailable") { + return { status: "fallback", reason: "proxy_unavailable" }; + } + + const requestedConnections = Math.max(2, Math.min(32, Math.floor(options.connections || 16))); + const minSegmentBytes = Math.max(1, Math.floor(options.minSegmentBytes ?? DEFAULT_MIN_SEGMENT_BYTES)); + const connections = Math.min(requestedConnections, loaded.proxies.length, Math.floor(probe.totalBytes / minSegmentBytes)); + if (connections < 2) { + return { status: "fallback", reason: "file_too_small" }; + } + + await fs.promises.mkdir(path.dirname(options.targetPath), { recursive: true }); + try { + const targetStat = await fs.promises.stat(options.targetPath); + if (targetStat.size > 0) { + return { status: "fallback", reason: "segment_failed" }; + } + await fs.promises.rm(options.targetPath, { force: true }); + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? String((error as NodeJS.ErrnoException).code || "") + : ""; + if (code !== "ENOENT") { + throw error; + } + } + const tempPath = `${options.targetPath}.proxy-${process.pid}-${randomUUID()}.part`; + let committedProgress = 0; + let tempCreated = false; + const updateProgress = (deltaBytes: number, _downloadedBytes: number, totalBytes: number): void => { + committedProgress = Math.max(0, Math.min(totalBytes, committedProgress + deltaBytes)); + options.onProgress?.(deltaBytes, committedProgress, totalBytes); + }; + + try { + const initialHandle = await fs.promises.open(tempPath, "wx"); + tempCreated = true; + try { + await initialHandle.truncate(probe.totalBytes); + } finally { + await initialHandle.close(); + } + + const segments = buildSegments(probe.totalBytes, connections); + const pool = new ProxyPool(loaded.proxies); + const segmentController = new AbortController(); + const cascadeAbort = (): void => segmentController.abort("parent_abort"); + options.signal.addEventListener("abort", cascadeAbort, { once: true }); + const signal = AbortSignal.any([options.signal, segmentController.signal]); + + const tasks = segments.map(async (segment) => { + const excluded = new Set(); + let lastError: unknown = null; + for (let attempt = 0; attempt < normalized.proxyAttempts; attempt += 1) { + if (signal.aborted) { + throw abortError(); + } + const proxy = pool.acquire(excluded); + if (!proxy) { + break; + } + excluded.add(proxy.id); + let attemptProgress = 0; + try { + const bytes = await downloadSegmentOnce( + target, + tempPath, + segment, + proxy, + signal, + normalized, + { + waitWhilePaused: options.waitWhilePaused, + onTrafficBytes: options.onTrafficBytes, + onProgress: (deltaBytes, downloadedBytes, totalBytes) => { + attemptProgress += deltaBytes; + updateProgress(deltaBytes, downloadedBytes, totalBytes); + } + }, + probe.totalBytes, + () => committedProgress + ); + pool.release(proxy, true); + return bytes; + } catch (error) { + pool.release(proxy, false); + if (attemptProgress > 0) { + updateProgress(-attemptProgress, committedProgress - attemptProgress, probe.totalBytes); + } + if (options.signal.aborted || isDiskError(error)) { + throw error; + } + lastError = error; + } + } + throw lastError || new Error("proxy_segment_failed"); + }); + + const guardedTasks = tasks.map((task) => task.catch((error) => { + segmentController.abort("segment_failed"); + throw error; + })); + const settled = await Promise.allSettled(guardedTasks); + options.signal.removeEventListener("abort", cascadeAbort); + const failed = settled.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (failed) { + if (options.signal.aborted) { + throw abortError(); + } + if (isDiskError(failed.reason)) { + throw failed.reason; + } + return { status: "fallback", reason: "segment_failed" }; + } + + const finalizedHandle = await fs.promises.open(tempPath, "r+"); + try { + await finalizedHandle.datasync(); + } finally { + await finalizedHandle.close(); + } + const finalStat = await fs.promises.stat(tempPath); + if (finalStat.size !== probe.totalBytes) { + return { status: "fallback", reason: "segment_failed" }; + } + await fs.promises.rename(tempPath, options.targetPath); + tempCreated = false; + return { status: "completed", totalBytes: probe.totalBytes, connections }; + } catch (error) { + if (options.signal.aborted || String(error).includes("aborted:proxy_download")) { + throw abortError(); + } + if (isDiskError(error)) { + throw error; + } + return { status: "fallback", reason: "segment_failed" }; + } finally { + if (tempCreated) { + try { + await fs.promises.rm(tempPath, { force: true }); + } catch { + } + } + if (committedProgress > 0 && tempCreated) { + updateProgress(-committedProgress, 0, probe.totalBytes); + } + } +} diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 6aa2c06..2d3ba77 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -204,6 +204,9 @@ export function createRendererSettings(settings: AppSettings): RendererSettings speedLimitEnabled: settings.speedLimitEnabled, speedLimitKbps: settings.speedLimitKbps, speedLimitMode: settings.speedLimitMode, + proxyDownloadEnabled: settings.proxyDownloadEnabled, + proxyListPath: settings.proxyListPath, + proxyConnectionsPerDownload: settings.proxyConnectionsPerDownload, updateRepo: settings.updateRepo, autoUpdateCheck: settings.autoUpdateCheck, clipboardWatch: settings.clipboardWatch, diff --git a/src/main/storage.ts b/src/main/storage.ts index dab96e5..63341e2 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -611,8 +611,11 @@ export function normalizeSettings(settings: AppSettings): AppSettings { completedCleanupPolicy: settings.completedCleanupPolicy, speedLimitEnabled: Boolean(settings.speedLimitEnabled), speedLimitKbps: clampNumber(settings.speedLimitKbps, defaults.speedLimitKbps, 0, 500000), - speedLimitMode: settings.speedLimitMode, - autoUpdateCheck: Boolean(settings.autoUpdateCheck), + speedLimitMode: settings.speedLimitMode, + proxyDownloadEnabled: Boolean(settings.proxyDownloadEnabled), + proxyListPath: asText(settings.proxyListPath).slice(0, 4096), + proxyConnectionsPerDownload: clampNumber(settings.proxyConnectionsPerDownload, defaults.proxyConnectionsPerDownload, 2, 32), + autoUpdateCheck: Boolean(settings.autoUpdateCheck), updateRepo: migrateUpdateRepo(asText(settings.updateRepo), defaults.updateRepo), clipboardWatch: Boolean(settings.clipboardWatch), minimizeToTray: Boolean(settings.minimizeToTray), diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 7f37db4..c82b0f3 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -93,8 +93,9 @@ const api: ElectronApi = { importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json), toggleClipboard: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), writeClipboardText: (text: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, text), - pickFolder: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), - pickContainers: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), + pickFolder: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), + pickProxyList: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_PROXY_LIST), + pickContainers: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), getSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS), resetSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS), resetDownloadStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index ee35de9..8ef2810 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -952,7 +952,8 @@ const emptySnapshot = (): UiSnapshot => ({ cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", - maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", + maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", + proxyDownloadEnabled: false, proxyListPath: "", proxyConnectionsPerDownload: 16, updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", themePreference: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, @@ -6064,7 +6065,8 @@ export function App(): ReactElement { historyMaxEntries: [50, 100000, 500], historyMaxAgeDays: [0, 3650, 0], maxParallelExtract: [1, 8, 2], - reconnectWaitSeconds: [10, 600, 45] + reconnectWaitSeconds: [10, 600, 45], + proxyConnectionsPerDownload: [2, 32, 16] }; const bounds = numericLimits[fieldId as keyof RendererSettingsDraft]; if (bounds) { @@ -6128,6 +6130,13 @@ export function App(): ReactElement { }); return; } + if (fieldId === "proxyListPath") { + void performQuickAction(async () => { + const selectedPath = await window.rd.pickProxyList(); + if (selectedPath) setText("proxyListPath", selectedPath); + }); + return; + } const targetKey = fieldId === "outputDir" ? "outputDir" : fieldId === "extractDir" ? "extractDir" : fieldId === "mkvLibraryDir" ? "mkvLibraryDir" : null; if (targetKey) { void performQuickAction(async () => { diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index 55caa47..f9e6e98 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -422,6 +422,40 @@ export function buildSettingsFormViewModel({ { id: "reconnectWaitSeconds", kind: "number", label: "Wartezeit vor neuem Versuch (Sek.)", value: String(settings.reconnectWaitSeconds), min: 10, max: 600 } ] }, + { + id: "speed-proxy", + title: "Proxy-Download", + description: "Teilt neue Downloads in Byte-Bereiche und lädt sie parallel über verschiedene HTTP-Proxys. Falls der Server keine Bereiche unterstützt oder die Proxys ausfallen, läuft der bestehende Direktdownload weiter.", + fields: [ + { + id: "proxyDownloadEnabled", + kind: "switch", + label: "Proxy-Download aktivieren", + value: settings.proxyDownloadEnabled, + help: "Gilt für neue Dateien ab 8 MiB. Ein aktives Geschwindigkeitslimit verwendet weiterhin den Direktdownload." + }, + { + id: "proxyListPath", + kind: "path", + label: "Proxy-Liste", + value: settings.proxyListPath, + placeholder: "Textdatei mit einem Proxy pro Zeile", + actionLabel: "Datei wählen", + disabled: !settings.proxyDownloadEnabled, + help: "Unterstützt unter anderem Benutzer:Passwort@Host:Port. Zugangsdaten werden nicht protokolliert." + }, + { + id: "proxyConnectionsPerDownload", + kind: "number", + label: "Verbindungen pro Download", + value: String(settings.proxyConnectionsPerDownload), + min: 2, + max: 32, + disabled: !settings.proxyDownloadEnabled, + help: `Bei ${settings.maxParallel} gleichzeitigen Downloads sind bis zu ${settings.maxParallel * settings.proxyConnectionsPerDownload} Proxy-Verbindungen möglich.` + } + ] + }, ...scheduleGroups, { id: "speed-schedule-add", diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index ede8da6..9950e3c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -38,8 +38,9 @@ export const IPC_CHANNELS = { EXPORT_ITEM_SELECTION: "queue:export-item-selection", EXPORT_QUEUE: "queue:export", IMPORT_QUEUE: "queue:import", - PICK_FOLDER: "dialog:pick-folder", - PICK_CONTAINERS: "dialog:pick-containers", + PICK_FOLDER: "dialog:pick-folder", + PICK_PROXY_LIST: "dialog:pick-proxy-list", + PICK_CONTAINERS: "dialog:pick-containers", STATE_UPDATE: "state:update", CLIPBOARD_DETECTED: "clipboard:detected", TOGGLE_CLIPBOARD: "clipboard:toggle", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 7f22802..e380f34 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -113,8 +113,9 @@ export interface ElectronApi { importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>; toggleClipboard: () => Promise; writeClipboardText: (text: string) => Promise; - pickFolder: () => Promise; - pickContainers: () => Promise; + pickFolder: () => Promise; + pickProxyList: () => Promise; + pickContainers: () => Promise; getSessionStats: () => Promise; resetSessionStats: () => Promise; resetDownloadStats: () => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index d18cf5c..4bc24e2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -139,7 +139,13 @@ export interface DailyStartSettings { dailyStartLastOutcome: DailyStartOutcome; } -export interface AppSettings extends DailyStartSettings { +export interface ProxyDownloadSettings { + proxyDownloadEnabled: boolean; + proxyListPath: string; + proxyConnectionsPerDownload: number; +} + +export interface AppSettings extends DailyStartSettings, ProxyDownloadSettings { language: AppLanguage; token: string; realDebridUseWebLogin: boolean; @@ -291,7 +297,7 @@ export interface RendererAccount { status: DebridAccountStatus | null; } -export interface RendererSettings extends DailyStartSettings { +export interface RendererSettings extends DailyStartSettings, ProxyDownloadSettings { language: AppLanguage; realDebridUseWebLogin: boolean; realDebridDisabledAccountIds: string[]; diff --git a/tests/proxy-segmented-download.test.ts b/tests/proxy-segmented-download.test.ts new file mode 100644 index 0000000..358f29e --- /dev/null +++ b/tests/proxy-segmented-download.test.ts @@ -0,0 +1,274 @@ +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { downloadWithProxySegments, parseProxyList } from "../src/main/proxy-segmented-download"; + +interface RunningServer { + port: number; + close: () => Promise; +} + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanups.length > 0) { + await cleanups.pop()?.(); + } +}); + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Testserver konnte nicht gestartet werden"); + } + const sockets = new Set(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + const close = async (): Promise => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + }; + cleanups.push(close); + return { port: address.port, close }; +} + +async function createRangeServer(content: Buffer, supportsRanges = true): Promise { + const ranges: string[] = []; + const server = http.createServer((request, response) => { + const rangeHeader = String(request.headers.range || ""); + ranges.push(rangeHeader); + if (!supportsRanges || !rangeHeader) { + response.writeHead(200, { "Content-Length": content.length }); + response.end(content); + return; + } + const match = /^bytes=(\d+)-(\d+)$/.exec(rangeHeader); + if (!match) { + response.writeHead(416, { "Content-Range": `bytes */${content.length}` }); + response.end(); + return; + } + const start = Number(match[1]); + const end = Math.min(Number(match[2]), content.length - 1); + const body = content.subarray(start, end + 1); + response.writeHead(206, { + "Accept-Ranges": "bytes", + "Content-Range": `bytes ${start}-${end}/${content.length}`, + "Content-Length": body.length + }); + response.end(body); + }); + const running = await listen(server); + return { ...running, ranges }; +} + +async function createConnectProxy(username: string, password: string, acceptedConnections: { value: number }): Promise { + const server = http.createServer(); + server.on("connect", (request, clientSocket, head) => { + const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; + if (request.headers["proxy-authorization"] !== expectedAuth) { + clientSocket.end("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"); + return; + } + const target = new URL(`http://${request.url}`); + const upstream = net.connect(Number(target.port), target.hostname, () => { + acceptedConnections.value += 1; + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) upstream.write(head); + clientSocket.pipe(upstream); + upstream.pipe(clientSocket); + }); + upstream.once("error", () => clientSocket.destroy()); + clientSocket.once("error", () => upstream.destroy()); + }); + return listen(server); +} + +async function createRejectingProxy(): Promise { + const server = http.createServer(); + server.on("connect", (_request, socket) => socket.end("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n")); + return listen(server); +} + +async function createTempDirectory(): Promise { + const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-proxy-test-")); + cleanups.push(() => fs.promises.rm(directory, { recursive: true, force: true })); + return directory; +} + +describe("proxy segmented download", () => { + it("accepts authenticated premium proxy list formats without exposing entries", () => { + const secret = "private-password"; + const count = parseProxyList([ + `user:${secret}@127.0.0.1:8080`, + "http://name:password@example.test:3128", + "127.0.0.1:9000:name:password", + "invalid", + "# ignored" + ].join("\n")); + + expect(count).toBe(3); + expect(String(count)).not.toContain(secret); + }); + + it("downloads exact byte segments through different authenticated proxies", async () => { + const content = Buffer.allocUnsafe(256 * 1024); + for (let index = 0; index < content.length; index += 1) content[index] = index % 251; + const targetServer = await createRangeServer(content); + const proxyCounters = Array.from({ length: 4 }, () => ({ value: 0 })); + const proxies = await Promise.all(proxyCounters.map((counter, index) => createConnectProxy(`user${index}`, `pass${index}`, counter))); + const directory = await createTempDirectory(); + const proxyFile = path.join(directory, "proxies.txt"); + const targetFile = path.join(directory, "archive.bin"); + await fs.promises.writeFile( + proxyFile, + proxies.map((proxy, index) => `user${index}:pass${index}@127.0.0.1:${proxy.port}`).join("\n"), + "utf8" + ); + const progress: number[] = []; + let trafficBytes = 0; + + const result = await downloadWithProxySegments({ + directUrl: `http://127.0.0.1:${targetServer.port}/archive.bin`, + targetPath: targetFile, + proxyListPath: proxyFile, + connections: 4, + signal: new AbortController().signal, + minSegmentBytes: 1, + onTrafficBytes: (bytes) => { trafficBytes += bytes; }, + onProgress: (_delta, downloaded) => progress.push(downloaded) + }); + + expect(result).toEqual({ status: "completed", totalBytes: content.length, connections: 4 }); + expect(await fs.promises.readFile(targetFile)).toEqual(content); + expect(proxyCounters.every((counter) => counter.value > 0)).toBe(true); + expect(targetServer.ranges).toContain("bytes=0-1"); + expect(new Set(targetServer.ranges.filter((range) => range !== "bytes=0-1")).size).toBe(4); + expect(progress.at(-1)).toBe(content.length); + expect(trafficBytes).toBe(content.length + 2); + expect((await fs.promises.readdir(directory)).filter((name) => name.includes(".proxy-")).length).toBe(0); + }); + + it("uses another proxy after a rejected connection", async () => { + const content = Buffer.alloc(64 * 1024, 7); + const targetServer = await createRangeServer(content); + const rejected = await createRejectingProxy(); + const counters = Array.from({ length: 3 }, () => ({ value: 0 })); + const working = await Promise.all(counters.map((counter, index) => createConnectProxy(`ok${index}`, `secret${index}`, counter))); + const directory = await createTempDirectory(); + const proxyFile = path.join(directory, "proxies.txt"); + const targetFile = path.join(directory, "retry.bin"); + await fs.promises.writeFile(proxyFile, [ + `bad:bad@127.0.0.1:${rejected.port}`, + ...working.map((proxy, index) => `ok${index}:secret${index}@127.0.0.1:${proxy.port}`) + ].join("\n")); + + const result = await downloadWithProxySegments({ + directUrl: `http://127.0.0.1:${targetServer.port}/retry.bin`, + targetPath: targetFile, + proxyListPath: proxyFile, + connections: 2, + signal: new AbortController().signal, + minSegmentBytes: 1 + }); + + expect(result.status).toBe("completed"); + expect(await fs.promises.readFile(targetFile)).toEqual(content); + expect(counters.some((counter) => counter.value > 0)).toBe(true); + }); + + it("falls back cleanly when the origin ignores byte ranges", async () => { + const content = Buffer.alloc(4096, 3); + const targetServer = await createRangeServer(content, false); + const counter = { value: 0 }; + const proxy = await createConnectProxy("user", "password", counter); + const directory = await createTempDirectory(); + const proxyFile = path.join(directory, "proxies.txt"); + const targetFile = path.join(directory, "fallback.bin"); + await fs.promises.writeFile(proxyFile, `user:password@127.0.0.1:${proxy.port}`); + + const result = await downloadWithProxySegments({ + directUrl: `http://127.0.0.1:${targetServer.port}/fallback.bin`, + targetPath: targetFile, + proxyListPath: proxyFile, + connections: 4, + signal: new AbortController().signal, + minSegmentBytes: 1 + }); + + expect(result).toEqual({ status: "fallback", reason: "range_unsupported" }); + expect(fs.existsSync(targetFile)).toBe(false); + expect((await fs.promises.readdir(directory)).filter((name) => name.includes(".proxy-")).length).toBe(0); + }); + + it("cancels all segments and removes temporary data", async () => { + const content = Buffer.alloc(256 * 1024, 5); + const server = http.createServer((request, response) => { + const match = /^bytes=(\d+)-(\d+)$/.exec(String(request.headers.range || "")); + if (!match) { + response.writeHead(416); + response.end(); + return; + } + const start = Number(match[1]); + const end = Number(match[2]); + const body = content.subarray(start, end + 1); + response.writeHead(206, { + "Content-Range": `bytes ${start}-${end}/${content.length}`, + "Content-Length": body.length + }); + if (start === 0 && end === 1) { + response.end(body); + return; + } + let offset = 0; + const timer = setInterval(() => { + if (offset >= body.length) { + clearInterval(timer); + response.end(); + return; + } + const next = Math.min(body.length, offset + 1024); + response.write(body.subarray(offset, next)); + offset = next; + }, 5); + response.once("close", () => clearInterval(timer)); + }); + const targetServer = await listen(server); + const counters = Array.from({ length: 2 }, () => ({ value: 0 })); + const proxies = await Promise.all(counters.map((counter, index) => createConnectProxy(`cancel${index}`, `pass${index}`, counter))); + const directory = await createTempDirectory(); + const proxyFile = path.join(directory, "proxies.txt"); + const targetFile = path.join(directory, "cancel.bin"); + await fs.promises.writeFile( + proxyFile, + proxies.map((proxy, index) => `cancel${index}:pass${index}@127.0.0.1:${proxy.port}`).join("\n") + ); + const controller = new AbortController(); + const progress: number[] = []; + + const download = downloadWithProxySegments({ + directUrl: `http://127.0.0.1:${targetServer.port}/cancel.bin`, + targetPath: targetFile, + proxyListPath: proxyFile, + connections: 2, + signal: controller.signal, + minSegmentBytes: 1, + onProgress: (_delta, downloaded) => { + progress.push(downloaded); + if (downloaded >= 4096) controller.abort("test_cancel"); + } + }); + + await expect(download).rejects.toThrow("aborted:proxy_download"); + expect(progress.at(-1)).toBe(0); + expect(fs.existsSync(targetFile)).toBe(false); + expect((await fs.promises.readdir(directory)).filter((name) => name.includes(".proxy-")).length).toBe(0); + }); +}); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 1decdce..67c3338 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -642,6 +642,43 @@ describe("settings views", () => { }); }); + it("projects proxy segmentation controls with safe defaults and connection totals", () => { + const form = buildSettingsFormViewModel({ + settings: { + ...createRendererSettings({ + ...defaultSettings(), + maxParallel: 5, + proxyDownloadEnabled: true, + proxyListPath: "C:\\proxy-list.txt", + proxyConnectionsPerDownload: 16 + }), + archivePasswordList: "", + notifyUrl: "" + }, + section: "speed", + speedLimitInput: "0", + scheduleSpeedInputs: {} + }); + const proxyGroup = form.groups.find((group) => group.id === "speed-proxy"); + + expect(proxyGroup?.fields.map((field) => field.id)).toEqual([ + "proxyDownloadEnabled", + "proxyListPath", + "proxyConnectionsPerDownload" + ]); + expect(proxyGroup?.fields.find((field) => field.id === "proxyListPath")).toEqual(expect.objectContaining({ + value: "C:\\proxy-list.txt", + actionLabel: "Datei wählen", + disabled: false + })); + expect(proxyGroup?.fields.find((field) => field.id === "proxyConnectionsPerDownload")).toEqual(expect.objectContaining({ + value: "16", + min: 2, + max: 32, + help: expect.stringContaining("80 Proxy-Verbindungen") + })); + }); + it("projects every notification control in the required order with exact bounds", () => { const form = buildSettingsFormViewModel({ settings: { diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 3b4f4dc..f417a11 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -579,9 +579,12 @@ describe("settings storage", () => { completedCleanupPolicy: "broken" as unknown as AppSettings["completedCleanupPolicy"], speedLimitMode: "broken" as unknown as AppSettings["speedLimitMode"], maxParallel: 0, - retryLimit: 999, - reconnectWaitSeconds: 9999, - speedLimitKbps: -1, + retryLimit: 999, + reconnectWaitSeconds: 9999, + speedLimitKbps: -1, + proxyDownloadEnabled: true, + proxyListPath: " C:\\proxies.txt ", + proxyConnectionsPerDownload: 999, outputDir: " ", extractDir: " ", mkvLibraryDir: " ", @@ -598,7 +601,10 @@ describe("settings storage", () => { expect(normalized.maxParallel).toBe(1); expect(normalized.retryLimit).toBe(99); expect(normalized.reconnectWaitSeconds).toBe(600); - expect(normalized.speedLimitKbps).toBe(0); + expect(normalized.speedLimitKbps).toBe(0); + expect(normalized.proxyDownloadEnabled).toBe(true); + expect(normalized.proxyListPath).toBe("C:\\proxies.txt"); + expect(normalized.proxyConnectionsPerDownload).toBe(32); expect(normalized.outputDir).toBe(defaultSettings().outputDir); expect(normalized.extractDir).toBe(defaultSettings().extractDir); expect(normalized.mkvLibraryDir).toBe(defaultSettings().mkvLibraryDir); @@ -629,8 +635,11 @@ describe("settings storage", () => { completedCleanupPolicy: "not-valid", maxParallel: "999", retryLimit: "-3", - reconnectWaitSeconds: "1", - speedLimitMode: "not-valid", + reconnectWaitSeconds: "1", + speedLimitMode: "not-valid", + proxyDownloadEnabled: 1, + proxyListPath: " C:\\proxy-list.txt ", + proxyConnectionsPerDownload: "1", updateRepo: "", autoSortPackagesByProgress: false }), @@ -643,7 +652,10 @@ describe("settings storage", () => { expect(loaded.maxParallel).toBe(50); expect(loaded.retryLimit).toBe(0); expect(loaded.reconnectWaitSeconds).toBe(10); - expect(loaded.speedLimitMode).toBe("global"); + expect(loaded.speedLimitMode).toBe("global"); + expect(loaded.proxyDownloadEnabled).toBe(true); + expect(loaded.proxyListPath).toBe("C:\\proxy-list.txt"); + expect(loaded.proxyConnectionsPerDownload).toBe(2); expect(loaded.updateRepo).toBe(defaultSettings().updateRepo); expect(loaded.autoSortPackagesByProgress).toBe(false); }); diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 2f48618..07bd55f 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -114,9 +114,12 @@ function createSettings(): AppSettings { maxParallel: 4, maxParallelExtract: 2, retryLimit: 3, - speedLimitEnabled: false, - speedLimitKbps: 0, - speedLimitMode: "global", + speedLimitEnabled: false, + speedLimitKbps: 0, + speedLimitMode: "global", + proxyDownloadEnabled: false, + proxyListPath: "", + proxyConnectionsPerDownload: 16, updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader", autoUpdateCheck: true, clipboardWatch: true, diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index 9b3ef0a..9645da9 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -168,6 +168,7 @@ export function createVisualElectronApi( }, writeClipboardText: async () => true, pickFolder: async () => "C:\\Visual\\Selected", + pickProxyList: async () => "C:\\Visual\\proxy-list.txt", pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"], getSessionStats: async () => ({ bandwidth: {