diff --git a/CHANGELOG.md b/CHANGELOG.md index b45185e..4e7679f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.29] - 2026-08-13 + +### Account and rotation reliability + +- Applied rapid account enable and disable changes immediately, persisted every click in order, and reconciled the final state without losing fast consecutive changes. +- Refreshed Mega-Debrid API and Web account pools during active downloads without requiring an application restart. +- Isolated account attempt cancellation and timeouts so a failed or stalled account can rotate to the next enabled account within the same link conversion. +- Cleared stale account cooldowns, provider failures, API token work, and Web sessions when relevant credentials or enabled accounts change. +- Added explicit TEST, OK, and FAILED account-rotation evidence with masked account identities to diagnostics and support bundles. + +### Download lifecycle + +- Kept Pause and Stop responsive while other interface actions are running and canceled active requests cleanly when pausing. +- Recovered persisted partial downloads by renewing expiring direct links and falling back to one clean full request after repeated range rejection. +- Preserved known online availability when resetting packages or selected files. +- Kept every start and resume path blocked when no usable download account is active. +- Reduced active session, speed, sparkline, and bandwidth updates to a stable 500 ms interface cadence. + +### Diagnostics and desktop integration + +- Routed copy actions through Electron's native clipboard for reliable operation in hardened and Remote Desktop sessions. +- Made support bundle creation visibly active from the save dialog through the final write, prevented duplicate exports, and saved completed bundles atomically. +- Bounded support bundle queue data and recent log collection, kept the interface responsive during creation, and strengthened redaction for credentials, URLs, local paths, and account identifiers. + ## [2.0.28] - 2026-08-12 ### Startup reliability diff --git a/package-lock.json b/package-lock.json index f395226..23d6ce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "2.0.28", + "version": "2.0.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "2.0.28", + "version": "2.0.29", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 2459a4a..3074999 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "2.0.28", + "version": "2.0.29", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 45d8ba9..0b6ea94 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -859,19 +859,38 @@ public async checkDebridAccounts(): Promise { return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" }; } - public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> { - this.audit("INFO", "Support-Bundle exportiert"); - logTraceEvent("INFO", "support", "Support-Bundle erstellt", { - packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, - itemCount: Object.keys(this.manager.getSnapshot().session.items).length - }); - return { - buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }), - defaultFileName: getSupportBundleDefaultFileName() - }; - } - - public getSupportBundleDefaultFileName(): string { + public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> { + const buffer = await buildSupportBundle(this.manager, this.storagePaths.baseDir, { + hostDiagnosticsMode: "cached", + debugSetupMode: "deferred" + }); + return { + buffer, + defaultFileName: getSupportBundleDefaultFileName() + }; + } + + public recordSupportBundleExported(filePath: string, bytes: number): void { + const snapshot = this.manager.getSnapshot(); + const fields = { + fileName: path.basename(filePath), + bytes, + packageCount: Object.keys(snapshot.session.packages).length, + itemCount: Object.keys(snapshot.session.items).length + }; + this.audit("INFO", "Support-Bundle exportiert", fields); + logTraceEvent("INFO", "support", "Support-Bundle exportiert", fields); + } + + public recordSupportBundleExportFailed(error: unknown): void { + const fields = { + error: error instanceof Error ? error.message : String(error) + }; + this.audit("ERROR", "Support-Bundle-Export fehlgeschlagen", fields); + logTraceEvent("ERROR", "support", "Support-Bundle-Export fehlgeschlagen", fields); + } + + public getSupportBundleDefaultFileName(): string { return getSupportBundleDefaultFileName(); } diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 57ee98d..cfa183a 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -288,16 +288,17 @@ function getDebridLinkKeyHostCooldownState( type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip"; type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean }; const megaDebridAccountCooldowns = new Map(); -const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; -const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000; -const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000; +const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; +const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000; +const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000; +const MEGA_DEBRID_ACCOUNT_ATTEMPT_TIMEOUT_MS_DEFAULT = 120_000; // A Mega-Web account abort (the shared unrestrict timeout firing while this // account ran) only cools the account down — so the next attempt rotates on — // if it actually ran this long. Below this, it's treated as a quick user-cancel // (no cooldown). Env-overridable for tests. const MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT = 8000; -function getMegaDebridAbortMinRunMs(): number { +function getMegaDebridAbortMinRunMs(): number { const fromEnv = Number(process.env.RD_MEGA_ABORT_MIN_RUN_MS ?? NaN); return Number.isFinite(fromEnv) && fromEnv >= 0 ? Math.floor(fromEnv) : MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT; } @@ -328,9 +329,23 @@ export function recordMegaDebridEmptyResponseStreak(accountId: string): number { return streak; } -export function clearMegaDebridEmptyResponseStreak(accountId: string): void { - megaDebridEmptyResponseStreaks.delete(accountId); -} +export function clearMegaDebridEmptyResponseStreak(accountId: string): void { + megaDebridEmptyResponseStreaks.delete(accountId); +} + +export function getMegaDebridAccountAttemptTimeoutMs(): number { + const fromEnv = Number(process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS ?? NaN); + return Number.isFinite(fromEnv) && fromEnv >= 10 && fromEnv <= 10 * 60 * 1000 + ? Math.floor(fromEnv) + : MEGA_DEBRID_ACCOUNT_ATTEMPT_TIMEOUT_MS_DEFAULT; +} + +export function clearMegaDebridAccountRuntimeStates(accountKeys: Iterable): void { + for (const accountKey of accountKeys) { + megaDebridAccountCooldowns.delete(accountKey); + megaDebridEmptyResponseStreaks.delete(accountKey); + } +} export function resetMegaDebridRuntimeStateForTests(): void { megaDebridAccountCooldowns.clear(); @@ -720,7 +735,7 @@ function isRetryableErrorText(text: string): boolean { || lower.includes("html statt json"); } -async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { +async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { if (!signal) { await sleep(ms); return; @@ -745,8 +760,34 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise }; signal.addEventListener("abort", onAbort, { once: true }); - }); -} + }); +} + +function waitForPromiseWithSignal(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) { + return promise; + } + if (signal.aborted) { + return Promise.reject(new Error("aborted:debrid")); + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener("abort", onAbort); + reject(new Error("aborted:debrid")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + void promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + } + ); + }); +} function asRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -1797,7 +1838,7 @@ function buildBestDebridRequests(link: string, token: string): BestDebridRequest ]; } -class MegaDebridClient { +class MegaDebridClient { private megaWebUnrestrict?: MegaWebUnrestrictor; private login: string; @@ -1808,31 +1849,47 @@ class MegaDebridClient { private allowApiFallback: boolean; - private static cachedApiTokens = new Map(); + private static cachedApiTokens = new Map(); + + private static pendingConnects = new Map }>(); + + private static credentialGenerations = new Map(); + + private static getCredentialGeneration(key: string): number { + return MegaDebridClient.credentialGenerations.get(key) ?? 0; + } + + private static invalidateCredential(key: string): void { + MegaDebridClient.credentialGenerations.set(key, MegaDebridClient.getCredentialGeneration(key) + 1); + MegaDebridClient.cachedApiTokens.delete(key); + MegaDebridClient.pendingConnects.delete(key); + } + + private static invalidateCredentialIfCurrent(key: string, generation: number): void { + if (MegaDebridClient.getCredentialGeneration(key) === generation) { + MegaDebridClient.invalidateCredential(key); + } + } - private static pendingConnects = new Map>(); - - public static pruneCachedTokensNotIn(activeLogins: Iterable): void { - const keep = new Set(); + public static pruneCachedTokensNotIn(activeLogins: Iterable): void { + const keep = new Set(); for (const login of activeLogins) { keep.add(String(login || "").toLowerCase()); } - for (const login of MegaDebridClient.cachedApiTokens.keys()) { - if (!keep.has(login)) { - MegaDebridClient.cachedApiTokens.delete(login); - } - } - for (const login of MegaDebridClient.pendingConnects.keys()) { - if (!keep.has(login)) { - MegaDebridClient.pendingConnects.delete(login); - } - } - } + const knownLogins = new Set([ + ...MegaDebridClient.cachedApiTokens.keys(), + ...MegaDebridClient.pendingConnects.keys() + ]); + for (const login of knownLogins) { + if (!keep.has(login)) { + MegaDebridClient.invalidateCredential(login); + } + } + } - public static clearCachedApiToken(login: string): void { - const key = String(login || "").toLowerCase(); - MegaDebridClient.cachedApiTokens.delete(key); - MegaDebridClient.pendingConnects.delete(key); + public static clearCachedApiToken(login: string): void { + const key = String(login || "").toLowerCase(); + MegaDebridClient.invalidateCredential(key); } public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) { @@ -1847,60 +1904,69 @@ class MegaDebridClient { return this.login.trim().toLowerCase(); } - private async connectApi(signal?: AbortSignal): Promise { - const key = this.cacheKey; - const cached = MegaDebridClient.cachedApiTokens.get(key); - if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) { - traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" }); - return cached.token; - } - - const pending = MegaDebridClient.pendingConnects.get(key); - if (pending) { - traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" }); - return pending; - } - - const promise = this.doConnectApi(signal).finally(() => { - MegaDebridClient.pendingConnects.delete(key); - }); - MegaDebridClient.pendingConnects.set(key, promise); - return promise; + private async connectApi(signal?: AbortSignal): Promise { + const key = this.cacheKey; + const generation = MegaDebridClient.getCredentialGeneration(key); + const cached = MegaDebridClient.cachedApiTokens.get(key); + if (cached && cached.generation === generation && cached.token && Date.now() - cached.at < 20 * 60 * 1000) { + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" }); + return waitForPromiseWithSignal(Promise.resolve(cached.token), signal); + } + + const pending = MegaDebridClient.pendingConnects.get(key); + if (pending && pending.generation === generation) { + traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" }); + return waitForPromiseWithSignal(pending.promise, signal); + } + + const promise = this.doConnectApi(key, generation); + const entry = { generation, promise }; + MegaDebridClient.pendingConnects.set(key, entry); + const clearPending = (): void => { + if (MegaDebridClient.pendingConnects.get(key) === entry) { + MegaDebridClient.pendingConnects.delete(key); + } + }; + void promise.then(clearPending, clearPending); + return waitForPromiseWithSignal(promise, signal); } private clearTokenCache(): void { MegaDebridClient.cachedApiTokens.delete(this.cacheKey); } - private async doConnectApi(signal?: AbortSignal): Promise { - const connectStartedAt = Date.now(); - const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`; - const response = await fetch(url, { - headers: { "User-Agent": DEBRID_USER_AGENT }, - signal: withTimeoutSignal(signal, API_TIMEOUT_MS) - }); - const text = await response.text(); - if (!response.ok) { - if (response.status === 401 || response.status === 403) { - this.clearTokenCache(); - } + private async doConnectApi(cacheKey: string, generation: number): Promise { + const connectStartedAt = Date.now(); + const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`; + const response = await fetch(url, { + headers: { "User-Agent": DEBRID_USER_AGENT }, + signal: AbortSignal.timeout(API_TIMEOUT_MS) + }); + const text = await response.text(); + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation); + } traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` }); return null; } - const payload = parseJsonSafe(text); - if (!payload || payload.response_code !== "ok") { - if (payload && String(payload.response_code || "").toLowerCase().includes("token")) { - this.clearTokenCache(); + const payload = parseJsonSafe(text); + if (!payload || payload.response_code !== "ok") { + if (payload && String(payload.response_code || "").toLowerCase().includes("token")) { + MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation); } traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() }); return null; } const token = String(payload.token || "").trim(); - if (!token) { + if (!token) { traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" }); - return null; - } - MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() }); + return null; + } + if (MegaDebridClient.getCredentialGeneration(cacheKey) !== generation) { + return null; + } + MegaDebridClient.cachedApiTokens.set(cacheKey, { token, at: Date.now(), generation }); traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "ok" }); return token; } @@ -2121,11 +2187,15 @@ class MegaDebridClient { }); const testStartedAt = Date.now(); - usableAccountSeen = true; - megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1); - try { - const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict); - const result = await client.unrestrictLink(link, signal); + usableAccountSeen = true; + megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1); + const accountAttemptTimeoutSignal = AbortSignal.timeout(getMegaDebridAccountAttemptTimeoutMs()); + const accountAttemptSignal = signal + ? AbortSignal.any([signal, accountAttemptTimeoutSignal]) + : accountAttemptTimeoutSignal; + try { + const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict); + const result = await client.unrestrictLink(link, accountAttemptSignal); clearMegaDebridAccountCooldownState(cooldownKey); clearMegaDebridEmptyResponseStreak(cooldownKey); const elapsedMs = Date.now() - testStartedAt; @@ -2149,9 +2219,12 @@ class MegaDebridClient { sourceAccountId: account.id, sourceAccountLabel: account.label }; - } catch (error) { - const elapsedMs = Date.now() - testStartedAt; - const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); + } catch (error) { + const elapsedMs = Date.now() - testStartedAt; + const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); + if (signal?.aborted) { + throw error; + } // Timeout/abort on THIS account (the shared unrestrict timeout fired). The // account-wide cooldown exists ONLY to make the retry rotate to another // account — so it is set only when another usable account actually exists. @@ -2160,8 +2233,9 @@ class MegaDebridClient { // a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so // we park just this link (mega_debrid_slow_link) and leave the account free // for other items. A quick user-cancel (below the min run) parks nothing. - if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { - const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); + if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { + const accountAttemptTimedOut = accountAttemptTimeoutSignal.aborted; + const ranLongEnough = accountAttemptTimedOut || elapsedMs >= getMegaDebridAbortMinRunMs(); const otherUsableAccounts = orderedEntries.reduce((count, candidate) => { if (candidate.account.id === account.id) { return count; @@ -2190,15 +2264,18 @@ class MegaDebridClient { detail: `${abortText}${rotateToAnotherAccount ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ranLongEnough ? ` slowlink=${Math.ceil(MEGA_DEBRID_SLOW_LINK_RETRY_MS / 1000)}s` : ""}` }); failures.push(`Mega-Debrid${accountLabel}: ${abortText}`); - logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", { - elapsedMs, - reason: abortText, - cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0, - next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)" - }); - if (ranLongEnough && !rotateToAnotherAccount) { - throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`); - } + logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", { + elapsedMs, + reason: abortText, + cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0, + next: rotateToAnotherAccount ? "naechster Account im selben Versuch" : "Einzel-Retry (Account bleibt fuer andere Items frei)" + }); + if (rotateToAnotherAccount) { + continue; + } + if (ranLongEnough && !rotateToAnotherAccount) { + throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`); + } throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`); } const failure = MegaDebridClient.classifyAccountFailure(error); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 4b2b755..a62626b 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -54,7 +54,7 @@ function releaseTlsSkip(): void { } import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; -import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; +import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, clearMegaDebridAccountRuntimeStates, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountAttemptTimeoutMs, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid"; import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor"; import { validateFileAgainstManifest } from "./integrity"; import { classifyDiskError } from "./fs-error"; @@ -78,7 +78,7 @@ type ActiveTask = { itemId: string; packageId: string; abortController: AbortController; - abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "reset" | "none"; + abortReason: "stop" | "pause" | "cancel" | "reconnect" | "package_toggle" | "settings_refresh" | "stall" | "shutdown" | "reset" | "none"; resumable: boolean; nonResumableCounted: boolean; freshRetryUsed?: boolean; @@ -122,7 +122,11 @@ const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 10000; const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000; -const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 60000; +const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 60000; + +const MAX_UNRESTRICT_TIMEOUT_MS = 2_147_483_647; + +const UNRESTRICT_TIMEOUT_OVERHEAD_MS = 15_000; const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000; @@ -342,7 +346,7 @@ function getPostExtractTimeoutMs(): number { return DEFAULT_POST_EXTRACT_TIMEOUT_MS; } -function getUnrestrictTimeoutMs(): number { +function getUnrestrictTimeoutMs(): number { const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN); if (Number.isFinite(fromEnv) && fromEnv >= 5000 && fromEnv <= 15 * 60 * 1000) { return Math.floor(fromEnv); @@ -476,6 +480,31 @@ function isArchiveLikePath(filePath: string): boolean { function extractHosterKey(link: string): string { return extractHosterFromUrl(link); } + +export function getUnrestrictTimeoutMsForProviderPlan(settings: AppSettings, providerPlan: readonly DebridProvider[]): number { + const baseTimeoutMs = getUnrestrictTimeoutMs(); + const megaModes = new Set<"api" | "web">(); + for (const provider of providerPlan) { + const effectiveProvider = resolveMegaDebridProvider(settings, provider); + if (effectiveProvider === "megadebrid-api") { + megaModes.add("api"); + } else if (effectiveProvider === "megadebrid-web") { + megaModes.add("web"); + } + } + if (megaModes.size === 0) { + return baseTimeoutMs; + } + let accountCount = 0; + for (const mode of megaModes) { + accountCount += getAvailableMegaDebridAccounts(settings, mode).length; + } + if (accountCount === 0) { + return baseTimeoutMs; + } + const calculatedTimeoutMs = baseTimeoutMs + UNRESTRICT_TIMEOUT_OVERHEAD_MS + accountCount * getMegaDebridAccountAttemptTimeoutMs(); + return Math.min(MAX_UNRESTRICT_TIMEOUT_MS, Math.max(baseTimeoutMs, calculatedTimeoutMs)); +} function isLargeBinaryLikePath(filePath: string): boolean { const lower = path.basename(String(filePath || "")).toLowerCase(); @@ -552,9 +581,14 @@ function shouldPreflightFinalizeItemFromDisk(item: DownloadItem): boolean { || text.includes("server ignorierte range"); } -function isResumeHardResetReason(errorText: string): boolean { - const text = String(errorText || ""); - return text.startsWith("resume_download_underflow:"); +export function isResumeHardResetReason(errorText: string, renewedLinkFailures = 0): boolean { + const text = String(errorText || "").toLowerCase(); + return text.startsWith("resume_download_underflow:") + || (renewedLinkFailures > 0 && ( + text.startsWith("range_ignored_on_resume:") + || text.startsWith("range_mismatch_on_resume:") + || text.includes("server ignorierte range") + )); } function isRealDebridProvider(provider: string | null | undefined): boolean { @@ -833,7 +867,7 @@ function providerLabel(provider: DownloadItem["provider"]): string { return "Debrid"; } -function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider | null): DebridProvider | null { +function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider | null): DebridProvider | null { if (provider !== "megadebrid") { return provider; } @@ -1701,6 +1735,15 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number { return Math.floor(jitter); } +function isMegaDebridProviderKey(value: string): boolean { + return value === "megadebrid" + || value === "megadebrid-api" + || value === "megadebrid-web" + || value.startsWith("megadebrid:") + || value.startsWith("megadebrid-api:") + || value.startsWith("megadebrid-web:"); +} + export function getDiskWriteWaitReason(error: unknown): string | null { const text = compactErrorText(error).replace(/^Error:\s*/i, ""); const marked = text.match(/^disk_write_wait:(.+)$/i); @@ -2194,25 +2237,31 @@ export class DownloadManager extends EventEmitter { public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void { const previous = this.settings; - const previousMegaPool = (["api", "web"] as const) - .flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => `${mode}:${account.id}:${account.password}`)) + const previousMegaAccounts = (["api", "web"] as const) + .flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => ({ mode, account }))); + const previousMegaPoolEntries = new Map(previousMegaAccounts.map(({ mode, account }) => [`${account.id}:${mode}`, account.password])); + const previousMegaPool = [...previousMegaPoolEntries] + .map(([key, password]) => `${key}:${password}`) .sort() .join("\n"); const nextMegaAccounts = (["api", "web"] as const) .flatMap((mode) => getAvailableMegaDebridAccounts(next, mode)); - const nextMegaPool = (["api", "web"] as const) - .flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => `${mode}:${account.id}:${account.password}`)) + const nextMegaPoolEntries = new Map((["api", "web"] as const) + .flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => [`${account.id}:${mode}`, account.password] as const))); + const nextMegaPool = [...nextMegaPoolEntries] + .map(([key, password]) => `${key}:${password}`) .sort() .join("\n"); - const previousMegaWebCredentials = getMegaDebridAccountsForMode(previous, "web") + const previousMegaWebPool = getAvailableMegaDebridAccounts(previous, "web") .map((account) => `${account.id}:${account.password}`) .sort() .join("\n"); - const nextMegaWebCredentials = getMegaDebridAccountsForMode(next, "web") + const nextMegaWebPool = getAvailableMegaDebridAccounts(next, "web") .map((account) => `${account.id}:${account.password}`) .sort() .join("\n"); - const megaPoolChanged = previousMegaPool !== nextMegaPool && nextMegaAccounts.length > 0; + const megaPoolChanged = previousMegaPool !== nextMegaPool; + const megaWebPoolChanged = previousMegaWebPool !== nextMegaWebPool; next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0); next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); const now = nowMs(); @@ -2222,7 +2271,7 @@ export class DownloadManager extends EventEmitter { this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0; this.runtimePersistedAt = now; this.ensureProviderDailyUsageFresh(nowMs()); - if (previousMegaWebCredentials !== nextMegaWebCredentials) { + if (megaWebPoolChanged) { this.invalidateMegaSessionFn?.(); } this.debridService.setSettings(next); @@ -2280,7 +2329,34 @@ export class DownloadManager extends EventEmitter { logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`); } - if (!opts?.settingsOnlyImport && megaPoolChanged) { + if (megaPoolChanged) { + const changedAccountKeys = new Set(); + for (const key of new Set([...previousMegaPoolEntries.keys(), ...nextMegaPoolEntries.keys()])) { + if (previousMegaPoolEntries.get(key) !== nextMegaPoolEntries.get(key)) { + changedAccountKeys.add(key); + } + } + clearMegaDebridAccountRuntimeStates(changedAccountKeys); + for (const key of [...this.providerFailures.keys()]) { + if (isMegaDebridProviderKey(key)) { + this.providerFailures.delete(key); + } + } + for (const active of this.activeTasks.values()) { + const item = this.session.items[active.itemId]; + if (!item || item.status !== "validating") { + continue; + } + const provider = String(item.provider || this.getExpectedProviderForItem(item) || ""); + if (provider !== "megadebrid" && provider !== "megadebrid-api" && provider !== "megadebrid-web") { + continue; + } + active.abortReason = "settings_refresh"; + active.abortController.abort("settings_refresh"); + } + } + + if (!opts?.settingsOnlyImport && megaPoolChanged && nextMegaAccounts.length > 0) { this.releaseMegaDebridResetParks(); } @@ -5372,8 +5448,7 @@ export class DownloadManager extends EventEmitter { item.targetPath = ""; item.provider = null; item.fullStatus = "Wartet"; - item.onlineStatus = undefined; - item.updatedAt = nowMs(); + item.updatedAt = nowMs(); } const postProcessTasks = this.abortPackagePostProcessing(packageId, "reset"); @@ -5456,8 +5531,7 @@ export class DownloadManager extends EventEmitter { item.targetPath = ""; item.provider = null; item.fullStatus = "Wartet"; - item.onlineStatus = undefined; - item.updatedAt = nowMs(); + item.updatedAt = nowMs(); if (this.session.running) { this.runItemIds.add(itemId); @@ -5923,7 +5997,7 @@ export class DownloadManager extends EventEmitter { }); } - public stop(options?: { parkForRestart?: boolean }): void { + public stop(options?: { parkForRestart?: boolean }): void { const parkForRestart = options?.parkForRestart === true; const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; const keepExtraction = this.settings.autoExtractWhenStopped; @@ -5936,7 +6010,13 @@ export class DownloadManager extends EventEmitter { this.retryAfterByItem.clear(); this.providerStartReservations.clear(); this.pacedStartReservationByItem.clear(); - this.retryStateByItem.clear(); + this.retryStateByItem.clear(); + this.invalidateMegaSessionFn?.(); + for (const key of [...this.providerFailures.keys()]) { + if (isMegaDebridProviderKey(key)) { + this.providerFailures.delete(key); + } + } this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); this.speedEvents = []; @@ -6086,11 +6166,16 @@ export class DownloadManager extends EventEmitter { } this.session.paused = !this.session.paused; - if (!wasPaused && this.session.paused) { - this.speedEvents = []; - this.speedBytesLastWindow = 0; - this.speedBytesPerPackage.clear(); - this.speedEventsHead = 0; + if (!wasPaused && this.session.paused) { + this.speedEvents = []; + this.speedBytesLastWindow = 0; + this.speedBytesPerPackage.clear(); + this.speedEventsHead = 0; + this.invalidateMegaSessionFn?.(); + for (const active of this.activeTasks.values()) { + active.abortReason = "pause"; + active.abortController.abort("pause"); + } } if (wasPaused && !this.session.paused) { @@ -8177,7 +8262,7 @@ export class DownloadManager extends EventEmitter { } } - private getProviderOrder(): DebridProvider[] { + private getProviderOrder(): DebridProvider[] { if (this.settings.providerOrder && this.settings.providerOrder.length > 0) { return [...this.settings.providerOrder]; } @@ -8185,8 +8270,36 @@ export class DownloadManager extends EventEmitter { this.settings.providerPrimary, this.settings.providerSecondary !== "none" ? this.settings.providerSecondary : null, this.settings.providerTertiary !== "none" ? this.settings.providerTertiary : null - ].filter(Boolean) as DebridProvider[]; - } + ].filter(Boolean) as DebridProvider[]; + } + + private getReachableUnrestrictProviderPlan(item: DownloadItem, preferredLeadProvider: DebridProvider | null): DebridProvider[] { + const baseOrder = this.getProviderOrder(); + const order = preferredLeadProvider && baseOrder.includes(preferredLeadProvider) + ? [preferredLeadProvider, ...baseOrder.filter((provider) => provider !== preferredLeadProvider)] + : baseOrder; + const candidates: DebridProvider[] = []; + const hosterKey = extractHosterKey(item.url); + const routedProvider = hosterKey ? (this.settings.hosterRouting || {})[hosterKey] : undefined; + if (routedProvider && this.isProviderConfigured(routedProvider)) { + candidates.push(routedProvider); + if (!this.settings.autoProviderFallback) { + return [resolveMegaDebridProvider(this.settings, routedProvider) || routedProvider]; + } + } + const configuredOrder = order.filter((provider) => this.isProviderConfigured(provider)); + candidates.push(...(this.settings.autoProviderFallback ? configuredOrder : configuredOrder.slice(0, 1))); + const seen = new Set(); + const providerPlan: DebridProvider[] = []; + for (const provider of candidates) { + const effectiveProvider = resolveMegaDebridProvider(this.settings, provider) || provider; + if (!seen.has(effectiveProvider)) { + seen.add(effectiveProvider); + providerPlan.push(effectiveProvider); + } + } + return providerPlan; + } private findFallbackProviderNotInCooldown(item: DownloadItem): DebridProvider | null { const hosterKey = extractHosterKey(item.url); @@ -8677,17 +8790,17 @@ export class DownloadManager extends EventEmitter { return; } - const VALIDATING_STUCK_MS = getUnrestrictTimeoutMs() + 15000; - for (const active of this.activeTasks.values()) { + for (const active of this.activeTasks.values()) { if (active.abortController.signal.aborted) { continue; } const item = this.session.items[active.itemId]; - if (!item || item.status !== "validating") { - continue; - } - const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0; - if (ageMs > VALIDATING_STUCK_MS) { + if (!item || item.status !== "validating") { + continue; + } + const validatingStuckMs = getUnrestrictTimeoutMsForProviderPlan(this.settings, this.getReachableUnrestrictProviderPlan(item, null)) + 15_000; + const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0; + if (ageMs > validatingStuckMs) { logger.warn(`Validating-Stuck erkannt: item=${item.fileName || active.itemId}, ${Math.floor(ageMs / 1000)}s ohne Fortschritt`); active.abortReason = "stall"; active.abortController.abort("stall"); @@ -9172,7 +9285,9 @@ export class DownloadManager extends EventEmitter { this.emitState(); return; } - const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs()); + const unrestrictProviderPlan = this.getReachableUnrestrictProviderPlan(item, preferredLeadProvider); + const unrestrictTimeoutMs = getUnrestrictTimeoutMsForProviderPlan(this.settings, unrestrictProviderPlan); + const unrestrictTimeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs); const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]); let unrestricted; try { @@ -9193,7 +9308,7 @@ export class DownloadManager extends EventEmitter { traceConversionPhase({ phase: "caller-timeout", outcome: "timeout", - detail: `Caller-Budget ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)` + detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)` }); } throw innerError; @@ -9203,7 +9318,7 @@ export class DownloadManager extends EventEmitter { } catch (unrestrictError) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { this.recordProviderFailure(cooldownProvider); - throw new Error(`Unrestrict Timeout nach ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s`); + throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`); } const errText = compactErrorText(unrestrictError); if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { @@ -9484,7 +9599,19 @@ export class DownloadManager extends EventEmitter { this.dropItemContribution(item.id); } this.retryStateByItem.delete(item.id); - } else if (reason === "shutdown") { + } else if (reason === "pause") { + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = "Pausiert"; + this.retryAfterByItem.delete(item.id); + this.retryStateByItem.set(item.id, { + freshRetryUsed: Boolean(active.freshRetryUsed), + resumeHardResetUsed: Boolean(active.resumeHardResetUsed), + stallRetries: Number(active.stallRetries || 0), + genericErrorRetries: Number(active.genericErrorRetries || 0), + unrestrictRetries: Number(active.unrestrictRetries || 0) + }); + } else if (reason === "shutdown") { this.logPackageForItem(item, "WARN", "Download für Shutdown geparkt", { reason }); @@ -9507,9 +9634,20 @@ export class DownloadManager extends EventEmitter { genericErrorRetries: Number(active.genericErrorRetries || 0), unrestrictRetries: Number(active.unrestrictRetries || 0) }); - } else if (reason === "reset") { - this.retryStateByItem.delete(item.id); - } else if (reason === "package_toggle") { + } else if (reason === "reset") { + this.retryStateByItem.delete(item.id); + } else if (reason === "settings_refresh") { + item.status = "queued"; + item.speedBps = 0; + item.fullStatus = "Wartet"; + item.lastError = ""; + item.provider = null; + item.providerLabel = undefined; + item.providerAccountId = undefined; + item.providerAccountLabel = undefined; + this.retryAfterByItem.delete(item.id); + this.retryStateByItem.delete(item.id); + } else if (reason === "package_toggle") { this.logPackageForItem(item, "WARN", "Download wegen Paket-Toggle pausiert", { reason }); @@ -9640,7 +9778,7 @@ export class DownloadManager extends EventEmitter { this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason); return; } - if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) { + if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) { active.resumeHardResetUsed = true; item.retries += 1; logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`); diff --git a/src/main/main.ts b/src/main/main.ts index 656db10..15bb55c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -21,6 +21,7 @@ import { createRendererSettings } from "./renderer-state"; import { validateRendererSettingsUpdate } from "./renderer-settings"; import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security"; import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; +import { createSupportBundleExportRunner, writeSupportBundleAtomically } from "./support-bundle"; function validateString(value: unknown, name: string): string { if (typeof value !== "string") { @@ -604,13 +605,20 @@ function registerIpcHandlers(): void { } return controller.importQueue(json); }); - handleTrusted(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => { + handleTrusted(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => { const settings = controller.getSettings(); const next = !settings.clipboardWatch; controller.updateSettings({ clipboardWatch: next }); updateClipboardWatcher(); - return next; - }); + return next; + }); + handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, text: unknown) => { + if (typeof text !== "string" || text.length > 16 * 1024 * 1024) { + throw new Error("Ungültiger Zwischenablageinhalt"); + } + clipboard.writeText(text); + return true; + }); handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => { const options = { properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory"> @@ -667,19 +675,22 @@ function registerIpcHandlers(): void { return controller.importOnlineBackup(key); }); - handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { - const options = { - defaultPath: controller.getSupportBundleDefaultFileName(), - filters: [{ name: "Support Bundle", extensions: ["zip"] }] - }; - const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); - if (result.canceled || !result.filePath) { - return { saved: false }; - } - const exported = await controller.exportSupportBundle(); - await fs.promises.writeFile(result.filePath, exported.buffer); - return { saved: true, filePath: result.filePath }; - }); + const runSupportBundleExport = createSupportBundleExportRunner({ + chooseFile: async () => { + const options = { + defaultPath: controller.getSupportBundleDefaultFileName(), + filters: [{ name: "Support Bundle", extensions: ["zip"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + return result.canceled || !result.filePath ? null : result.filePath; + }, + build: async () => (await controller.exportSupportBundle()).buffer, + write: writeSupportBundleAtomically, + onSuccess: ({ filePath, bytes }) => controller.recordSupportBundleExported(filePath, bytes), + onFailure: (error) => controller.recordSupportBundleExportFailed(error) + }); + + handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, () => runSupportBundleExport()); handleTrusted(IPC_CHANNELS.OPEN_LOG, async () => { const logPath = getLogFilePath(); diff --git a/src/main/mega-web-fallback.ts b/src/main/mega-web-fallback.ts index 92f06de..b808249 100644 --- a/src/main/mega-web-fallback.ts +++ b/src/main/mega-web-fallback.ts @@ -229,17 +229,23 @@ export class MegaWebFallback { private sessions = new Map(); private sessionGeneration = 0; + + private invalidationController = new AbortController(); public constructor(getCredentials: () => MegaCredentials) { this.getCredentials = getCredentials; } - public async unrestrict( + public async unrestrict( link: string, signal?: AbortSignal, account?: { login: string; password: string } - ): Promise { - const overallSignal = withTimeoutSignal(signal, 180000); + ): Promise { + const invalidationSignal = this.invalidationController.signal; + const requestSignal = signal + ? AbortSignal.any([signal, invalidationSignal]) + : invalidationSignal; + const overallSignal = withTimeoutSignal(requestSignal, 180000); const creds = (account && account.login.trim() && account.password.trim()) ? account : this.getCredentials(); @@ -251,14 +257,20 @@ export class MegaWebFallback { return this.runExclusive(async () => { throwIfAborted(overallSignal); let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration); + throwIfAborted(overallSignal); let generated = await this.generate(link, cookie, overallSignal); + throwIfAborted(overallSignal); if (!generated) { - this.sessions.delete(key); + if (sessionGeneration === this.sessionGeneration) { + this.sessions.delete(key); + } cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration); - generated = await this.generate(link, cookie, overallSignal); - if (!generated) { - return null; + throwIfAborted(overallSignal); + generated = await this.generate(link, cookie, overallSignal); + throwIfAborted(overallSignal); + if (!generated) { + return null; } } return { @@ -290,7 +302,10 @@ export class MegaWebFallback { public invalidateSession(): void { this.sessionGeneration += 1; + this.invalidationController.abort("session_invalidated"); + this.invalidationController = new AbortController(); this.sessions.clear(); + this.queues.clear(); } private async runExclusive(job: () => Promise, key: string, signal?: AbortSignal): Promise { @@ -314,11 +329,17 @@ export class MegaWebFallback { traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) }); throw jobError; } - }; - const prev = this.queues.get(key) ?? Promise.resolve(); - const run = prev.then(guardedJob, guardedJob); - this.queues.set(key, run.then(() => undefined, () => undefined)); - return raceWithAbort(run, signal, () => + }; + const prev = this.queues.get(key) ?? Promise.resolve(); + const run = prev.then(guardedJob, guardedJob); + const tail = run.then(() => undefined, () => undefined); + this.queues.set(key, tail); + void tail.finally(() => { + if (this.queues.get(key) === tail) { + this.queues.delete(key); + } + }); + return raceWithAbort(run, signal, () => workStarted ? abortError() : new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`) @@ -460,9 +481,11 @@ export class MegaWebFallback { return null; } - public dispose(): void { - this.sessions.clear(); - } + public dispose(): void { + this.invalidationController.abort("dispose"); + this.sessions.clear(); + this.queues.clear(); + } } export function compactMegaWebError(error: unknown): string { diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index e1ae524..b43adf2 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -1,87 +1,389 @@ -import { promises as fsp } from "node:fs"; -import path from "node:path"; +import { promises as fsp } from "node:fs"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; import AdmZip from "adm-zip"; import { APP_VERSION } from "./constants"; import { getAccountRotationLogPath } from "./account-rotation-log"; import { getConversionLogPath } from "./conversion-trace"; import { getAuditLogPath } from "./audit-log"; -import { getDebugSetupCheck } from "./debug-setup"; -import { getLogFilePath } from "./logger"; -import { getRecentErrors } from "./error-ring"; -import { getPackageLogPath } from "./package-log"; -import { getRenameLogPath } from "./rename-log"; +import { getDebugSetupCheck } from "./debug-setup"; +import { getLogFilePath } from "./logger"; +import { getRecentErrors } from "./error-ring"; +import { getRenameLogPath } from "./rename-log"; import { getDesktopRenameLogPath } from "./desktop-rename-log"; import { getSessionLogPath } from "./session-log"; -import { createStoragePaths, loadHistory, loadSettings } from "./storage"; -import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; +import { createStoragePaths, loadSettings } from "./storage"; +import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data"; import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log"; import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics"; -import type { DownloadManager } from "./download-manager"; - +import type { DownloadManager } from "./download-manager"; +import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types"; + const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json"; +const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000; +const MAX_TEXT_FILE_BYTES = 128 * 1024; +const MAX_RUNTIME_FILE_BYTES = 64 * 1024; +const MAX_TOTAL_TEXT_BYTES = 4 * 1024 * 1024; +const MAX_DIRECTORY_SCAN_FILES = 2_048; +const MAX_SESSION_LOG_FILES = 4; +const MAX_PACKAGE_LOG_FILES = 8; +const MAX_ITEM_LOG_FILES = 16; +const MAX_PACKAGE_DTOS = 200; +const MAX_ITEM_DTOS = 500; +const MAX_HISTORY_FILE_BYTES = 1024 * 1024; +const MAX_HISTORY_ENTRIES = 100; + +interface TextBudget { + remainingBytes: number; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function collectSensitiveValues(value: unknown, key = "", output = new Set()): Set { + if (typeof value === "string") { + if (/token|api.?key|password|passwd|secret|cookie|authorization|credential|login|username/i.test(key)) { + for (const candidate of [value, ...value.split(/\r?\n/)]) { + const trimmed = candidate.trim(); + if (trimmed.length >= 4) { + output.add(trimmed); + } + if (/credential/i.test(key)) { + const separator = trimmed.indexOf(":"); + if (separator > 0) { + const login = trimmed.slice(0, separator).trim(); + const password = trimmed.slice(separator + 1).trim(); + if (login.length >= 4) { + output.add(login); + } + if (password.length >= 4) { + output.add(password); + } + } + } + } + } + return output; + } + if (Array.isArray(value)) { + for (const entry of value) { + collectSensitiveValues(entry, key, output); + } + return output; + } + if (value && typeof value === "object") { + for (const [entryKey, entryValue] of Object.entries(value as Record)) { + collectSensitiveValues(entryValue, entryKey, output); + } + } + return output; +} + +function redactUrl(value: string): string { + try { + const parsed = new URL(value); + return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}/`; + } catch { + return ""; + } +} + +function redactSupportText(value: string, sensitiveValues: ReadonlySet): string { + let output = String(value || "").replace(/\0/g, ""); + const secrets = [...sensitiveValues].sort((a, b) => b.length - a.length); + for (const secret of secrets) { + output = output.replace(new RegExp(escapeRegExp(secret), "g"), ""); + } + output = output.replace(/https?:\/\/[^\s"'<>]+/gi, (url) => redactUrl(url)); + output = output.replace(/(["']?(?:authorization|proxy-authorization|set-cookie|cookie|password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*')/gi, "$1\"\""); + output = output.replace(/\b(?:authorization|proxy-authorization)\s*[:=]\s*[^\r\n]+/gi, "Authorization: "); + output = output.replace(/\b(?:set-cookie|cookie)\s*[:=]\s*[^\r\n]+/gi, "Cookie: "); + output = output.replace(/\b(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)\b(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;|]+)/gi, "$1$2"); + output = output.replace(/\b[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\b/g, ""); + output = output.replace(/\b(?=[A-Za-z0-9+/_=-]{24,}\b)(?=[A-Za-z0-9+/_=-]*[A-Za-z])(?=[A-Za-z0-9+/_=-]*\d)[A-Za-z0-9+/_=-]+\b/g, ""); + output = output.replace(/\b[A-Z]:\\[^\r\n|"<>]+/gi, ""); + output = output.replace(/\\\\[^\r\n|"<>]+/g, ""); + output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, ""); + output = output.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, ""); + return output; +} + +function redactSupportValue(value: unknown, sensitiveValues: ReadonlySet, key = ""): unknown { + if (typeof value === "string") { + if (/token|api.?key|password|passwd|secret|cookie|authorization|credential|login|username/i.test(key) && value.trim()) { + return ""; + } + return redactSupportText(value, sensitiveValues); + } + if (Array.isArray(value)) { + return value.map((entry) => redactSupportValue(entry, sensitiveValues, key)); + } + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record) + .map(([entryKey, entryValue]) => [entryKey, redactSupportValue(entryValue, sensitiveValues, entryKey)])); + } + return value; +} + +function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet): string { + return redactSupportText(zipPath, sensitiveValues) + .split("/") + .map((part) => part.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry") + .join("/"); +} + +async function yieldToEventLoop(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +async function addJson(zip: AdmZip, zipPath: string, value: unknown, sensitiveValues: ReadonlySet): Promise { + const redacted = redactSupportValue(value, sensitiveValues); + const buffer = Buffer.from(`${JSON.stringify(redacted, null, 2)}\n`, "utf8"); + await yieldToEventLoop(); + zip.addFile(zipPath, buffer); +} + +async function safeReadBoundedJson(filePath: string, maxBytes: number): Promise { + try { + const stats = await fsp.stat(filePath); + if (!stats.isFile() || stats.size > maxBytes) { + return null; + } + return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown; + } catch { + return null; + } +} -async function safeReadJson(filePath: string): Promise { - try { - return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown; - } catch { - return null; - } -} - -function addJson(zip: AdmZip, zipPath: string, value: unknown): void { - zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")); -} - -async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise { - if (!sourcePath) { - return; - } - try { - const buffer = await fsp.readFile(sourcePath); - zip.addFile(zipPath, buffer); - } catch { - } -} - -async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise { - let entries; - try { - entries = await fsp.readdir(dirPath, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - const zipPath = path.posix.join(zipRoot, entry.name); - if (entry.isDirectory()) { - await addDirectoryIfExists(zip, fullPath, zipPath); - continue; - } - await addFileIfExists(zip, fullPath, zipPath); - } -} - -async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise { - let entries; - try { - entries = await fsp.readdir(dirPath, { withFileTypes: true }); - } catch { - return 0; - } - const cutoff = Date.now() - maxAgeMs; - let added = 0; - for (const entry of entries) { - if (!entry.isFile()) continue; - const fullPath = path.join(dirPath, entry.name); - try { - if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) { - await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name)); - added += 1; - } - } catch { } - } - return added; -} +function getSourcePathKey(sourcePath: string): string { + const resolved = path.resolve(sourcePath); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +async function readTextTail(filePath: string, maxBytes: number): Promise { + const stats = await fsp.stat(filePath); + const bytesToRead = Math.min(stats.size, Math.max(0, maxBytes)); + if (bytesToRead <= 0) { + return ""; + } + const handle = await fsp.open(filePath, "r"); + try { + const buffer = Buffer.alloc(bytesToRead); + const { bytesRead } = await handle.read(buffer, 0, bytesToRead, Math.max(0, stats.size - bytesToRead)); + const text = buffer.subarray(0, bytesRead).toString("utf8"); + return stats.size > bytesRead ? `[gekürzt: letzte ${bytesRead} Bytes]\n${text}` : text; + } finally { + await handle.close(); + } +} + +async function addTextFileIfExists( + zip: AdmZip, + sourcePath: string | null, + zipPath: string, + includedSourcePaths: Set, + sensitiveValues: ReadonlySet, + budget: TextBudget, + maxFileBytes: number, + maxAgeMs?: number +): Promise { + if (!sourcePath || budget.remainingBytes <= 0) { + return false; + } + const sourcePathKey = getSourcePathKey(sourcePath); + if (includedSourcePaths.has(sourcePathKey)) { + return false; + } + try { + if (maxAgeMs !== undefined && (await fsp.stat(sourcePath)).mtimeMs < Date.now() - maxAgeMs) { + return false; + } + const allowedBytes = Math.min(maxFileBytes, budget.remainingBytes); + const text = redactSupportText(await readTextTail(sourcePath, allowedBytes), sensitiveValues); + let buffer = Buffer.from(text, "utf8"); + if (buffer.length > allowedBytes) { + buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8"); + } + await yieldToEventLoop(); + zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues), buffer); + includedSourcePaths.add(sourcePathKey); + budget.remainingBytes = Math.max(0, budget.remainingBytes - buffer.length); + return true; + } catch { + return false; + } +} + +async function addRecentDirectoryFiles( + zip: AdmZip, + dirPath: string, + zipRoot: string, + maxAgeMs: number, + maxFiles: number, + includedSourcePaths: Set, + sensitiveValues: ReadonlySet, + budget: TextBudget +): Promise { + const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = []; + let directory; + try { + directory = await fsp.opendir(dirPath); + } catch { + return 0; + } + const cutoff = Date.now() - maxAgeMs; + let scanned = 0; + for await (const entry of directory) { + if (scanned >= MAX_DIRECTORY_SCAN_FILES) { + break; + } + scanned += 1; + if (!entry.isFile()) { + continue; + } + const fullPath = path.join(dirPath, entry.name); + try { + const stats = await fsp.stat(fullPath); + if (stats.mtimeMs >= cutoff) { + candidates.push({ name: entry.name, fullPath, mtimeMs: stats.mtimeMs }); + candidates.sort((a, b) => b.mtimeMs - a.mtimeMs); + if (candidates.length > maxFiles) { + candidates.length = maxFiles; + } + } + } catch { + } + } + let added = 0; + for (const candidate of candidates) { + if (budget.remainingBytes <= 0) { + break; + } + if (await addTextFileIfExists( + zip, + candidate.fullPath, + path.posix.join(zipRoot, candidate.name), + includedSourcePaths, + sensitiveValues, + budget, + MAX_TEXT_FILE_BYTES + )) { + added += 1; + } + } + return added; +} + +function isActiveStatus(status: unknown): boolean { + return !new Set(["completed", "failed", "cancelled", "extracted", "deleted"]).has(String(status || "")); +} + +function createPackageDto(entry: PackageEntry): Record { + return { + id: entry.id, + name: entry.name, + status: entry.status, + itemCount: entry.itemIds.length, + cancelled: entry.cancelled, + enabled: entry.enabled, + priority: entry.priority, + postProcessLabel: entry.postProcessLabel, + outputPath: entry.outputDir ? "" : "", + extractPath: entry.extractDir ? "" : "", + cleanedItemCount: entry.cleanedCompletedItemCount, + cleanedUrlCount: entry.cleanedUrls?.length || 0, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt + }; +} + +function getSourceHost(value: string): string { + try { + return new URL(value).hostname; + } catch { + return ""; + } +} + +function createItemDto(entry: DownloadItem): Record { + return { + id: entry.id, + packageId: entry.packageId, + sourceHost: getSourceHost(entry.url), + provider: entry.provider, + providerLabel: entry.providerLabel, + providerAccountLabel: entry.providerAccountLabel, + status: entry.status, + retries: entry.retries, + speedBps: entry.speedBps, + downloadedBytes: entry.downloadedBytes, + totalBytes: entry.totalBytes, + progressPercent: entry.progressPercent, + fileName: entry.fileName, + targetPath: entry.targetPath ? "" : "", + resumable: entry.resumable, + attempts: entry.attempts, + lastError: entry.lastError, + fullStatus: entry.fullStatus, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + onlineStatus: entry.onlineStatus + }; +} + +function selectRelevantEntries(entries: T[], limit: number): T[] { + return entries.sort((a, b) => Number(isActiveStatus(b.status)) - Number(isActiveStatus(a.status)) || b.updatedAt - a.updatedAt).slice(0, limit); +} + +function createSessionDto(session: SessionState): Record { + return { + version: session.version, + runStartedAt: session.runStartedAt, + totalDownloadedBytes: session.totalDownloadedBytes, + summaryText: session.summaryText, + reconnectUntil: session.reconnectUntil, + reconnectReason: session.reconnectReason, + paused: session.paused, + running: session.running, + updatedAt: session.updatedAt, + packageCount: Object.keys(session.packages).length, + itemCount: Object.keys(session.items).length + }; +} + +function createHistoryDto(entry: HistoryEntry): Record { + return { + id: entry.id, + name: entry.name, + status: entry.status, + provider: entry.provider, + fileCount: entry.fileCount, + totalBytes: entry.totalBytes, + downloadedBytes: entry.downloadedBytes, + durationSeconds: entry.durationSeconds, + completedAt: entry.completedAt, + outputPath: entry.outputDir ? "" : "", + urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0 + }; +} + +async function loadBoundedHistory(filePath: string): Promise<{ total: number | null; entries: Array>; omitted: number | null }> { + try { + const stats = await fsp.stat(filePath); + if (!stats.isFile() || stats.size > MAX_HISTORY_FILE_BYTES) { + return { total: null, entries: [], omitted: null }; + } + const parsed = JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown; + if (!Array.isArray(parsed)) { + return { total: 0, entries: [], omitted: 0 }; + } + const entries = parsed.slice(0, MAX_HISTORY_ENTRIES).map((entry) => createHistoryDto(entry as HistoryEntry)); + return { total: parsed.length, entries, omitted: Math.max(0, parsed.length - entries.length) }; + } catch { + return { total: 0, entries: [], omitted: 0 }; + } +} function formatTimestampForFileName(date: Date): string { const y = date.getFullYear(); @@ -93,15 +395,98 @@ function formatTimestampForFileName(date: Date): string { return `${y}-${mo}-${d}_${h}-${mi}-${s}`; } -export function getSupportBundleDefaultFileName(): string { - return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`; -} +export function getSupportBundleDefaultFileName(): string { + return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`; +} + +export interface SupportBundleExportResult { + saved: boolean; + busy: boolean; + filePath?: string; + message?: string; +} + +interface SupportBundleExportSuccess { + filePath: string; + bytes: number; +} + +interface SupportBundleExportRunnerOptions { + chooseFile: () => Promise; + build: () => Promise; + write: (filePath: string, buffer: Buffer) => Promise; + onSuccess?: (result: SupportBundleExportSuccess) => Promise | void; + onFailure?: (error: unknown) => Promise | void; +} + +export function createSupportBundleExportRunner( + options: SupportBundleExportRunnerOptions +): () => Promise { + let active = false; + return async () => { + if (active) { + return { + saved: false, + busy: true, + message: "Support-Bundle wird bereits erstellt." + }; + } + active = true; + try { + const filePath = await options.chooseFile(); + if (!filePath) { + return { saved: false, busy: false }; + } + const buffer = await options.build(); + await options.write(filePath, buffer); + if (options.onSuccess) { + try { + await options.onSuccess({ filePath, bytes: buffer.length }); + } catch { + } + } + return { saved: true, busy: false, filePath }; + } catch (error) { + if (options.onFailure) { + try { + await options.onFailure(error); + } catch { + } + } + throw error; + } finally { + active = false; + } + }; +} + +export async function writeSupportBundleAtomically(filePath: string, buffer: Buffer): Promise { + const targetPath = path.resolve(filePath); + const targetDirectory = path.dirname(targetPath); + const temporaryPath = path.join(targetDirectory, `.${path.basename(targetPath)}.${process.pid}.${randomUUID()}.tmp`); + let handle: Awaited> | null = null; + try { + handle = await fsp.open(temporaryPath, "wx"); + await handle.writeFile(buffer); + await handle.sync(); + await handle.close(); + handle = null; + await fsp.rename(temporaryPath, targetPath); + } catch (error) { + if (handle) { + await handle.close().catch(() => undefined); + } + await fsp.rm(temporaryPath, { force: true }).catch(() => undefined); + throw error; + } +} + +type HostDiagnosticsMode = "full" | "cached" | "none"; -type HostDiagnosticsMode = "full" | "cached" | "none"; - -interface BuildSupportBundleOptions { - hostDiagnosticsMode?: HostDiagnosticsMode; -} +interface BuildSupportBundleOptions { + hostDiagnosticsMode?: HostDiagnosticsMode; + debugSetupMode?: "full" | "deferred"; +} function createDeferredHostDiagnostics(reason: string): unknown { return { @@ -135,90 +520,132 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown { return getWindowsHostDiagnostics(); } -export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise { - const zip = new AdmZip(); - const hostDiagnosticsMode = options.hostDiagnosticsMode || "full"; - const storagePaths = createStoragePaths(baseDir); - const settings = loadSettings(storagePaths); - const history = loadHistory(storagePaths); - const snapshot = manager.getSnapshot(); - const packageIds = Object.keys(snapshot.session.packages); - const itemIds = Object.keys(snapshot.session.items); - const debugSetup = getDebugSetupCheck(baseDir); - - addJson(zip, "overview/meta.json", { - appVersion: APP_VERSION, - generatedAt: new Date().toISOString(), - runtimeBaseDir: baseDir, - packageCount: packageIds.length, - itemCount: itemIds.length - }); - addJson(zip, "overview/status.json", snapshot.session); - addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings)); - addJson(zip, "overview/accounts.json", buildAccountSummary(settings)); - addJson(zip, "overview/stats.json", { - ...buildStatsPayload(snapshot), - allTime: { - totalDownloadedAllTime: settings.totalDownloadedAllTime, - totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, - totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs - } - }); - addJson(zip, "overview/debug-setup.json", debugSetup); - addJson(zip, "overview/self-check.json", debugSetup); - addJson(zip, "overview/history.json", { - total: history.length, - entries: history.map((entry) => summarizeHistoryEntry(entry)) - }); - addJson(zip, "overview/packages.json", { - count: packageIds.length, - packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean) - }); - addJson(zip, "overview/items.json", { - count: itemIds.length, - items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean) - }); - addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode)); - addJson(zip, "overview/trace-config.json", getTraceConfig()); - const recentErrors = getRecentErrors(); - addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }); - - await addFileIfExists(zip, path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`); - await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); - await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt"); - await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json"); - - await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log"); - await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old"); - await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log"); - await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old"); - await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log"); - await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old"); - await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt"); - await addFileIfExists(zip, getSessionLogPath(), "logs/session.log"); - await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); - await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old"); - await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log"); - await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old"); - await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log"); - await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old"); - - const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000; - await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); - await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); - await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); - - for (const packageId of packageIds) { - await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`); - } - for (const itemId of itemIds) { - await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`); - } - - const supportManifest = await safeReadJson(path.join(baseDir, SUPPORT_MANIFEST_FILE)); +export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise { + const zip = new AdmZip(); + const includedSourcePaths = new Set(); + const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES }; + const hostDiagnosticsMode = options.hostDiagnosticsMode || "full"; + const storagePaths = createStoragePaths(baseDir); + const settings = loadSettings(storagePaths); + const sensitiveValues = collectSensitiveValues(settings); + const snapshot = manager.getSnapshot(); + const packageEntries = Object.values(snapshot.session.packages); + const itemEntries = Object.values(snapshot.session.items); + const selectedPackages = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS).map(createPackageDto); + const selectedItems = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS).map(createItemDto); + const history = await loadBoundedHistory(storagePaths.historyFile); + const debugSetup = options.debugSetupMode === "deferred" + ? { status: "deferred", generatedAt: new Date().toISOString(), reason: "Tiefer Setup-Scan wurde beim interaktiven Export ausgelassen." } + : getDebugSetupCheck(baseDir); + + await addJson(zip, "overview/meta.json", { + appVersion: APP_VERSION, + generatedAt: new Date().toISOString(), + runtimeBaseDir: "", + packageCount: packageEntries.length, + itemCount: itemEntries.length, + limits: { + packageDtos: MAX_PACKAGE_DTOS, + itemDtos: MAX_ITEM_DTOS, + textBytes: MAX_TOTAL_TEXT_BYTES, + textFileBytes: MAX_TEXT_FILE_BYTES, + logWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000 + } + }, sensitiveValues); + await addJson(zip, "overview/status.json", createSessionDto(snapshot.session), sensitiveValues); + await addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings), sensitiveValues); + await addJson(zip, "overview/accounts.json", buildAccountSummary(settings), sensitiveValues); + await addJson(zip, "overview/stats.json", { + ...buildStatsPayload(snapshot), + allTime: { + totalDownloadedAllTime: settings.totalDownloadedAllTime, + totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, + totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs + } + }, sensitiveValues); + await addJson(zip, "overview/debug-setup.json", debugSetup, sensitiveValues); + await addJson(zip, "overview/self-check.json", debugSetup, sensitiveValues); + await addJson(zip, "overview/history.json", history, sensitiveValues); + await addJson(zip, "overview/packages.json", { + count: packageEntries.length, + included: selectedPackages.length, + omitted: Math.max(0, packageEntries.length - selectedPackages.length), + packages: selectedPackages + }, sensitiveValues); + await addJson(zip, "overview/items.json", { + count: itemEntries.length, + included: selectedItems.length, + omitted: Math.max(0, itemEntries.length - selectedItems.length), + items: selectedItems + }, sensitiveValues); + await addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode), sensitiveValues); + await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues); + const recentErrors = getRecentErrors().slice(-100); + await addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }, sensitiveValues); + + const addRuntimeFile = (sourcePath: string | null, zipPath: string): Promise => addTextFileIfExists( + zip, + sourcePath, + zipPath, + includedSourcePaths, + sensitiveValues, + textBudget, + MAX_RUNTIME_FILE_BYTES + ); + const addCurrentLog = (sourcePath: string | null, zipPath: string): Promise => addTextFileIfExists( + zip, + sourcePath, + zipPath, + includedSourcePaths, + sensitiveValues, + textBudget, + MAX_TEXT_FILE_BYTES + ); + const addRotatedLog = (sourcePath: string | null, zipPath: string): Promise => addTextFileIfExists( + zip, + sourcePath, + zipPath, + includedSourcePaths, + sensitiveValues, + textBudget, + MAX_TEXT_FILE_BYTES, + SUPPORT_BUNDLE_LOG_WINDOW_MS + ); + + await addRuntimeFile(path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`); + await addRuntimeFile(path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); + await addRuntimeFile(path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt"); + await addRuntimeFile(getTraceConfigPath(), "runtime/trace_config.json"); + + const mainLogPath = getLogFilePath(); + const auditLogPath = getAuditLogPath(); + const renameLogPath = getRenameLogPath(); + const traceLogPath = getTraceLogPath(); + const accountRotationLogPath = getAccountRotationLogPath(); + const conversionLogPath = getConversionLogPath(); + await addCurrentLog(mainLogPath, "logs/rd_downloader.log"); + await addRotatedLog(`${mainLogPath}.old`, "logs/rd_downloader.log.old"); + await addCurrentLog(auditLogPath, "logs/audit.log"); + await addRotatedLog(auditLogPath ? `${auditLogPath}.old` : null, "logs/audit.log.old"); + await addCurrentLog(renameLogPath, "logs/rename.log"); + await addRotatedLog(renameLogPath ? `${renameLogPath}.old` : null, "logs/rename.log.old"); + await addCurrentLog(getDesktopRenameLogPath(), "logs/rename-session-desktop.txt"); + await addCurrentLog(getSessionLogPath(), "logs/session.log"); + await addCurrentLog(traceLogPath, "logs/trace.log"); + await addRotatedLog(traceLogPath ? `${traceLogPath}.old` : null, "logs/trace.log.old"); + await addCurrentLog(accountRotationLogPath, "logs/account-rotation.log"); + await addRotatedLog(accountRotationLogPath ? `${accountRotationLogPath}.old` : null, "logs/account-rotation.log.old"); + await addCurrentLog(conversionLogPath, "logs/conversion.log"); + await addRotatedLog(conversionLogPath ? `${conversionLogPath}.old` : null, "logs/conversion.log.old"); + + await addRecentDirectoryFiles(zip, path.join(baseDir, "session-logs"), "logs/session-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_SESSION_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget); + await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_PACKAGE_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget); + await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_ITEM_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget); + + const supportManifest = await safeReadBoundedJson(path.join(baseDir, SUPPORT_MANIFEST_FILE), MAX_RUNTIME_FILE_BYTES); if (supportManifest) { - addJson(zip, "overview/support-manifest.json", supportManifest); - } - - return zip.toBuffer(); -} + await addJson(zip, "overview/support-manifest.json", supportManifest, sensitiveValues); + } + + return await zip.toBufferPromise(); +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 6d4f940..ab99261 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -64,7 +64,8 @@ const api: ElectronApi = { exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds), exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE), importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json), - toggleClipboard: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), + 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), getSessionStats: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS), @@ -78,7 +79,7 @@ const api: ElectronApi = { cancelBackupImport: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_BACKUP_IMPORT), exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP), importOnlineBackup: (key: string): Promise<{ restored: boolean; relaunch: false; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, key), - exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), + exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string; busy?: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openLogDirectory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG_DIRECTORY), openAuditLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4e65e4c..fa554fb 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -45,6 +45,7 @@ import type { AccountModeFilter } from "./account-ui"; import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit"; import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit"; import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons"; +import { buildAccountToggleSettingsUpdate, SerialTaskQueue, setAccountTargetEnabled, type AccountToggleTarget } from "./account-toggle-queue"; import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state"; import { createUiLocalizer, normalizeLanguage } from "./i18n"; import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow"; @@ -869,11 +870,44 @@ const historyRetentionLabels: Record= 700 ? 0 : itemCount >= 250 ? 50 : 100; + let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200; if (!running) delay = Math.min(delay, 200); if (activeTab !== "downloads") delay = Math.max(delay, 800); return delay; } + +interface SupportBundleExportUiOptions { + exportBundle: () => Promise<{ saved: boolean; busy?: boolean }>; + setBusy: (busy: boolean) => void; + clearMessage: () => void; + showMessage: (message: string) => void; +} + +export async function runSupportBundleExportUi(options: SupportBundleExportUiOptions): Promise { + options.clearMessage(); + options.setBusy(true); + try { + const result = await options.exportBundle(); + if (result.saved) { + options.showMessage("Support-Bundle exportiert"); + } else if (result.busy) { + options.showMessage("Support-Bundle wird bereits erstellt …"); + } + } catch (error) { + options.showMessage(`Support-Bundle fehlgeschlagen: ${String(error)}`); + } finally { + options.setBusy(false); + } +} + +interface SupportBundleToastProps { + busy: boolean; + message: string; +} + +export function SupportBundleToast({ busy, message }: SupportBundleToastProps): ReactElement | null { + return ; +} const KNOWN_HOSTERS: { id: string; label: string }[] = [ { id: "rapidgator", label: "Rapidgator" }, @@ -1261,7 +1295,7 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const interval = setInterval(() => { drawChart(); - }, reducedMotion ? 1000 : 250); + }, reducedMotion ? 1000 : 500); return () => clearInterval(interval); }, [drawChart, running, paused]); @@ -1363,7 +1397,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps, draw(); }; - const id = window.setInterval(tick, 250); + const id = window.setInterval(tick, 500); return () => window.clearInterval(id); }, []); @@ -1513,6 +1547,8 @@ export function App(): ReactElement { const [updateDialogOpen, setUpdateDialogOpen] = useState(false); const [updateInstallProgress, setUpdateInstallProgress] = useState(null); const [settingsDraft, setSettingsDraft] = useState(() => createSettingsDraft(emptySnapshot().settings)); + const settingsDraftRef = useRef(settingsDraft); + settingsDraftRef.current = settingsDraft; const [settingsThemeChoice, setSettingsThemeChoice] = useState(emptySnapshot().settings.theme); const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); @@ -1571,10 +1607,17 @@ export function App(): ReactElement { const [showAllPackages, setShowAllPackages] = useState(false); const [actionBusy, setActionBusy] = useState(false); const [accountCheckBusy, setAccountCheckBusy] = useState(false); + const [accountEnabledOverrides, setAccountEnabledOverrides] = useState>({}); + const accountEnabledOverridesRef = useRef>({}); + const accountToggleQueueRef = useRef(new SerialTaskQueue()); + const accountToggleRevisionRef = useRef(0); + const accountTogglePendingRef = useRef(0); const actionBusyRef = useRef(false); const actionUnlockTimerRef = useRef | null>(null); const mountedRef = useRef(true); - const [supportTraceEnabled, setSupportTraceEnabled] = useState(false); + const [supportTraceEnabled, setSupportTraceEnabled] = useState(false); + const [supportBundleExporting, setSupportBundleExporting] = useState(false); + const supportBundleExportingRef = useRef(false); const dragOverRef = useRef(false); const dragDepthRef = useRef(0); const [openMenu, setOpenMenu] = useState(null); @@ -1755,6 +1798,14 @@ export function App(): ReactElement { }, timeoutMs); }, []); + const clearToast = useCallback((): void => { + setStatusToast(""); + if (toastTimerRef.current) { + clearTimeout(toastTimerRef.current); + toastTimerRef.current = null; + } + }, []); + const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => { const availableIds = entries.map((entry) => entry.id); const availableSet = new Set(availableIds); @@ -1954,9 +2005,9 @@ export function App(): ReactElement { if (next.settings.columnOrder?.length > 0) { setColumnOrder(next.settings.columnOrder); } - if (!settingsDirtyRef.current) { + if (!settingsDirtyRef.current && accountTogglePendingRef.current === 0) { setSettingsDraft(createSettingsDraft(next.settings)); - } + } latestStateRef.current = null; } }, flushDelay); @@ -2308,8 +2359,9 @@ export function App(): ReactElement { for (const acc of accounts) { const used = acc.dailyUsageBytes; const limit = acc.dailyLimitBytes; + const rowKey = `mega-${entry.kind}-${acc.accountId}`; rows.push({ - rowKey: `mega-${entry.kind}-${acc.accountId}`, + rowKey, entry, hosterLabel: entry.serviceLabel, modeLabel: entry.modeLabel, @@ -2317,7 +2369,9 @@ export function App(): ReactElement { credentialLabel: "••••••", accountId: acc.accountId, checkable: true, - disabled: !acc.enabled, + disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey) + ? !accountEnabledOverrides[rowKey] + : !acc.enabled, dailyUsedBytes: used, dailyLimitBytes: limit, dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0, @@ -2333,9 +2387,10 @@ export function App(): ReactElement { }); } } else if (entry.kind === "debridlink-api") { - for (const key of entry.debridLinkKeys) { - rows.push({ - rowKey: `dl-${key.id}`, + for (const key of entry.debridLinkKeys) { + const rowKey = `dl-${key.id}`; + rows.push({ + rowKey, entry, hosterLabel: entry.serviceLabel, modeLabel: entry.modeLabel, @@ -2343,7 +2398,9 @@ export function App(): ReactElement { credentialLabel: "API-Key", accountId: key.id, checkable: true, - disabled: key.disabled, + disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey) + ? !accountEnabledOverrides[rowKey] + : key.disabled, dailyUsedBytes: key.dailyUsedBytes, dailyLimitBytes: key.dailyLimitBytes, dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0, @@ -2359,9 +2416,10 @@ export function App(): ReactElement { } }); } - } else { - rows.push({ - rowKey: `svc-${entry.service}`, + } else { + const rowKey = `svc-${entry.service}`; + rows.push({ + rowKey, entry, hosterLabel: entry.serviceLabel, modeLabel: entry.modeLabel, @@ -2369,7 +2427,9 @@ export function App(): ReactElement { credentialLabel: getAccountCredentialLabel(entry.kind), accountId: null, checkable: false, - disabled: entry.disabled, + disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey) + ? !accountEnabledOverrides[rowKey] + : entry.disabled, dailyUsedBytes: entry.dailyUsedBytes, dailyLimitBytes: entry.dailyLimitBytes, dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0, @@ -2386,7 +2446,7 @@ export function App(): ReactElement { } } return rows; - }, [configuredAccounts, snapshot.accounts]); + }, [accountEnabledOverrides, configuredAccounts, snapshot.accounts]); const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none"); const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none")); @@ -2791,29 +2851,7 @@ export function App(): ReactElement { }); }; - const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise => { - await performQuickAction(async () => { - const currentDisabledIds = settingsDraft.debridLinkDisabledKeyIds || []; - const nextDisabledIds = key.disabled - ? currentDisabledIds.filter((existingId) => existingId !== key.id) - : [...currentDisabledIds, key.id]; - const nextDraft: RendererSettingsDraft = { - ...settingsDraft, - debridLinkDisabledKeyIds: nextDisabledIds - }; - await persistSpecificSettings(nextDraft); - showToast( - key.disabled - ? `${entry.serviceLabel} ${key.label} aktiviert` - : `${entry.serviceLabel} ${key.label} deaktiviert`, - 2200 - ); - }, (error) => { - showToast(`${entry.serviceLabel} ${key.label}: Umschalten fehlgeschlagen: ${String(error)}`, 3200); - }); - }; - - const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise => { + const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise => { const meta = getAccountQuickActionMeta(entry.kind); if (!meta) { return; @@ -2825,25 +2863,6 @@ export function App(): ReactElement { }); }; - const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", accountId: string, currentlyDisabled: boolean): Promise => { - await performQuickAction(async () => { - const mode = kind === "megadebrid-web" ? "web" : "api"; - const current = mode === "api" ? settingsDraft.megaDebridApiDisabledAccountIds : settingsDraft.megaDebridWebDisabledAccountIds; - const next = currentlyDisabled ? current.filter((id) => id !== accountId) : [...current, accountId]; - const apiDisabledIds = mode === "api" ? next : settingsDraft.megaDebridApiDisabledAccountIds; - const webDisabledIds = mode === "web" ? next : settingsDraft.megaDebridWebDisabledAccountIds; - await persistSpecificSettings({ - ...settingsDraft, - megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], - megaDebridApiDisabledAccountIds: apiDisabledIds, - megaDebridWebDisabledAccountIds: webDisabledIds - }); - showToast(currentlyDisabled ? "Account aktiviert" : "Account deaktiviert", 2000); - }, (error) => { - showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200); - }); - }; - const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise => { const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); if (!confirmed) return; @@ -2855,61 +2874,145 @@ export function App(): ReactElement { }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); }; - const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise => { - await performQuickAction(async () => { - const provider = entry.service as DebridProvider; - const current = settingsDraft.disabledProviders || []; - const nextDisabledProviders = current.includes(provider) - ? current.filter((existing) => existing !== provider) - : [...current, provider]; - const nextDraft: RendererSettingsDraft = { - ...settingsDraft, - disabledProviders: nextDisabledProviders - }; - await persistSpecificSettings(nextDraft); - showToast( - nextDisabledProviders.includes(provider) - ? `${entry.serviceLabel} deaktiviert` - : `${entry.serviceLabel} aktiviert`, - 2200 - ); - }, (error) => { - showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200); + const reconcileAccountToggleState = async (revision: number): Promise => { + const fresh = await window.rd.getSnapshot(); + if (!mountedRef.current || revision !== accountToggleRevisionRef.current) { + return; + } + masterSnapshotRef.current = fresh; + latestStateRef.current = null; + setSnapshot(fresh); + const reconciledDraft: RendererSettingsDraft = { + ...settingsDraftRef.current, + ...buildAccountToggleSettingsUpdate(fresh.settings) + }; + settingsDraftRef.current = reconciledDraft; + setSettingsDraft(reconciledDraft); + accountEnabledOverridesRef.current = {}; + setAccountEnabledOverrides({}); + }; + + const enqueueAccountSettingsChange = ( + nextDraft: RendererSettingsDraft, + nextOverrides: Record, + successMessage: string, + errorMessage: string + ): void => { + settingsDraftRevisionRef.current += 1; + settingsDraftRef.current = nextDraft; + setSettingsDraft(nextDraft); + accountEnabledOverridesRef.current = nextOverrides; + setAccountEnabledOverrides(nextOverrides); + const revision = accountToggleRevisionRef.current + 1; + accountToggleRevisionRef.current = revision; + accountTogglePendingRef.current += 1; + void accountToggleQueueRef.current.enqueue(async () => { + let error: unknown = null; + try { + await window.rd.updateSettings(buildAccountToggleSettingsUpdate(nextDraft)); + } catch (caught) { + error = caught; + } finally { + accountTogglePendingRef.current = Math.max(0, accountTogglePendingRef.current - 1); + } + if (revision !== accountToggleRevisionRef.current) { + return; + } + try { + await reconcileAccountToggleState(revision); + } catch (caught) { + error = error ?? caught; + if (mountedRef.current && revision === accountToggleRevisionRef.current) { + const fallbackDraft: RendererSettingsDraft = { + ...settingsDraftRef.current, + ...buildAccountToggleSettingsUpdate(snapshotRef.current.settings) + }; + settingsDraftRef.current = fallbackDraft; + setSettingsDraft(fallbackDraft); + accountEnabledOverridesRef.current = {}; + setAccountEnabledOverrides({}); + } + } + if (error) { + showToast(`${errorMessage}: ${String(error)}`, 3200); + } else { + showToast(successMessage, 1800); + } }); }; + const getAccountToggleTarget = (row: AccountTableRow): AccountToggleTarget | null => { + if (row.toggleKind === "mega" && row.accountId) { + return { + kind: row.entry.kind === "megadebrid-web" ? "mega-web" : "mega-api", + accountId: row.accountId + }; + } + if (row.toggleKind === "dl" && row.accountId) { + return { kind: "debridlink", accountId: row.accountId }; + } + if (row.toggleKind === "single") { + return { kind: "provider", provider: row.entry.service as DebridProvider }; + } + return null; + }; + const toggleAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); - if (row.toggleKind === "mega" && row.accountId) { - void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.accountId, row.disabled); - } else if (row.toggleKind === "dl" && row.dlKey) { - void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey); - } else { - void onToggleAccountEnabled(row.entry); + const target = getAccountToggleTarget(row); + if (!target) { + return; + } + const currentEnabled = Object.prototype.hasOwnProperty.call(accountEnabledOverridesRef.current, row.rowKey) + ? accountEnabledOverridesRef.current[row.rowKey] + : !row.disabled; + const nextEnabled = !currentEnabled; + const nextDraft = setAccountTargetEnabled(settingsDraftRef.current, target, nextEnabled); + const nextOverrides = { + ...accountEnabledOverridesRef.current, + [row.rowKey]: nextEnabled + }; + enqueueAccountSettingsChange( + nextDraft, + nextOverrides, + nextEnabled ? "Account aktiviert" : "Account deaktiviert", + "Account konnte nicht umgeschaltet werden" + ); + }; + + const onToggleDebridLinkApiKeyEnabled = (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): void => { + const row = accountRows.find((candidate) => candidate.entry.service === entry.service && candidate.accountId === key.id); + if (row) { + toggleAccountTableRow(row); } }; - const setAllAccountsEnabled = async (enabled: boolean): Promise => { - await performQuickAction(async () => { - const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))]; - const nextEnabledState = buildBulkAccountEnabledState( - settingsDraft.disabledProviders || [], - configuredProviderIds, - accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string), - accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string), - enabled - ); - const nextDraft: RendererSettingsDraft = { - ...settingsDraft, - ...nextEnabledState, - megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string), - megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string) - }; - await persistSpecificSettings(nextDraft); - showToast(enabled ? "Accounts aktiviert" : "Accounts deaktiviert", 2200); - }, (error) => { - showToast(`Accounts konnten nicht umgeschaltet werden: ${String(error)}`, 3200); - }); + const setAllAccountsEnabled = (enabled: boolean): void => { + const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))]; + const nextEnabledState = buildBulkAccountEnabledState( + settingsDraftRef.current.disabledProviders || [], + configuredProviderIds, + accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string), + accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string), + enabled + ); + const nextDraft: RendererSettingsDraft = { + ...settingsDraftRef.current, + ...nextEnabledState, + megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string), + megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string) + }; + nextDraft.megaDebridDisabledAccountIds = [...new Set([ + ...nextDraft.megaDebridApiDisabledAccountIds, + ...nextDraft.megaDebridWebDisabledAccountIds + ])]; + const nextOverrides = Object.fromEntries(accountRows.map((row) => [row.rowKey, enabled])); + enqueueAccountSettingsChange( + nextDraft, + nextOverrides, + enabled ? "Accounts aktiviert" : "Accounts deaktiviert", + "Accounts konnten nicht umgeschaltet werden" + ); }; const removeAccountTableRow = (row: AccountTableRow): void => { @@ -4199,24 +4302,31 @@ export function App(): ReactElement { const onCopyOnlineBackupKey = async (): Promise => { if (!onlineBackupDialog?.key) return; try { - await navigator.clipboard.writeText(onlineBackupDialog.key); + await window.rd.writeClipboardText(onlineBackupDialog.key); showToast("Online-Schlüssel kopiert", 2200); } catch { showToast("Schlüssel konnte nicht kopiert werden", 2600); } }; - const onExportSupportBundle = async (): Promise => { - closeMenus(); - await performQuickAction(async () => { - const result = await window.rd.exportSupportBundle(); - if (result.saved) { - showToast("Support-Bundle exportiert", 2600); - } - }, (error) => { - showToast(`Support-Bundle fehlgeschlagen: ${String(error)}`, 2800); - }); - }; + const onExportSupportBundle = async (): Promise => { + closeMenus(); + if (supportBundleExportingRef.current) { + showToast("Support-Bundle wird bereits erstellt …", 2600); + return; + } + supportBundleExportingRef.current = true; + try { + await runSupportBundleExportUi({ + exportBundle: () => window.rd.exportSupportBundle(), + setBusy: setSupportBundleExporting, + clearMessage: clearToast, + showMessage: (message) => showToast(message, 2800) + }); + } finally { + supportBundleExportingRef.current = false; + } + }; const onToggleSupportTrace = async (): Promise => { closeMenus(); @@ -4268,7 +4378,7 @@ export function App(): ReactElement { detailsLabel: "Einträge anzeigen" }); if (copy && entries.length > 0) { - await navigator.clipboard.writeText(details); + await window.rd.writeClipboardText(details); showToast("Fehlerliste kopiert", 2600); } } catch (error) { @@ -4371,7 +4481,7 @@ export function App(): ReactElement { return; } try { - await navigator.clipboard.writeText(remoteDiag.code); + await window.rd.writeClipboardText(remoteDiag.code); showToast("Verbindungscode kopiert", 2200); } catch { showToast("Kopieren fehlgeschlagen", 2200); @@ -4624,9 +4734,26 @@ export function App(): ReactElement { }, onPauseDownloads: () => { setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } })); - void window.rd.togglePause().catch(() => {}); + void window.rd.togglePause().then((paused) => { + setSnapshot((current) => ({ ...current, session: { ...current.session, paused } })); + }).catch(async (error) => { + try { + setSnapshot(await window.rd.getSnapshot()); + } catch { + } + showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200); + }); + }, + onStopDownloads: () => { + setSnapshot((current) => ({ ...current, session: { ...current.session, running: false, paused: false } })); + void window.rd.stop().catch(async (error) => { + try { + setSnapshot(await window.rd.getSnapshot()); + } catch { + } + showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200); + }); }, - onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); }, onToggleSchedule: () => { setSchedulePickerOpen((current) => !current); setScheduleTimeInput(""); @@ -5492,7 +5619,7 @@ export function App(): ReactElement { className={`menu-submenu-dropdown${openSubmenu === "hilfe-remote" ? " is-open" : ""}`} > - + @@ -5843,7 +5970,7 @@ export function App(): ReactElement { accountEdit={accountEditDialogView} accountCreate={accountAddDialog} - toast={} + toast={} dropOverlay={dragOver ?
Links, .dlc oder Export-Dateien hier ablegen
: null} accountContextMenu={accountContextMenu && activeAccountContextRow ? ( { - void navigator.clipboard.writeText(key.masked) + void window.rd.writeClipboardText(key.masked) .then(() => showToast("Maskierte Kennung kopiert", 1800)) .catch(() => showToast("Kopieren fehlgeschlagen", 2200)); }} @@ -6211,8 +6338,8 @@ export function App(): ReactElement {
{linkPopup.links.map((link, i) => (
- - + +
))}
@@ -6220,13 +6347,13 @@ export function App(): ReactElement { {linkPopup.isPackage && ( )} {linkPopup.isPackage && ( )} diff --git a/src/renderer/account-toggle-queue.ts b/src/renderer/account-toggle-queue.ts new file mode 100644 index 0000000..b3d7fba --- /dev/null +++ b/src/renderer/account-toggle-queue.ts @@ -0,0 +1,72 @@ +import type { DebridProvider, RendererSettingsUpdate } from "../shared/types"; + +export type AccountToggleTarget = + | { kind: "provider"; provider: DebridProvider } + | { kind: "debridlink"; accountId: string } + | { kind: "mega-api"; accountId: string } + | { kind: "mega-web"; accountId: string }; + +export interface AccountToggleSettings { + disabledProviders: DebridProvider[]; + debridLinkDisabledKeyIds: string[]; + megaDebridDisabledAccountIds: string[]; + megaDebridApiDisabledAccountIds: string[]; + megaDebridWebDisabledAccountIds: string[]; +} + +function setListEntry(values: T[], value: T, present: boolean): T[] { + return present + ? [...new Set([...values, value])] + : values.filter((entry) => entry !== value); +} + +export function setAccountTargetEnabled( + settings: T, + target: AccountToggleTarget, + enabled: boolean +): T { + if (target.kind === "provider") { + return { + ...settings, + disabledProviders: setListEntry(settings.disabledProviders, target.provider, !enabled) + }; + } + if (target.kind === "debridlink") { + return { + ...settings, + debridLinkDisabledKeyIds: setListEntry(settings.debridLinkDisabledKeyIds, target.accountId, !enabled) + }; + } + const apiDisabled = target.kind === "mega-api" + ? setListEntry(settings.megaDebridApiDisabledAccountIds, target.accountId, !enabled) + : settings.megaDebridApiDisabledAccountIds; + const webDisabled = target.kind === "mega-web" + ? setListEntry(settings.megaDebridWebDisabledAccountIds, target.accountId, !enabled) + : settings.megaDebridWebDisabledAccountIds; + return { + ...settings, + megaDebridApiDisabledAccountIds: apiDisabled, + megaDebridWebDisabledAccountIds: webDisabled, + megaDebridDisabledAccountIds: [...new Set([...apiDisabled, ...webDisabled])] + }; +} + +export function buildAccountToggleSettingsUpdate(settings: AccountToggleSettings): RendererSettingsUpdate { + return { + disabledProviders: settings.disabledProviders, + debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds, + megaDebridDisabledAccountIds: settings.megaDebridDisabledAccountIds, + megaDebridApiDisabledAccountIds: settings.megaDebridApiDisabledAccountIds, + megaDebridWebDisabledAccountIds: settings.megaDebridWebDisabledAccountIds + }; +} + +export class SerialTaskQueue { + private tail: Promise = Promise.resolve(); + + public enqueue(task: () => Promise): Promise { + const result = this.tail.then(task); + this.tail = result.then(() => undefined, () => undefined); + return result; + } +} diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 6dcbca9..f322380 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -137,7 +137,7 @@ const pairs = [ ["Diese Sicherung ist mit einer Passphrase geschützt.", "This backup is protected with a passphrase."], ["Passphrase", "Passphrase"], ["Passphrase bestätigen", "Confirm passphrase"], ["Bitte eine Passphrase eingeben", "Enter a passphrase"], ["Die Passphrasen stimmen nicht überein", "The passphrases do not match"], ["Sicherung exportieren", "Export backup"], ["Sicherung importieren", "Import backup"], ["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."], - ["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"], + ["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"], ["Support-Bundle wird erstellt …", "Support bundle is being created …"], ["Support-Bundle wird bereits erstellt …", "Support bundle is already being created …"], ["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"], ["Remote-fähig konfiguriert", "Configured for remote access"], ["Debug-Setup prüfen", "Check debug setup"], ["Letzte Fehler", "Recent errors"], ["Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet.", "No errors or warnings recorded since the app started."], ["In Zwischenablage kopieren", "Copy to clipboard"], ["Einträge anzeigen", "Show entries"], ["Fehlerliste kopiert", "Error list copied"], ["Debug-Token rotieren", "Rotate debug token"], diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 4c7068e..4d43d3a 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -119,7 +119,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
- + {model.scheduleActive ? Geplant: {model.scheduleLabel} : <>{model.scheduleOpen ? actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /> : null}} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index e82d35e..ba4cd6e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -33,8 +33,9 @@ export const IPC_CHANNELS = { PICK_FOLDER: "dialog:pick-folder", PICK_CONTAINERS: "dialog:pick-containers", STATE_UPDATE: "state:update", - CLIPBOARD_DETECTED: "clipboard:detected", - TOGGLE_CLIPBOARD: "clipboard:toggle", + CLIPBOARD_DETECTED: "clipboard:detected", + TOGGLE_CLIPBOARD: "clipboard:toggle", + WRITE_CLIPBOARD_TEXT: "clipboard:write-text", GET_SESSION_STATS: "stats:get-session-stats", RESET_SESSION_STATS: "stats:reset-session", RESET_DOWNLOAD_STATS: "stats:reset-download", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 4dca7ff..7223dc9 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -61,7 +61,8 @@ export interface ElectronApi { exportItemSelection: (itemIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>; exportQueue: () => Promise<{ saved: boolean }>; importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>; - toggleClipboard: () => Promise; + toggleClipboard: () => Promise; + writeClipboardText: (text: string) => Promise; pickFolder: () => Promise; pickContainers: () => Promise; getSessionStats: () => Promise; @@ -75,7 +76,7 @@ export interface ElectronApi { cancelBackupImport: () => Promise; exportOnlineBackup: () => Promise<{ key: string }>; importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>; - exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; + exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string; busy?: boolean }>; openLog: () => Promise; openLogDirectory: () => Promise; openAuditLog: () => Promise; diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts index 984903e..51f4092 100644 --- a/tests/account-preload.test.ts +++ b/tests/account-preload.test.ts @@ -61,4 +61,15 @@ describe("account preload contract", () => { IPC_CHANNELS.DELETE_ACCOUNT ]); }); + + it("writes copied text through the native Electron clipboard channel", async () => { + electron.invoke.mockResolvedValueOnce(true); + + await electron.api?.writeClipboardText("https://rapidgator.net/file/example"); + + expect(electron.invoke).toHaveBeenCalledWith( + IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, + "https://rapidgator.net/file/example" + ); + }); }); diff --git a/tests/account-toggle-queue.test.ts b/tests/account-toggle-queue.test.ts new file mode 100644 index 0000000..d12cb48 --- /dev/null +++ b/tests/account-toggle-queue.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + SerialTaskQueue, + setAccountTargetEnabled, + type AccountToggleTarget +} from "../src/renderer/account-toggle-queue"; + +describe("account toggle queue", () => { + it("keeps every rapid mutation and persists them in click order", async () => { + const queue = new SerialTaskQueue(); + const calls: number[] = []; + const first = queue.enqueue(async () => { + await new Promise((resolve) => setTimeout(resolve, 15)); + calls.push(1); + }); + const second = queue.enqueue(async () => { + calls.push(2); + }); + const third = queue.enqueue(async () => { + calls.push(3); + }); + + await Promise.all([first, second, third]); + + expect(calls).toEqual([1, 2, 3]); + }); + + it("continues after a failed mutation", async () => { + const queue = new SerialTaskQueue(); + const calls: number[] = []; + const failed = queue.enqueue(async () => { + calls.push(1); + throw new Error("failed"); + }); + const recovered = queue.enqueue(async () => { + calls.push(2); + }); + + await expect(failed).rejects.toThrow("failed"); + await recovered; + + expect(calls).toEqual([1, 2]); + }); + + it("combines rapid Mega-Debrid Web activations without losing earlier clicks", () => { + const accountIds = ["web-1", "web-2", "web-3"]; + let settings = { + disabledProviders: [], + debridLinkDisabledKeyIds: [], + megaDebridApiDisabledAccountIds: [], + megaDebridWebDisabledAccountIds: [...accountIds], + megaDebridDisabledAccountIds: [...accountIds] + }; + + for (const accountId of accountIds) { + const target: AccountToggleTarget = { kind: "mega-web", accountId }; + settings = setAccountTargetEnabled(settings, target, true); + } + + expect(settings.megaDebridWebDisabledAccountIds).toEqual([]); + expect(settings.megaDebridDisabledAccountIds).toEqual([]); + }); +}); diff --git a/tests/app-shell.test.tsx b/tests/app-shell.test.tsx index 7fe37f2..23c5bf0 100644 --- a/tests/app-shell.test.tsx +++ b/tests/app-shell.test.tsx @@ -5,7 +5,7 @@ import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/A import { AppHeader } from "../src/renderer/shell/AppHeader"; import { AppShell } from "../src/renderer/shell/AppShell"; import { buildMainNavigation } from "../src/renderer/shell/shell-model"; -import { getSnapshotRenderDelay } from "../src/renderer/App"; +import { getSnapshotRenderDelay, runSupportBundleExportUi, SupportBundleToast } from "../src/renderer/App"; describe("desktop shell", () => { it("uses keyboard-focusable controls for every copy target", () => { @@ -13,8 +13,8 @@ describe("desktop shell", () => { expect(source).not.toMatch(/]*className="[^"]*link-popup-click/); expect(source.match(/]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3); - expect(source).not.toContain("navigator.clipboard.writeText(key.token)"); - expect(source).toContain("navigator.clipboard.writeText(key.masked)"); + expect(source).not.toContain("navigator.clipboard.writeText"); + expect(source).toContain("window.rd.writeClipboardText(key.masked)"); expect(source).toContain("Maskierte Kennung kopiert"); }); @@ -35,11 +35,57 @@ describe("desktop shell", () => { expect(removal).toContain('title: "Ausgewählte Links löschen"'); }); - it("does not stack renderer latency on the manager cadence for large active queues", () => { - expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0); + it("renders active download telemetry at a stable half-second cadence", () => { + expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500); expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800); }); + it("keeps support bundle progress tied to the unresolved export", async () => { + let resolveExport: (value: { saved: boolean; busy?: boolean }) => void = () => undefined; + const pendingExport = new Promise<{ saved: boolean; busy?: boolean }>((resolve) => { resolveExport = resolve; }); + const busyStates: boolean[] = []; + const messages: string[] = []; + + const running = runSupportBundleExportUi({ + exportBundle: () => pendingExport, + setBusy: (busy) => { busyStates.push(busy); }, + clearMessage: () => { messages.length = 0; }, + showMessage: (message) => { messages.push(message); } + }); + + expect(busyStates).toEqual([true]); + expect(messages).toEqual([]); + resolveExport({ saved: true }); + await running; + expect(busyStates).toEqual([true, false]); + expect(messages).toEqual(["Support-Bundle exportiert"]); + }); + + it("ends support bundle progress immediately when the file dialog is canceled", async () => { + const busyStates: boolean[] = []; + const messages = ["Vorherige Meldung"]; + + await runSupportBundleExportUi({ + exportBundle: async () => ({ saved: false, busy: false }), + setBusy: (busy) => { busyStates.push(busy); }, + clearMessage: () => { messages.length = 0; }, + showMessage: (message) => { messages.push(message); } + }); + + expect(busyStates).toEqual([true, false]); + expect(messages).toEqual([]); + }); + + it("renders progress from the real busy state instead of a timed status message", () => { + const busyHtml = renderToStaticMarkup(); + const idleHtml = renderToStaticMarkup(); + + expect(busyHtml).toContain("Support-Bundle wird erstellt …"); + expect(busyHtml).not.toContain("Alte Meldung"); + expect(idleHtml).toContain("Export beendet"); + expect(idleHtml).not.toContain("Support-Bundle wird erstellt …"); + }); + it("keeps application menus mounted for animated opening and closing", () => { const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8"); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index 48a10e6..690d7c8 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -8,13 +8,14 @@ import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearM const originalFetch = globalThis.fetch; -afterEach(() => { - globalThis.fetch = originalFetch; - resetDebridLinkRuntimeStateForTests(); - resetMegaDebridRuntimeStateForTests(); - delete process.env.RD_MEGA_ABORT_MIN_RUN_MS; - vi.restoreAllMocks(); -}); +afterEach(() => { + globalThis.fetch = originalFetch; + resetDebridLinkRuntimeStateForTests(); + resetMegaDebridRuntimeStateForTests(); + delete process.env.RD_MEGA_ABORT_MIN_RUN_MS; + delete process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS; + vi.restoreAllMocks(); +}); describe("leadProviderChainWith", () => { it("leaves the order unchanged when no preferred provider is given", () => { @@ -1398,7 +1399,7 @@ describe("debrid service", () => { expect(megaWeb).toHaveBeenCalledTimes(1); }); - it("does not fallback from Mega API to Mega Web unless Mega Web is a separate provider in the order", async () => { + it("does not fallback from Mega API to Mega Web unless Mega Web is a separate provider in the order", async () => { const settings = { ...defaultSettings(), token: "", @@ -1426,8 +1427,183 @@ describe("debrid service", () => { const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); await expect(service.unrestrictLink("https://rapidgator.net/file/mega-api-only.rar.html")).rejects.toThrow(/mega-debrid api/i); - expect(megaWeb).toHaveBeenCalledTimes(0); - }); + expect(megaWeb).toHaveBeenCalledTimes(0); + }); + + it("isolates Mega-Debrid API single-flight consumers so one abort does not cancel the shared connect", async () => { + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaCredentials: "single-flight-user:single-flight-pass", + megaDebridApiCredentials: "single-flight-user:single-flight-pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + providerOrder: [] as const, + providerPrimary: "megadebrid-api" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + let releaseConnect: () => void = () => {}; + let markConnectStarted: () => void = () => {}; + const connectStarted = new Promise((resolve) => { + markConnectStarted = resolve; + }); + let connectSignal: AbortSignal | undefined; + let connectCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("action=connectUser")) { + connectCalls += 1; + connectSignal = init?.signal as AbortSignal | undefined; + markConnectStarted(); + await new Promise((resolve, reject) => { + const onAbort = (): void => reject(new Error("shared connect aborted")); + releaseConnect = (): void => { + connectSignal?.removeEventListener("abort", onAbort); + resolve(); + }; + if (connectSignal?.aborted) { + onAbort(); + return; + } + connectSignal?.addEventListener("abort", onAbort, { once: true }); + }); + return new Response(JSON.stringify({ response_code: "ok", token: "shared-token" }), { status: 200 }); + } + if (url.includes("action=getLink")) { + return new Response(JSON.stringify({ + response_code: "ok", + debridLink: "https://mega-cdn.example/survivor.rar", + filename: "survivor.rar" + }), { status: 200 }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(settings); + const firstController = new AbortController(); + const secondController = new AbortController(); + const first = service.unrestrictLink("https://rapidgator.net/file/first-consumer", firstController.signal); + await connectStarted; + const second = service.unrestrictLink("https://rapidgator.net/file/second-consumer", secondController.signal); + const firstOutcome = first.then(() => "fulfilled", () => "rejected"); + const secondOutcome = second.then( + (value) => ({ status: "fulfilled" as const, value }), + (error: unknown) => ({ status: "rejected" as const, error }) + ); + + try { + firstController.abort("cancel-first-consumer"); + let timeout: ReturnType | undefined; + const firstState = await Promise.race([ + firstOutcome, + new Promise<"pending">((resolve) => { + timeout = setTimeout(() => resolve("pending"), 100); + }) + ]); + if (timeout) { + clearTimeout(timeout); + } + expect(firstState).toBe("rejected"); + expect(connectSignal?.aborted).toBe(false); + expect(secondController.signal.aborted).toBe(false); + + releaseConnect(); + const survivor = await secondOutcome; + expect(survivor.status).toBe("fulfilled"); + if (survivor.status === "fulfilled") { + expect(survivor.value.directUrl).toBe("https://mega-cdn.example/survivor.rar"); + } + expect(connectCalls).toBe(1); + } finally { + releaseConnect(); + await Promise.all([firstOutcome, secondOutcome]); + } + }); + + it("does not cache a stale Mega-Debrid API token after credentials change during connect", async () => { + const oldSettings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaCredentials: "generation-user:old-pass", + megaDebridApiCredentials: "generation-user:old-pass", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + providerOrder: [] as const, + providerPrimary: "megadebrid-api" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + const newSettings = { + ...oldSettings, + megaCredentials: "generation-user:new-pass", + megaDebridApiCredentials: "generation-user:new-pass" + }; + let releaseOldConnect: () => void = () => {}; + let markOldConnectStarted: () => void = () => {}; + const oldConnectStarted = new Promise((resolve) => { + markOldConnectStarted = resolve; + }); + const connectPasswords: string[] = []; + const getLinkTokens: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("action=connectUser")) { + const parsed = new URL(url); + const password = parsed.searchParams.get("password") || ""; + connectPasswords.push(password); + if (password === "old-pass") { + markOldConnectStarted(); + await new Promise((resolve) => { + releaseOldConnect = resolve; + }); + return new Response(JSON.stringify({ response_code: "ok", token: "old-token" }), { status: 200 }); + } + return new Response(JSON.stringify({ response_code: "ok", token: "new-token" }), { status: 200 }); + } + if (url.includes("action=getLink")) { + const token = new URL(url).searchParams.get("token") || ""; + getLinkTokens.push(token); + return new Response(JSON.stringify({ + response_code: "ok", + debridLink: `https://mega-cdn.example/${token}.rar`, + filename: `${token}.rar` + }), { status: 200 }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const service = new DebridService(oldSettings); + const oldRequest = service.unrestrictLink("https://rapidgator.net/file/old-credentials"); + const oldOutcome = oldRequest.then( + (value) => ({ status: "fulfilled" as const, value }), + (error: unknown) => ({ status: "rejected" as const, error }) + ); + await oldConnectStarted; + + try { + service.setSettings(newSettings); + const current = await service.unrestrictLink("https://rapidgator.net/file/new-credentials"); + expect(current.directUrl).toBe("https://mega-cdn.example/new-token.rar"); + + releaseOldConnect(); + await oldOutcome; + + const subsequent = await service.unrestrictLink("https://rapidgator.net/file/subsequent"); + expect(subsequent.directUrl).toBe("https://mega-cdn.example/new-token.rar"); + expect(connectPasswords).toEqual(["old-pass", "new-pass"]); + expect(getLinkTokens.at(-1)).toBe("new-token"); + } finally { + releaseOldConnect(); + await oldOutcome; + } + }); it("treats a Mega-Debrid 'Fichier supprimé' as transient: no account cooldown, German message, retryable", async () => { const settings = { @@ -1691,12 +1867,11 @@ describe("debrid service", () => { }, 200); try { - await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i); - expect(megaWeb).toHaveBeenCalledTimes(1); - expect(megaWeb.mock.calls[0]?.[1]).toBe(controller.signal); - } finally { - clearTimeout(abortTimer); - } + await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i); + expect(megaWeb).toHaveBeenCalledTimes(1); + } finally { + clearTimeout(abortTimer); + } }); it("bleibt klebrig bei einem funktionierenden Account (kein Account-Wechsel pro Link)", async () => { @@ -2091,8 +2266,9 @@ describe("debrid service", () => { expect(acc!.cooldown!.message).toContain("Abbruch"); }); - it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => { - process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; + it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => { + process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; + process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20"; const settings = { ...defaultSettings(), token: "", @@ -2110,15 +2286,20 @@ describe("debrid service", () => { }; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; - const controller = new AbortController(); - let calls = 0; - const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => { - calls += 1; - if (calls === 1) { - controller.abort("simulated-60s-timeout"); - return Promise.reject(new Error("aborted")); - } - return Promise.resolve({ + let calls = 0; + const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => { + calls += 1; + if (calls === 1) { + return new Promise((_resolve, reject) => { + const onAbort = (): void => reject(new Error("aborted")); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + return Promise.resolve({ fileName: "healthy.rar", directUrl: "https://www11.unrestrict.link/download/file/ok/healthy.rar", fileSize: null, @@ -2128,7 +2309,7 @@ describe("debrid service", () => { const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); - const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e); + const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html").then(() => null, (e: unknown) => e); expect(err).toBeTruthy(); expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i); @@ -2280,7 +2461,7 @@ describe("debrid service", () => { expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true); }, 20000); - it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => { + it("rotates to the next Mega-Web account in the same unrestrict after an account timeout", async () => { process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout const settings = { ...defaultSettings(), @@ -2310,19 +2491,70 @@ describe("debrid service", () => { const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const user1Key = `${getMegaDebridAccountId("user1")}:web`; - // Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down. - await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow(); - expect(loginsSeen).toContain("user1"); - expect(loginsSeen).not.toContain("user2"); - expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull(); - - // Call 2 (the retry, same state): account 1 is on cooldown -> skipped -> account 2 served. - loginsSeen.length = 0; - const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-2"); - expect(loginsSeen).not.toContain("user1"); - expect(loginsSeen).toContain("user2"); - expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); - }, 20000); + const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-1"); + expect(loginsSeen).toContain("user1"); + expect(loginsSeen).toContain("user2"); + expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull(); + expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); + }, 20000); + + it("gives every Mega-Web account a fresh timeout budget after a queue timeout", async () => { + process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20"; + const settings = { + ...defaultSettings(), + token: "", + bestToken: "", + allDebridToken: "", + megaLogin: "user1", + megaPassword: "pass1", + megaCredentials: "user1:pass1\nuser2:pass2", + megaDebridPreferApi: false, + providerOrder: [] as const, + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + autoProviderFallback: false + }; + const loginsSeen: string[] = []; + const accountSignals: AbortSignal[] = []; + const outerController = new AbortController(); + const megaWeb = vi.fn(async (_link: string, signal?: AbortSignal, account?: { login: string; password: string }) => { + loginsSeen.push(account?.login || ""); + if (signal) { + accountSignals.push(signal); + } + if (account?.login === "user1") { + await new Promise((_resolve, reject) => { + const onAbort = (): void => reject(new Error("aborted:debrid")); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + if (signal?.aborted) { + throw new Error("account 2 received an aborted signal"); + } + return { + directUrl: "https://mega-web.example/fresh-account.rar", + fileName: "fresh-account.rar", + fileSize: null, + retriesUsed: 0 + }; + }); + const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); + + const result = await service.unrestrictLink("https://rapidgator.net/file/queue-timeout-rotation", outerController.signal); + + expect(loginsSeen).toEqual(["user1", "user2"]); + expect(accountSignals).toHaveLength(2); + expect(accountSignals[0]).not.toBe(accountSignals[1]); + expect(accountSignals[0].aborted).toBe(true); + expect(accountSignals[1].aborted).toBe(false); + expect(outerController.signal.aborted).toBe(false); + expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); + }, 20000); it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => { process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 8302b80..560b435 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -6,7 +6,7 @@ import crypto from "node:crypto"; import { EventEmitter, once } from "node:events"; import AdmZip from "adm-zip"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager"; +import { DownloadManager, buildAutoRenameBaseNameFromFoldersWithOptions, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, getDiskWriteWaitReason, getUnrestrictTimeoutMsForProviderPlan, isResumeHardResetReason, resolveArchiveItemsFromList, runWithLimitedConcurrency } from "../src/main/download-manager"; import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion"; import { DiskReservationCoordinator } from "../src/main/disk-space"; import { defaultSettings } from "../src/main/constants"; @@ -14,13 +14,13 @@ import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; -import { createStoragePaths, emptySession } from "../src/main/storage"; -import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid"; +import { createStoragePaths, emptySession, loadSession, saveSession } from "../src/main/storage"; +import { getMegaDebridAccountCooldownState, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; import { UnrestrictedLink } from "../src/main/realdebrid"; import { resetVideoToolingCache } from "../src/main/video-processor"; -import type { HistoryEntry, PackageEntry } from "../src/shared/types"; +import type { AppSettings, DebridProvider, HistoryEntry, PackageEntry } from "../src/shared/types"; const tempDirs: string[] = []; const originalFetch = globalThis.fetch; @@ -532,6 +532,43 @@ describe("disk write recovery", () => { }); }); +describe("resume recovery", () => { + it("preserves a partial file for one renewed link and resets after repeated range rejection", () => { + expect(isResumeHardResetReason("range_ignored_on_resume:100/200", 0)).toBe(false); + expect(isResumeHardResetReason("range_ignored_on_resume:100/200", 1)).toBe(true); + expect(isResumeHardResetReason("range_mismatch_on_resume:100/0", 1)).toBe(true); + expect(isResumeHardResetReason("resume_download_underflow:100/200", 0)).toBe(true); + }); + + it("budgets a later Mega fallback across every active account without a fifteen minute cap", () => { + process.env.RD_UNRESTRICT_TIMEOUT_MS = "60000"; + process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "120000"; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-unrestrict-budget-")); + tempDirs.push(root); + const settings: AppSettings = { + ...defaultSettings(), + bestToken: "best-token", + megaDebridWebCredentials: Array.from({ length: 8 }, (_, index) => `user-${index}:pass-${index}`).join("\n"), + megaDebridWebEnabled: true, + megaDebridApiEnabled: false, + providerOrder: ["megadebrid-web", "bestdebrid"], + autoProviderFallback: true, + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract") + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "fallback-budget", links: ["https://rapidgator.net/file/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"] }]); + const snapshot = manager.getSnapshot(); + const item = snapshot.session.items[snapshot.session.packages[snapshot.session.packageOrder[0]].itemIds[0]]; + const providerPlan = (manager as unknown as { + getReachableUnrestrictProviderPlan: (candidate: typeof item, preferredLeadProvider: DebridProvider | null) => DebridProvider[]; + }).getReachableUnrestrictProviderPlan(item, "bestdebrid"); + + expect(providerPlan).toEqual(["bestdebrid", "megadebrid-web"]); + expect(getUnrestrictTimeoutMsForProviderPlan(settings, providerPlan)).toBe(1_035_000); + }); +}); + describe("download start account gate", () => { it("disables every start path when no usable account is active", async () => { const createManager = () => { @@ -760,6 +797,8 @@ async function removeDirWithRetries(dir: string): Promise { afterEach(async () => { globalThis.fetch = originalFetch; + delete process.env.RD_UNRESTRICT_TIMEOUT_MS; + delete process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS; delete process.env.RD_FFMPEG_BIN; delete process.env.RD_FFPROBE_BIN; resetVideoToolingCache(); @@ -861,6 +900,155 @@ describe("download manager", () => { expect(invalidateMegaSession).toHaveBeenCalledTimes(1); }); + it("refreshes an active Mega-Debrid account pool without restarting the application", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-live-pool-refresh-")); + tempDirs.push(root); + const firstId = getMegaDebridAccountId("first-user"); + const settings = { + ...defaultSettings(), + megaCredentials: "first-user:first-pass\nsecond-user:second-pass", + megaDebridWebCredentials: "first-user:first-pass\nsecond-user:second-pass", + megaDebridWebEnabled: true + }; + const invalidateMegaSession = vi.fn(); + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession }); + manager.addPackages([{ name: "pool-refresh", links: ["https://rapidgator.net/file/pool-refresh"] }]); + const session = (manager as any).session; + const item = Object.values(session.items)[0] as any; + item.provider = "megadebrid-web"; + item.status = "validating"; + session.running = true; + const active = { + itemId: item.id, + packageId: item.packageId, + abortController: new AbortController(), + abortReason: "none", + resumable: true, + nonResumableCounted: false + }; + (manager as any).activeTasks.set(item.id, active); + const failures = (manager as any).providerFailures as Map; + primeMegaDebridRuntimeCooldownForTests(`${firstId}:web`, 120_000); + failures.set("megadebrid-web:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + failures.set("megadebrid-api:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + failures.set("realdebrid:rapidgator.net", { count: 3, lastFailAt: 1, cooldownUntil: Date.now() + 60_000 }); + + manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [firstId] }); + + expect(invalidateMegaSession).toHaveBeenCalledTimes(1); + expect(active.abortController.signal.aborted).toBe(true); + expect(active.abortReason).toBe("settings_refresh"); + expect(getMegaDebridAccountCooldownState(`${firstId}:web`)).toBeNull(); + expect(failures.has("megadebrid-web:rapidgator.net")).toBe(false); + expect(failures.has("megadebrid-api:rapidgator.net")).toBe(false); + expect(failures.has("realdebrid:rapidgator.net")).toBe(true); + }); + + it("continues a running Mega-Web download with the next enabled account after a live settings change", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-live-account-switch-")); + tempDirs.push(root); + const payload = Buffer.alloc(256 * 1024, 0x5a); + const server = http.createServer((_req, res) => { + res.writeHead(200, { + "Content-Length": String(payload.length), + "Content-Type": "application/octet-stream" + }); + res.end(payload); + }); + 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 directUrl = `http://127.0.0.1:${address.port}/account-two.bin`; + const firstAccountId = getMegaDebridAccountId("first-user"); + const accountCalls: string[] = []; + const settings = { + ...defaultSettings(), + megaCredentials: "first-user:first-pass\nsecond-user:second-pass", + megaDebridWebCredentials: "first-user:first-pass\nsecond-user:second-pass", + megaDebridWebEnabled: true, + megaDebridApiEnabled: false, + megaDebridPreferApi: false, + providerOrder: [], + providerPrimary: "megadebrid" as const, + providerSecondary: "none" as const, + providerTertiary: "none" as const, + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 1 + }; + const manager = new DownloadManager(settings, emptySession(), createStoragePaths(path.join(root, "state")), { + invalidateMegaSession: vi.fn(), + megaWebUnrestrict: vi.fn(async (_link: string, signal?: AbortSignal, account?: { login: string; password: string }) => { + const login = account?.login || ""; + accountCalls.push(login); + if (login === "first-user") { + return await new Promise((_resolve, reject) => { + const onAbort = (): void => reject(new Error("aborted:settings-refresh")); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + return { + fileName: "account-two.bin", + directUrl, + fileSize: payload.length, + retriesUsed: 0 + }; + }) + }); + manager.addPackages([{ name: "live-account-switch", links: ["https://rapidgator.net/file/live-account-switch"] }]); + + try { + await manager.start(); + await waitFor(() => accountCalls.includes("first-user"), 10_000); + + manager.setSettings({ ...settings, megaDebridWebDisabledAccountIds: [firstAccountId] }); + + await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.status === "completed"), 15_000); + expect(accountCalls).toEqual(["first-user", "second-user"]); + expect(fs.readFileSync(path.join(root, "downloads", "live-account-switch", "account-two.bin"))).toEqual(payload); + } finally { + manager.stop(); + server.close(); + await once(server, "close"); + } + }, 20_000); + + it("aborts active work immediately when downloads are paused", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pause-active-")); + tempDirs.push(root); + const invalidateMegaSession = vi.fn(); + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")), { invalidateMegaSession }); + manager.addPackages([{ name: "pause-active", links: ["https://rapidgator.net/file/pause-active"] }]); + const session = (manager as any).session; + const item = Object.values(session.items)[0] as any; + item.status = "validating"; + session.running = true; + const active = { + itemId: item.id, + packageId: item.packageId, + abortController: new AbortController(), + abortReason: "none", + resumable: true, + nonResumableCounted: false + }; + (manager as any).activeTasks.set(item.id, active); + + expect(manager.togglePause()).toBe(true); + + expect(active.abortController.signal.aborted).toBe(true); + expect(active.abortReason).toBe("pause"); + expect(invalidateMegaSession).toHaveBeenCalledTimes(1); + }); + it("releases only Mega-Debrid reset parks when a newly usable account appears", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-account-refresh-")); tempDirs.push(root); @@ -2435,7 +2623,7 @@ describe("download manager", () => { } }); - it("preserves partial files and requests a fresh direct link when resume gets HTTP 200", async () => { + it("preserves partial files and requests a fresh direct link when resume gets HTTP 200", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const binary = Buffer.alloc(256 * 1024, 21); @@ -2581,10 +2769,138 @@ describe("download manager", () => { } finally { server.close(); await once(server, "close"); - } - }); - - it("treats tiny Real-Debrid resume size mismatches as completed instead of looping", async () => { + } + }); + + it("restores a persisted Mega-Web partial and hard-resets after two HTTP 200 range rejections", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-resume-restore-")); + tempDirs.push(root); + const binary = Buffer.alloc(192 * 1024, 37); + const partialSize = 64 * 1024; + const packageId = "mega-resume-restore-package"; + const itemId = "mega-resume-restore-item"; + const outputDir = path.join(root, "downloads", "mega-resume-restore"); + const targetPath = path.join(outputDir, "mega-resume-restore.mkv"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(targetPath, binary.subarray(0, partialSize)); + const rangeHeaders: string[] = []; + + const server = http.createServer((req, res) => { + if (req.method === "GET") { + rangeHeaders.push(String(req.headers.range || "")); + } + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/mega-resume-restore`; + + try { + const session = emptySession(); + const createdAt = Date.now() - 10_000; + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "mega-resume-restore", + outputDir, + extractDir: path.join(root, "extract", "mega-resume-restore"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/mega-resume-restore", + provider: "megadebrid-web", + status: "queued", + retries: 1, + speedBps: 0, + downloadedBytes: partialSize, + totalBytes: binary.length, + progressPercent: Math.floor((partialSize / binary.length) * 100), + fileName: "mega-resume-restore.mkv", + targetPath, + resumable: true, + attempts: 0, + lastError: `range_ignored_on_resume:${partialSize}/${binary.length}`, + fullStatus: "Resume-Link erneuern, Retry 1/3", + onlineStatus: "online", + createdAt, + updatedAt: createdAt + }; + const storagePaths = createStoragePaths(path.join(root, "state")); + saveSession(storagePaths, session); + const megaWebUnrestrict = vi.fn(async (): Promise => ({ + directUrl, + fileName: "mega-resume-restore.mkv", + fileSize: binary.length, + retriesUsed: 0 + })); + const manager = new DownloadManager( + { + ...defaultSettings(), + megaDebridWebCredentials: "mega-user:mega-pass", + megaDebridWebEnabled: true, + megaDebridApiEnabled: false, + megaDebridPreferApi: false, + providerOrder: ["megadebrid-web"], + autoProviderFallback: false, + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 3, + autoExtract: false, + autoReconnect: false, + enableIntegrityCheck: false, + maxParallel: 1 + }, + loadSession(storagePaths), + storagePaths, + { megaWebUnrestrict } + ); + + expect(manager.getSnapshot().session.items[itemId]).toEqual(expect.objectContaining({ + status: "queued", + downloadedBytes: partialSize, + targetPath, + onlineStatus: "online" + })); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 25000); + + const restoredItem = manager.getSnapshot().session.items[itemId]; + expect(restoredItem).toEqual(expect.objectContaining({ + status: "completed", + downloadedBytes: binary.length, + onlineStatus: "online" + })); + expect(megaWebUnrestrict).toHaveBeenCalledTimes(3); + expect(rangeHeaders).toEqual([ + `bytes=${partialSize}-`, + `bytes=${partialSize}-`, + "" + ]); + expect(fs.readFileSync(targetPath)).toEqual(binary); + } finally { + server.close(); + await once(server, "close"); + } + }); + + it("treats tiny Real-Debrid resume size mismatches as completed instead of looping", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const actual = Buffer.alloc(192 * 1024, 17); @@ -8323,11 +8639,73 @@ describe("download manager", () => { progressPercent: 0, lastError: "", fullStatus: "Wartet", - onlineStatus: undefined + onlineStatus: "online" })); } }); + it("preserves a known online status when resetting a whole package", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-reset-package-online-")); + tempDirs.push(root); + const session = emptySession(); + const packageId = "reset-package-online"; + const itemId = "reset-package-online-item"; + const createdAt = Date.now(); + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "reset-package-online", + outputDir: path.join(root, "downloads", "reset-package-online"), + extractDir: path.join(root, "extract", "reset-package-online"), + status: "failed", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/reset-package-online", + provider: "megadebrid-web", + status: "failed", + retries: 3, + speedBps: 0, + downloadedBytes: 512, + totalBytes: 1_024, + progressPercent: 50, + fileName: "reset-package-online.bin", + targetPath: "", + resumable: true, + attempts: 3, + lastError: "range_ignored_on_resume:512/1024", + fullStatus: "Fehler", + onlineStatus: "online", + createdAt, + updatedAt: createdAt + }; + const manager = new DownloadManager( + { + ...defaultSettings(), + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.resetPackage(packageId); + + expect(manager.getSnapshot().session.items[itemId]).toEqual(expect.objectContaining({ + status: "queued", + downloadedBytes: 0, + fullStatus: "Wartet", + onlineStatus: "online" + })); + }); + it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 240e797..ef89995 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -708,7 +708,7 @@ describe("downloads view", () => { expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect"); }); - it("blocks resume without a usable account and keeps pause independent from unrelated action busy state", () => { + it("blocks resume without a usable account and keeps lifecycle controls independent from unrelated action busy state", () => { const pausedToolbar = DownloadsToolbar({ actions: createActions(), model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true }) @@ -721,6 +721,7 @@ describe("downloads view", () => { expect(findButton(pausedToolbar, "Start").props.disabled).toBe(true); expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true); expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false); + expect(findButton(busyToolbar, "Stop").props.disabled).toBe(false); }); it("enables package movement only for a visible selected package row", () => { diff --git a/tests/mega-web-fallback.test.ts b/tests/mega-web-fallback.test.ts index 6ac568c..b70fcbe 100644 --- a/tests/mega-web-fallback.test.ts +++ b/tests/mega-web-fallback.test.ts @@ -45,7 +45,7 @@ describe("mega-web-fallback", () => { generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string } | null>; sessions: Map; }; - vi.spyOn(internals, "login") + const login = vi.spyOn(internals, "login") .mockResolvedValueOnce("first-stale-cookie") .mockResolvedValueOnce("second-stale-cookie"); vi.spyOn(internals, "generate") @@ -55,12 +55,58 @@ describe("mega-web-fallback", () => { }) .mockResolvedValueOnce({ directUrl: "https://mega.direct/retry", fileName: "retry.bin" }); - const result = await fallback.unrestrict("https://mega.debrid/retry", undefined, { login: "old-user", password: "old-pass" }); + await expect(fallback.unrestrict("https://mega.debrid/retry", undefined, { login: "old-user", password: "old-pass" })) + .rejects.toThrow(/aborted/i); - expect(result?.directUrl).toBe("https://mega.direct/retry"); + expect(login).toHaveBeenCalledTimes(1); expect(internals.sessions.size).toBe(0); }); + it("keeps a fresh session when an invalidated older request finishes later", async () => { + let releaseOldGenerate: () => void = () => {}; + let markOldGenerateStarted: () => void = () => {}; + const oldGenerateGate = new Promise((resolve) => { + releaseOldGenerate = resolve; + }); + const oldGenerateStarted = new Promise((resolve) => { + markOldGenerateStarted = resolve; + }); + const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" })); + const internals = fallback as unknown as { + login: (login: string, password: string, signal?: AbortSignal) => Promise; + generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string } | null>; + sessions: Map; + }; + const login = vi.spyOn(internals, "login") + .mockResolvedValueOnce("old-cookie") + .mockResolvedValueOnce("fresh-cookie"); + vi.spyOn(internals, "generate").mockImplementation(async (link) => { + if (link.includes("old")) { + markOldGenerateStarted(); + await oldGenerateGate; + return null; + } + return { directUrl: "https://mega.direct/fresh", fileName: "fresh.bin" }; + }); + + const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" }); + const oldSettled = oldRequest.catch((error: unknown) => error); + await oldGenerateStarted; + fallback.invalidateSession(); + const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" }); + + try { + await expect(freshRequest).resolves.toMatchObject({ directUrl: "https://mega.direct/fresh" }); + expect(internals.sessions.get("same-user")?.cookie).toBe("fresh-cookie"); + } finally { + releaseOldGenerate(); + } + + await oldSettled; + expect(internals.sessions.get("same-user")?.cookie).toBe("fresh-cookie"); + expect(login).toHaveBeenCalledTimes(2); + }); + it("does not cache old credentials from a queued request after invalidation", async () => { let releaseFirstLogin: () => void = () => {}; let markFirstLoginStarted: () => void = () => {}; @@ -88,16 +134,162 @@ describe("mega-web-fallback", () => { vi.spyOn(internals, "generate").mockResolvedValue({ directUrl: "https://mega.direct/queued", fileName: "queued.bin" }); const first = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "old-user", password: "old-pass" }); + const firstSettled = first.catch((error: unknown) => error); await firstLoginStarted; const queued = fallback.unrestrict("https://mega.debrid/queued", undefined, { login: "old-user", password: "old-pass" }); + const queuedSettled = queued.catch((error: unknown) => error); fallback.invalidateSession(); releaseFirstLogin(); - await expect(Promise.all([first, queued])).resolves.toHaveLength(2); - expect(loginCount).toBe(2); + await expect(firstSettled).resolves.toBeInstanceOf(Error); + await expect(queuedSettled).resolves.toBeInstanceOf(Error); + expect(loginCount).toBe(1); expect(internals.sessions.size).toBe(0); }); + it("does not let a blocked old account job block a new request after session invalidation", async () => { + let releaseOldLogin: () => void = () => {}; + let markOldLoginStarted: () => void = () => {}; + const oldLoginGate = new Promise((resolve) => { + releaseOldLogin = resolve; + }); + const oldLoginStarted = new Promise((resolve) => { + markOldLoginStarted = resolve; + }); + const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" })); + const internals = fallback as unknown as { + login: (login: string, password: string, signal?: AbortSignal) => Promise; + generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>; + }; + const login = vi.spyOn(internals, "login") + .mockImplementationOnce(async () => { + markOldLoginStarted(); + await oldLoginGate; + return "old-cookie"; + }) + .mockResolvedValueOnce("new-cookie"); + vi.spyOn(internals, "generate").mockImplementation(async (link) => ({ + directUrl: link.includes("fresh") ? "https://mega.direct/fresh" : "https://mega.direct/old", + fileName: link.includes("fresh") ? "fresh.bin" : "old.bin" + })); + + const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" }); + const oldSettled = oldRequest.catch((error: unknown) => error); + await oldLoginStarted; + fallback.invalidateSession(); + const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" }); + + try { + await vi.waitFor(() => expect(login).toHaveBeenCalledTimes(2), { timeout: 500, interval: 5 }); + await expect(freshRequest).resolves.toMatchObject({ + directUrl: "https://mega.direct/fresh", + fileName: "fresh.bin" + }); + } finally { + releaseOldLogin(); + await Promise.allSettled([oldSettled, freshRequest]); + } + }); + + it("does not execute provider work for a queued request aborted before it starts", async () => { + let releaseFirstLogin: () => void = () => {}; + let markFirstLoginStarted: () => void = () => {}; + const firstLoginGate = new Promise((resolve) => { + releaseFirstLogin = resolve; + }); + const firstLoginStarted = new Promise((resolve) => { + markFirstLoginStarted = resolve; + }); + const generatedLinks: string[] = []; + const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" })); + const internals = fallback as unknown as { + login: (login: string, password: string, signal?: AbortSignal) => Promise; + generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>; + queues: Map>; + }; + const login = vi.spyOn(internals, "login").mockImplementation(async () => { + markFirstLoginStarted(); + await firstLoginGate; + return "first-cookie"; + }); + vi.spyOn(internals, "generate").mockImplementation(async (link) => { + generatedLinks.push(link); + return { directUrl: "https://mega.direct/first", fileName: "first.bin" }; + }); + + const firstRequest = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "same-user", password: "same-pass" }); + await firstLoginStarted; + const queuedController = new AbortController(); + const queuedRequest = fallback.unrestrict("https://mega.debrid/aborted", queuedController.signal, { login: "same-user", password: "same-pass" }); + const queueTail = internals.queues.get("same-user"); + queuedController.abort("cancel-before-start"); + + await expect(queuedRequest).rejects.toThrow(/queue.?timeout/i); + releaseFirstLogin(); + await expect(firstRequest).resolves.toMatchObject({ directUrl: "https://mega.direct/first" }); + await queueTail; + + expect(login).toHaveBeenCalledTimes(1); + expect(generatedLinks).toEqual(["https://mega.debrid/first"]); + }); + + it("starts a new request normally after invalidating a queue with an aborted waiter", async () => { + let releaseOldLogin: () => void = () => {}; + let markOldLoginStarted: () => void = () => {}; + const oldLoginGate = new Promise((resolve) => { + releaseOldLogin = resolve; + }); + const oldLoginStarted = new Promise((resolve) => { + markOldLoginStarted = resolve; + }); + const generatedLinks: string[] = []; + const fallback = new MegaWebFallback(() => ({ login: "same-user", password: "same-pass" })); + const internals = fallback as unknown as { + login: (login: string, password: string, signal?: AbortSignal) => Promise; + generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>; + queues: Map>; + }; + const login = vi.spyOn(internals, "login") + .mockImplementationOnce(async () => { + markOldLoginStarted(); + await oldLoginGate; + return "old-cookie"; + }) + .mockResolvedValueOnce("fresh-cookie"); + vi.spyOn(internals, "generate").mockImplementation(async (link) => { + generatedLinks.push(link); + return { + directUrl: link.includes("fresh") ? "https://mega.direct/fresh" : "https://mega.direct/old", + fileName: link.includes("fresh") ? "fresh.bin" : "old.bin" + }; + }); + + const oldRequest = fallback.unrestrict("https://mega.debrid/old", undefined, { login: "same-user", password: "same-pass" }); + const oldSettled = oldRequest.catch((error: unknown) => error); + await oldLoginStarted; + const queuedController = new AbortController(); + const queuedRequest = fallback.unrestrict("https://mega.debrid/aborted", queuedController.signal, { login: "same-user", password: "same-pass" }); + const staleQueueTail = internals.queues.get("same-user"); + queuedController.abort("cancel-before-start"); + await expect(queuedRequest).rejects.toThrow(/queue.?timeout/i); + + fallback.invalidateSession(); + const freshRequest = fallback.unrestrict("https://mega.debrid/fresh", undefined, { login: "same-user", password: "same-pass" }); + + try { + await vi.waitFor(() => expect(login).toHaveBeenCalledTimes(2), { timeout: 500, interval: 5 }); + await expect(freshRequest).resolves.toMatchObject({ + directUrl: "https://mega.direct/fresh", + fileName: "fresh.bin" + }); + } finally { + releaseOldLogin(); + await Promise.allSettled([oldSettled, freshRequest, staleQueueTail]); + } + + expect(generatedLinks).toEqual(["https://mega.debrid/fresh"]); + }); + it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => { let fetchCallCount = 0; globalThis.fetch = vi.fn(async (url: string | URL | Request) => { diff --git a/tests/statistics-view.test.tsx b/tests/statistics-view.test.tsx index 3f5c100..95e16a3 100644 --- a/tests/statistics-view.test.tsx +++ b/tests/statistics-view.test.tsx @@ -482,7 +482,7 @@ describe("bandwidth chart palette", () => { expect(chartBlock).toContain('role="img"'); expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"'); expect(chartBlock).toContain('window.matchMedia("(prefers-reduced-motion: reduce)")'); - expect(chartBlock).toContain("reducedMotion ? 1000 : 250"); + expect(chartBlock).toContain("reducedMotion ? 1000 : 500"); }); it("asks for confirmation before deleting all saved download statistics", () => { diff --git a/tests/support-bundle.test.ts b/tests/support-bundle.test.ts index bc3cf31..49e98c5 100644 --- a/tests/support-bundle.test.ts +++ b/tests/support-bundle.test.ts @@ -2,20 +2,26 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import AdmZip from "adm-zip"; -import { afterEach, describe, expect, it } from "vitest"; -import { buildSupportBundle } from "../src/main/support-bundle"; -import type { DownloadManager } from "../src/main/download-manager"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildSupportBundle, + createSupportBundleExportRunner, + writeSupportBundleAtomically +} from "../src/main/support-bundle"; +import type { DownloadManager } from "../src/main/download-manager"; +import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log"; const tempDirs: string[] = []; const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join(""); -afterEach(() => { - for (const dir of tempDirs.splice(0)) { +afterEach(() => { + shutdownSessionLog(); + for (const dir of tempDirs.splice(0)) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { } } }); -function fakeManager(): DownloadManager { +function fakeManager(): DownloadManager { const snapshot = { stats: {}, session: { packages: {}, items: {}, packageOrder: [] }, @@ -30,7 +36,100 @@ function fakeManager(): DownloadManager { getPackageLogPath: () => null, getItemLogPath: () => null } as unknown as DownloadManager; -} +} + +function populatedFakeManager(): DownloadManager { + const snapshot = { + stats: {}, + session: { + packages: { + "package-1": { id: "package-1", name: "Paket", itemIds: ["item-1"] } + }, + items: { + "item-1": { id: "item-1", packageId: "package-1", fileName: "datei.bin" } + }, + packageOrder: ["package-1"] + }, + speedText: "", + etaText: "", + canStart: false, + canStop: false, + canPause: false + }; + return { + getSnapshot: () => snapshot, + getPackageLogPath: () => { throw new Error("package log getter must not run"); }, + getItemLogPath: () => { throw new Error("item log getter must not run"); } + } as unknown as DownloadManager; +} + +function sensitiveActiveManager(itemCount = 1): DownloadManager { + const packages = { + "package-sensitive": { + id: "package-sensitive", + name: "Private Collection", + outputDir: "C:\\Users\\Alice\\Downloads\\Private Collection", + extractDir: "C:\\Users\\Alice\\Extracted\\Private Collection", + status: "downloading", + itemIds: Array.from({ length: itemCount }, (_, index) => `item-${index}`), + cancelled: false, + enabled: true, + cleanedUrls: ["https://files.example.test/archive?api_key=cleaned-secret#private"], + createdAt: 1, + updatedAt: 2 + } + }; + const items = Object.fromEntries(Array.from({ length: itemCount }, (_, index) => [ + `item-${index}`, + { + id: `item-${index}`, + packageId: "package-sensitive", + url: `https://url-user:url-pass@files.example.test/archive-${index}?token=query-secret-${index}#fragment-secret`, + provider: "realdebrid", + status: "downloading", + retries: 0, + speedBps: 1024, + downloadedBytes: index, + totalBytes: 2048, + progressPercent: 50, + fileName: `private-${index}.bin`, + targetPath: `C:\\Users\\Alice\\Downloads\\Private Collection\\private-${index}.bin`, + resumable: true, + attempts: 1, + lastError: "Authorization: Bearer item-bearer-secret", + fullStatus: "Cookie: session=item-cookie-secret", + createdAt: 1, + updatedAt: index + 2 + } + ])); + const snapshot = { + stats: {}, + session: { + version: 1, + packageOrder: ["package-sensitive"], + packages, + items, + runStartedAt: 1, + totalDownloadedBytes: 10, + summaryText: "password=summary-secret", + reconnectUntil: 0, + reconnectReason: "api_key=reconnect-secret", + paused: false, + running: true, + updatedAt: 2 + }, + speedText: "Geschwindigkeit: 1 KB/s", + etaText: "ETA: 1m", + canStart: false, + canStop: true, + canPause: true + }; + return { + getSnapshot: () => snapshot, + getPackageLogPath: () => { throw new Error("package log getter must not run"); }, + getItemLogPath: () => { throw new Error("item log getter must not run"); } + } as unknown as DownloadManager; +} describe("buildSupportBundle (async, non-blocking)", () => { it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => { @@ -60,7 +159,7 @@ describe("buildSupportBundle (async, non-blocking)", () => { expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test"); }); - it("does not block the event loop while building (a concurrent timer still fires)", async () => { + it("does not block the event loop while building (a concurrent timer still fires)", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); tempDirs.push(root); @@ -69,6 +168,312 @@ describe("buildSupportBundle (async, non-blocking)", () => { await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" }); clearTimeout(timer); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(timerFired).toBe(true); - }); -}); + expect(timerFired).toBe(true); + }); + + it("includes only recent directory logs without live duplicates or log getter side effects", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); + tempDirs.push(root); + const itemLogs = path.join(root, "item-logs"); + fs.mkdirSync(itemLogs, { recursive: true }); + const recentLog = path.join(itemLogs, "recent.txt"); + const oldLog = path.join(itemLogs, "old.txt"); + fs.writeFileSync(recentLog, "recent", "utf8"); + fs.writeFileSync(oldLog, "old", "utf8"); + const oldTimestamp = new Date(Date.now() - 9 * 60 * 60 * 1000); + fs.utimesSync(oldLog, oldTimestamp, oldTimestamp); + + const buffer = await buildSupportBundle(populatedFakeManager(), root, { + hostDiagnosticsMode: "none", + debugSetupMode: "deferred" + }); + const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName); + + expect(entries).toContain("logs/item-logs/recent.txt"); + expect(entries).not.toContain("logs/item-logs/old.txt"); + expect(entries.some((entry) => entry.startsWith("logs/live/"))).toBe(false); + }); + + it("includes each physical session log only once", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); + tempDirs.push(root); + initSessionLog(root); + const sessionLogPath = getSessionLogPath(); + expect(sessionLogPath).not.toBeNull(); + + const buffer = await buildSupportBundle(fakeManager(), root, { + hostDiagnosticsMode: "none", + debugSetupMode: "deferred" + }); + const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName); + const sessionEntries = entries.filter((entry) => ( + entry === "logs/session.log" || entry.startsWith("logs/session-logs/") + )); + + expect(sessionEntries).toHaveLength(1); + }); + + it("bounds recent item logs to the newest diagnostic files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); + tempDirs.push(root); + const itemLogs = path.join(root, "item-logs"); + fs.mkdirSync(itemLogs, { recursive: true }); + const baseTime = Date.now() - 60_000; + for (let index = 0; index < 365; index += 1) { + const filePath = path.join(itemLogs, `item-${String(index).padStart(3, "0")}.txt`); + fs.writeFileSync(filePath, String(index), "utf8"); + const timestamp = new Date(baseTime + index); + fs.utimesSync(filePath, timestamp, timestamp); + } + + const buffer = await buildSupportBundle(fakeManager(), root, { + hostDiagnosticsMode: "none", + debugSetupMode: "deferred" + }); + const entries = new AdmZip(buffer).getEntries().map((entry) => entry.entryName); + const itemEntries = entries.filter((entry) => entry.startsWith("logs/item-logs/")); + + expect(itemEntries).toHaveLength(16); + expect(itemEntries).not.toContain("logs/item-logs/item-000.txt"); + expect(itemEntries).toContain("logs/item-logs/item-364.txt"); + }); + + it("redacts active DTOs, runtime text and logs at the ZIP boundary", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-sensitive-")); + tempDirs.push(root); + fs.writeFileSync(path.join(root, "rd_downloader_config.json"), JSON.stringify({ + megaDebridWebCredentials: "primary-user:primary-password-secret\nsecondary-user:secondary-password-secret", + megaDebridWebEnabled: true + }), "utf8"); + const itemLogs = path.join(root, "item-logs"); + fs.mkdirSync(itemLogs, { recursive: true }); + fs.writeFileSync(path.join(itemLogs, "token=filename-secret.log"), [ + "Authorization: Bearer log-bearer-secret", + "Cookie: session=log-cookie-secret; auth=second-cookie-secret", + "username=account-secret", + "password=log-password-secret", + "api_key=log-api-key-secret", + "provider rejected secondary-password-secret during rotation", + "https://log-user:log-pass@files.example.test/archive?token=log-query-secret#log-fragment-secret", + "C:\\Users\\Alice\\Downloads\\Private Collection\\private.bin" + ].join("\n"), "utf8"); + fs.writeFileSync(path.join(root, "debug_support_manifest.json"), JSON.stringify({ + authorization: "Bearer manifest-bearer-secret", + supportManifestPath: "C:\\Users\\Alice\\AppData\\debug_support_manifest.json", + endpoint: "https://support.example.test/check?api_key=manifest-query-secret#manifest-fragment" + }), "utf8"); + + const buffer = await buildSupportBundle(sensitiveActiveManager(), root, { + hostDiagnosticsMode: "none", + debugSetupMode: "deferred" + }); + const zip = new AdmZip(buffer); + const archiveText = zip.getEntries() + .map((entry) => `${entry.entryName}\n${entry.getData().toString("utf8")}`) + .join("\n"); + const forbidden = [ + root, + "C:\\Users\\Alice", + "url-user", + "url-pass", + "query-secret-0", + "fragment-secret", + "item-bearer-secret", + "item-cookie-secret", + "summary-secret", + "reconnect-secret", + "cleaned-secret", + "filename-secret", + "log-bearer-secret", + "log-cookie-secret", + "second-cookie-secret", + "account-secret", + "log-password-secret", + "log-api-key-secret", + "primary-password-secret", + "secondary-password-secret", + "log-query-secret", + "log-fragment-secret", + "manifest-bearer-secret", + "manifest-query-secret", + "manifest-fragment" + ]; + + for (const secret of forbidden) { + expect(archiveText).not.toContain(secret); + } + expect(archiveText).toContain(""); + expect(archiveText).toContain(""); + const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as { + items?: Array>; + }; + expect(itemOverview.items?.[0]).toMatchObject({ sourceHost: "files.example.test" }); + expect(itemOverview.items?.[0]).not.toHaveProperty("url"); + }); + + it("bounds active DTOs and recent log tails while keeping the event loop responsive", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-load-")); + tempDirs.push(root); + const itemLogs = path.join(root, "item-logs"); + fs.mkdirSync(itemLogs, { recursive: true }); + const payload = `${"x".repeat(512 * 1024)}\napi_key=tail-secret`; + for (let index = 0; index < 48; index += 1) { + fs.writeFileSync(path.join(itemLogs, `active-${String(index).padStart(2, "0")}.log`), payload, "utf8"); + } + const oldRotatedLog = path.join(itemLogs, "rotated.log.old"); + fs.writeFileSync(oldRotatedLog, "api_key=old-secret", "utf8"); + const oldTimestamp = new Date(Date.now() - 9 * 60 * 60 * 1000); + fs.utimesSync(oldRotatedLog, oldTimestamp, oldTimestamp); + + const timerGaps: number[] = []; + let lastTick = Date.now(); + const timer = setInterval(() => { + const now = Date.now(); + timerGaps.push(now - lastTick); + lastTick = now; + }, 5); + const buffer = await buildSupportBundle(sensitiveActiveManager(1_800), root, { + hostDiagnosticsMode: "none", + debugSetupMode: "deferred" + }); + clearInterval(timer); + + const zip = new AdmZip(buffer); + const logEntries = zip.getEntries().filter((entry) => entry.entryName.startsWith("logs/item-logs/")); + const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as { + count?: number; + included?: number; + omitted?: number; + items?: unknown[]; + }; + const totalUncompressedBytes = zip.getEntries().reduce((sum, entry) => sum + entry.getData().length, 0); + + expect(logEntries.length).toBeLessThanOrEqual(16); + expect(Math.max(...logEntries.map((entry) => entry.getData().length))).toBeLessThanOrEqual(256 * 1024); + expect(logEntries.some((entry) => entry.entryName.includes("rotated"))).toBe(false); + expect(itemOverview.count).toBe(1_800); + expect(itemOverview.items?.length).toBeLessThanOrEqual(500); + expect(itemOverview.omitted).toBeGreaterThan(0); + expect(totalUncompressedBytes).toBeLessThan(8 * 1024 * 1024); + expect(timerGaps.length).toBeGreaterThan(2); + expect(Math.max(...timerGaps)).toBeLessThan(100); + }, 15_000); +}); + +describe("support bundle export runner", () => { + it("returns a visible busy result for reentry without choosing another target", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-")); + tempDirs.push(root); + const target = path.join(root, "support.zip"); + let chooseCount = 0; + let releaseBuild: (buffer: Buffer) => void = () => undefined; + let signalBuildStarted: () => void = () => undefined; + const buildStarted = new Promise((resolve) => { signalBuildStarted = resolve; }); + const buildPending = new Promise((resolve) => { releaseBuild = resolve; }); + const run = createSupportBundleExportRunner({ + chooseFile: async () => { + chooseCount += 1; + return target; + }, + build: async () => { + signalBuildStarted(); + return buildPending; + }, + write: async () => undefined + }); + + const first = run(); + await buildStarted; + const second = await run(); + + expect(second).toEqual({ + saved: false, + busy: true, + message: "Support-Bundle wird bereits erstellt." + }); + expect(chooseCount).toBe(1); + + releaseBuild(Buffer.from("zip")); + await expect(first).resolves.toEqual({ saved: true, busy: false, filePath: target }); + }); + + it("reports success only after the target write has completed", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-")); + tempDirs.push(root); + const target = path.join(root, "support.zip"); + let releaseWrite: () => void = () => undefined; + let signalWriteStarted: () => void = () => undefined; + let successCount = 0; + const writeStarted = new Promise((resolve) => { signalWriteStarted = resolve; }); + const writePending = new Promise((resolve) => { releaseWrite = resolve; }); + const run = createSupportBundleExportRunner({ + chooseFile: async () => target, + build: async () => Buffer.from("zip"), + write: async () => { + signalWriteStarted(); + await writePending; + }, + onSuccess: async () => { + successCount += 1; + } + }); + + const result = run(); + await writeStarted; + expect(successCount).toBe(0); + + releaseWrite(); + await expect(result).resolves.toEqual({ saved: true, busy: false, filePath: target }); + expect(successCount).toBe(1); + }); + + it("releases the busy guard after an export failure", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-")); + tempDirs.push(root); + const target = path.join(root, "support.zip"); + let writeCount = 0; + const run = createSupportBundleExportRunner({ + chooseFile: async () => target, + build: async () => Buffer.from("zip"), + write: async () => { + writeCount += 1; + if (writeCount === 1) { + throw new Error("write failed"); + } + } + }); + + await expect(run()).rejects.toThrow("write failed"); + await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target }); + }); + + it("releases the busy guard after the target dialog is canceled", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-")); + tempDirs.push(root); + const target = path.join(root, "support.zip"); + let chooseCount = 0; + const run = createSupportBundleExportRunner({ + chooseFile: async () => { + chooseCount += 1; + return chooseCount === 1 ? null : target; + }, + build: async () => Buffer.from("zip"), + write: async () => undefined + }); + + await expect(run()).resolves.toEqual({ saved: false, busy: false }); + await expect(run()).resolves.toEqual({ saved: true, busy: false, filePath: target }); + }); + + it("replaces the target atomically without leaving temporary files", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-export-")); + tempDirs.push(root); + const target = path.join(root, "support.zip"); + fs.writeFileSync(target, "old", "utf8"); + + await writeSupportBundleAtomically(target, Buffer.from("new")); + + expect(fs.readFileSync(target, "utf8")).toBe("new"); + expect(fs.readdirSync(root)).toEqual(["support.zip"]); + }); +}); diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index 9822b57..aa277c1 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -139,6 +139,7 @@ export function createVisualElectronApi( fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive; return fixture.snapshot.clipboardActive; }, + writeClipboardText: async () => true, pickFolder: async () => "C:\\Visual\\Selected", pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"], getSessionStats: async () => ({