fix: harden account rotation and active download recovery

Apply account enablement changes optimistically and persist rapid toggles sequentially without losing clicks.

Refresh Mega-Debrid API and Web account pools during active downloads, isolate per-account cancellation budgets, rotate within the same conversion attempt, and keep pause, stop, resume, reset, and no-account gates consistent.

Use the native Electron clipboard under Remote Desktop and make support bundle export bounded, redacted, responsive, guarded, visible, and atomic.

Add end-to-end regressions for live account switching, persisted partial recovery, support export, native copy, availability preservation, and the 500 ms telemetry cadence.
This commit is contained in:
Sucukdeluxe
2026-08-13 05:14:44 +02:00
parent ca015553f3
commit e1b4708952
26 changed files with 2811 additions and 561 deletions
+24
View File
@@ -2,6 +2,30 @@
All notable changes to Multi-Debrid Downloader are documented in this file. 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 ## [2.0.28] - 2026-08-12
### Startup reliability ### Startup reliability
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.28", "version": "2.0.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.28", "version": "2.0.29",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "0.6.0", "adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.28", "version": "2.0.29",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
+24 -5
View File
@@ -860,17 +860,36 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
} }
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> { public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
this.audit("INFO", "Support-Bundle exportiert"); const buffer = await buildSupportBundle(this.manager, this.storagePaths.baseDir, {
logTraceEvent("INFO", "support", "Support-Bundle erstellt", { hostDiagnosticsMode: "cached",
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, debugSetupMode: "deferred"
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
}); });
return { return {
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }), buffer,
defaultFileName: getSupportBundleDefaultFileName() 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 { public getSupportBundleDefaultFileName(): string {
return getSupportBundleDefaultFileName(); return getSupportBundleDefaultFileName();
} }
+104 -27
View File
@@ -291,6 +291,7 @@ const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
const MEGA_DEBRID_SLOW_LINK_RETRY_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_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 // 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 — // account ran) only cools the account down — so the next attempt rotates on —
@@ -332,6 +333,20 @@ export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
megaDebridEmptyResponseStreaks.delete(accountId); 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<string>): void {
for (const accountKey of accountKeys) {
megaDebridAccountCooldowns.delete(accountKey);
megaDebridEmptyResponseStreaks.delete(accountKey);
}
}
export function resetMegaDebridRuntimeStateForTests(): void { export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridAccountCooldowns.clear(); megaDebridAccountCooldowns.clear();
megaDebridEmptyResponseStreaks.clear(); megaDebridEmptyResponseStreaks.clear();
@@ -748,6 +763,32 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
}); });
} }
function waitForPromiseWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) {
return promise;
}
if (signal.aborted) {
return Promise.reject(new Error("aborted:debrid"));
}
return new Promise<T>((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<string, unknown> | null { function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) { if (!value || typeof value !== "object" || Array.isArray(value)) {
return null; return null;
@@ -1808,31 +1849,47 @@ class MegaDebridClient {
private allowApiFallback: boolean; private allowApiFallback: boolean;
private static cachedApiTokens = new Map<string, { token: string; at: number }>(); private static cachedApiTokens = new Map<string, { token: string; at: number; generation: number }>();
private static pendingConnects = new Map<string, Promise<string | null>>(); private static pendingConnects = new Map<string, { generation: number; promise: Promise<string | null> }>();
private static credentialGenerations = new Map<string, number>();
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);
}
}
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void { public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
const keep = new Set<string>(); const keep = new Set<string>();
for (const login of activeLogins) { for (const login of activeLogins) {
keep.add(String(login || "").toLowerCase()); keep.add(String(login || "").toLowerCase());
} }
for (const login of MegaDebridClient.cachedApiTokens.keys()) { const knownLogins = new Set<string>([
...MegaDebridClient.cachedApiTokens.keys(),
...MegaDebridClient.pendingConnects.keys()
]);
for (const login of knownLogins) {
if (!keep.has(login)) { if (!keep.has(login)) {
MegaDebridClient.cachedApiTokens.delete(login); MegaDebridClient.invalidateCredential(login);
}
}
for (const login of MegaDebridClient.pendingConnects.keys()) {
if (!keep.has(login)) {
MegaDebridClient.pendingConnects.delete(login);
} }
} }
} }
public static clearCachedApiToken(login: string): void { public static clearCachedApiToken(login: string): void {
const key = String(login || "").toLowerCase(); const key = String(login || "").toLowerCase();
MegaDebridClient.cachedApiTokens.delete(key); MegaDebridClient.invalidateCredential(key);
MegaDebridClient.pendingConnects.delete(key);
} }
public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) { public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) {
@@ -1849,40 +1906,46 @@ class MegaDebridClient {
private async connectApi(signal?: AbortSignal): Promise<string | null> { private async connectApi(signal?: AbortSignal): Promise<string | null> {
const key = this.cacheKey; const key = this.cacheKey;
const generation = MegaDebridClient.getCredentialGeneration(key);
const cached = MegaDebridClient.cachedApiTokens.get(key); const cached = MegaDebridClient.cachedApiTokens.get(key);
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) { 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" }); traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" });
return cached.token; return waitForPromiseWithSignal(Promise.resolve(cached.token), signal);
} }
const pending = MegaDebridClient.pendingConnects.get(key); const pending = MegaDebridClient.pendingConnects.get(key);
if (pending) { if (pending && pending.generation === generation) {
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" }); traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" });
return pending; return waitForPromiseWithSignal(pending.promise, signal);
} }
const promise = this.doConnectApi(signal).finally(() => { 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); MegaDebridClient.pendingConnects.delete(key);
}); }
MegaDebridClient.pendingConnects.set(key, promise); };
return promise; void promise.then(clearPending, clearPending);
return waitForPromiseWithSignal(promise, signal);
} }
private clearTokenCache(): void { private clearTokenCache(): void {
MegaDebridClient.cachedApiTokens.delete(this.cacheKey); MegaDebridClient.cachedApiTokens.delete(this.cacheKey);
} }
private async doConnectApi(signal?: AbortSignal): Promise<string | null> { private async doConnectApi(cacheKey: string, generation: number): Promise<string | null> {
const connectStartedAt = Date.now(); const connectStartedAt = Date.now();
const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`; const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`;
const response = await fetch(url, { const response = await fetch(url, {
headers: { "User-Agent": DEBRID_USER_AGENT }, headers: { "User-Agent": DEBRID_USER_AGENT },
signal: withTimeoutSignal(signal, API_TIMEOUT_MS) signal: AbortSignal.timeout(API_TIMEOUT_MS)
}); });
const text = await response.text(); const text = await response.text();
if (!response.ok) { if (!response.ok) {
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
this.clearTokenCache(); MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation);
} }
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` }); traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` });
return null; return null;
@@ -1890,7 +1953,7 @@ class MegaDebridClient {
const payload = parseJsonSafe(text); const payload = parseJsonSafe(text);
if (!payload || payload.response_code !== "ok") { if (!payload || payload.response_code !== "ok") {
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) { if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
this.clearTokenCache(); 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() }); 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; return null;
@@ -1900,7 +1963,10 @@ class MegaDebridClient {
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" }); traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" });
return null; return null;
} }
MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() }); 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" }); traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "ok" });
return token; return token;
} }
@@ -2123,9 +2189,13 @@ class MegaDebridClient {
usableAccountSeen = true; usableAccountSeen = true;
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1); megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
const accountAttemptTimeoutSignal = AbortSignal.timeout(getMegaDebridAccountAttemptTimeoutMs());
const accountAttemptSignal = signal
? AbortSignal.any([signal, accountAttemptTimeoutSignal])
: accountAttemptTimeoutSignal;
try { try {
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict); const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
const result = await client.unrestrictLink(link, signal); const result = await client.unrestrictLink(link, accountAttemptSignal);
clearMegaDebridAccountCooldownState(cooldownKey); clearMegaDebridAccountCooldownState(cooldownKey);
clearMegaDebridEmptyResponseStreak(cooldownKey); clearMegaDebridEmptyResponseStreak(cooldownKey);
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
@@ -2152,6 +2222,9 @@ class MegaDebridClient {
} catch (error) { } catch (error) {
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
if (signal?.aborted) {
throw error;
}
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The // Timeout/abort on THIS account (the shared unrestrict timeout fired). The
// account-wide cooldown exists ONLY to make the retry rotate to another // account-wide cooldown exists ONLY to make the retry rotate to another
// account — so it is set only when another usable account actually exists. // account — so it is set only when another usable account actually exists.
@@ -2161,7 +2234,8 @@ class MegaDebridClient {
// we park just this link (mega_debrid_slow_link) and leave the account free // 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. // for other items. A quick user-cancel (below the min run) parks nothing.
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); const accountAttemptTimedOut = accountAttemptTimeoutSignal.aborted;
const ranLongEnough = accountAttemptTimedOut || elapsedMs >= getMegaDebridAbortMinRunMs();
const otherUsableAccounts = orderedEntries.reduce((count, candidate) => { const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
if (candidate.account.id === account.id) { if (candidate.account.id === account.id) {
return count; return count;
@@ -2194,8 +2268,11 @@ class MegaDebridClient {
elapsedMs, elapsedMs,
reason: abortText, reason: abortText,
cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0, 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)" next: rotateToAnotherAccount ? "naechster Account im selben Versuch" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
}); });
if (rotateToAnotherAccount) {
continue;
}
if (ranLongEnough && !rotateToAnotherAccount) { 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_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
} }
+160 -22
View File
@@ -54,7 +54,7 @@ function releaseTlsSkip(): void {
} }
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup"; import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion"; 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 { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity"; import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error"; import { classifyDiskError } from "./fs-error";
@@ -78,7 +78,7 @@ type ActiveTask = {
itemId: string; itemId: string;
packageId: string; packageId: string;
abortController: AbortController; 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; resumable: boolean;
nonResumableCounted: boolean; nonResumableCounted: boolean;
freshRetryUsed?: boolean; freshRetryUsed?: boolean;
@@ -124,6 +124,10 @@ 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; const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
const EXTRACT_PROGRESS_EMIT_INTERVAL_MS = 260; const EXTRACT_PROGRESS_EMIT_INTERVAL_MS = 260;
@@ -477,6 +481,31 @@ function extractHosterKey(link: string): string {
return extractHosterFromUrl(link); 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 { function isLargeBinaryLikePath(filePath: string): boolean {
const lower = path.basename(String(filePath || "")).toLowerCase(); const lower = path.basename(String(filePath || "")).toLowerCase();
return isArchiveLikePath(lower) || LARGE_BINARY_FILE_RE.test(lower); return isArchiveLikePath(lower) || LARGE_BINARY_FILE_RE.test(lower);
@@ -552,9 +581,14 @@ function shouldPreflightFinalizeItemFromDisk(item: DownloadItem): boolean {
|| text.includes("server ignorierte range"); || text.includes("server ignorierte range");
} }
function isResumeHardResetReason(errorText: string): boolean { export function isResumeHardResetReason(errorText: string, renewedLinkFailures = 0): boolean {
const text = String(errorText || ""); const text = String(errorText || "").toLowerCase();
return text.startsWith("resume_download_underflow:"); 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 { function isRealDebridProvider(provider: string | null | undefined): boolean {
@@ -1701,6 +1735,15 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
return Math.floor(jitter); 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 { export function getDiskWriteWaitReason(error: unknown): string | null {
const text = compactErrorText(error).replace(/^Error:\s*/i, ""); const text = compactErrorText(error).replace(/^Error:\s*/i, "");
const marked = text.match(/^disk_write_wait:(.+)$/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 { public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
const previous = this.settings; const previous = this.settings;
const previousMegaPool = (["api", "web"] as const) const previousMegaAccounts = (["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => `${mode}:${account.id}:${account.password}`)) .flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => ({ mode, account })));
const previousMegaPoolEntries = new Map<string, string>(previousMegaAccounts.map(({ mode, account }) => [`${account.id}:${mode}`, account.password]));
const previousMegaPool = [...previousMegaPoolEntries]
.map(([key, password]) => `${key}:${password}`)
.sort() .sort()
.join("\n"); .join("\n");
const nextMegaAccounts = (["api", "web"] as const) const nextMegaAccounts = (["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode)); .flatMap((mode) => getAvailableMegaDebridAccounts(next, mode));
const nextMegaPool = (["api", "web"] as const) const nextMegaPoolEntries = new Map<string, string>((["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => `${mode}:${account.id}:${account.password}`)) .flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => [`${account.id}:${mode}`, account.password] as const)));
const nextMegaPool = [...nextMegaPoolEntries]
.map(([key, password]) => `${key}:${password}`)
.sort() .sort()
.join("\n"); .join("\n");
const previousMegaWebCredentials = getMegaDebridAccountsForMode(previous, "web") const previousMegaWebPool = getAvailableMegaDebridAccounts(previous, "web")
.map((account) => `${account.id}:${account.password}`) .map((account) => `${account.id}:${account.password}`)
.sort() .sort()
.join("\n"); .join("\n");
const nextMegaWebCredentials = getMegaDebridAccountsForMode(next, "web") const nextMegaWebPool = getAvailableMegaDebridAccounts(next, "web")
.map((account) => `${account.id}:${account.password}`) .map((account) => `${account.id}:${account.password}`)
.sort() .sort()
.join("\n"); .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.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0); next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
const now = nowMs(); const now = nowMs();
@@ -2222,7 +2271,7 @@ export class DownloadManager extends EventEmitter {
this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0; this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0;
this.runtimePersistedAt = now; this.runtimePersistedAt = now;
this.ensureProviderDailyUsageFresh(nowMs()); this.ensureProviderDailyUsageFresh(nowMs());
if (previousMegaWebCredentials !== nextMegaWebCredentials) { if (megaWebPoolChanged) {
this.invalidateMegaSessionFn?.(); this.invalidateMegaSessionFn?.();
} }
this.debridService.setSettings(next); 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`); logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
} }
if (!opts?.settingsOnlyImport && megaPoolChanged) { if (megaPoolChanged) {
const changedAccountKeys = new Set<string>();
for (const key of new Set<string>([...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(); this.releaseMegaDebridResetParks();
} }
@@ -5372,7 +5448,6 @@ export class DownloadManager extends EventEmitter {
item.targetPath = ""; item.targetPath = "";
item.provider = null; item.provider = null;
item.fullStatus = "Wartet"; item.fullStatus = "Wartet";
item.onlineStatus = undefined;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
} }
@@ -5456,7 +5531,6 @@ export class DownloadManager extends EventEmitter {
item.targetPath = ""; item.targetPath = "";
item.provider = null; item.provider = null;
item.fullStatus = "Wartet"; item.fullStatus = "Wartet";
item.onlineStatus = undefined;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
if (this.session.running) { if (this.session.running) {
@@ -5937,6 +6011,12 @@ export class DownloadManager extends EventEmitter {
this.providerStartReservations.clear(); this.providerStartReservations.clear();
this.pacedStartReservationByItem.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.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs(); this.lastGlobalProgressAt = nowMs();
this.speedEvents = []; this.speedEvents = [];
@@ -6091,6 +6171,11 @@ export class DownloadManager extends EventEmitter {
this.speedBytesLastWindow = 0; this.speedBytesLastWindow = 0;
this.speedBytesPerPackage.clear(); this.speedBytesPerPackage.clear();
this.speedEventsHead = 0; this.speedEventsHead = 0;
this.invalidateMegaSessionFn?.();
for (const active of this.activeTasks.values()) {
active.abortReason = "pause";
active.abortController.abort("pause");
}
} }
if (wasPaused && !this.session.paused) { if (wasPaused && !this.session.paused) {
@@ -8188,6 +8273,34 @@ export class DownloadManager extends EventEmitter {
].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<DebridProvider>();
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 { private findFallbackProviderNotInCooldown(item: DownloadItem): DebridProvider | null {
const hosterKey = extractHosterKey(item.url); const hosterKey = extractHosterKey(item.url);
for (const provider of this.getProviderOrder()) { for (const provider of this.getProviderOrder()) {
@@ -8677,7 +8790,6 @@ export class DownloadManager extends EventEmitter {
return; 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) { if (active.abortController.signal.aborted) {
continue; continue;
@@ -8686,8 +8798,9 @@ export class DownloadManager extends EventEmitter {
if (!item || item.status !== "validating") { if (!item || item.status !== "validating") {
continue; continue;
} }
const validatingStuckMs = getUnrestrictTimeoutMsForProviderPlan(this.settings, this.getReachableUnrestrictProviderPlan(item, null)) + 15_000;
const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0; const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0;
if (ageMs > VALIDATING_STUCK_MS) { if (ageMs > validatingStuckMs) {
logger.warn(`Validating-Stuck erkannt: item=${item.fileName || active.itemId}, ${Math.floor(ageMs / 1000)}s ohne Fortschritt`); logger.warn(`Validating-Stuck erkannt: item=${item.fileName || active.itemId}, ${Math.floor(ageMs / 1000)}s ohne Fortschritt`);
active.abortReason = "stall"; active.abortReason = "stall";
active.abortController.abort("stall"); active.abortController.abort("stall");
@@ -9172,7 +9285,9 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
return; 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]); const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
let unrestricted; let unrestricted;
try { try {
@@ -9193,7 +9308,7 @@ export class DownloadManager extends EventEmitter {
traceConversionPhase({ traceConversionPhase({
phase: "caller-timeout", phase: "caller-timeout",
outcome: "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; throw innerError;
@@ -9203,7 +9318,7 @@ export class DownloadManager extends EventEmitter {
} catch (unrestrictError) { } catch (unrestrictError) {
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) { if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
this.recordProviderFailure(cooldownProvider); 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); const errText = compactErrorText(unrestrictError);
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) { if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) {
@@ -9484,6 +9599,18 @@ export class DownloadManager extends EventEmitter {
this.dropItemContribution(item.id); this.dropItemContribution(item.id);
} }
this.retryStateByItem.delete(item.id); this.retryStateByItem.delete(item.id);
} 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") { } else if (reason === "shutdown") {
this.logPackageForItem(item, "WARN", "Download für Shutdown geparkt", { this.logPackageForItem(item, "WARN", "Download für Shutdown geparkt", {
reason reason
@@ -9509,6 +9636,17 @@ export class DownloadManager extends EventEmitter {
}); });
} else if (reason === "reset") { } else if (reason === "reset") {
this.retryStateByItem.delete(item.id); 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") { } else if (reason === "package_toggle") {
this.logPackageForItem(item, "WARN", "Download wegen Paket-Toggle pausiert", { this.logPackageForItem(item, "WARN", "Download wegen Paket-Toggle pausiert", {
reason reason
@@ -9640,7 +9778,7 @@ export class DownloadManager extends EventEmitter {
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason); this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
return; return;
} }
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) { if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) {
active.resumeHardResetUsed = true; active.resumeHardResetUsed = true;
item.retries += 1; item.retries += 1;
logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`); logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`);
+18 -7
View File
@@ -21,6 +21,7 @@ import { createRendererSettings } from "./renderer-state";
import { validateRendererSettingsUpdate } from "./renderer-settings"; import { validateRendererSettingsUpdate } from "./renderer-settings";
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security"; import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
import { createSupportBundleExportRunner, writeSupportBundleAtomically } from "./support-bundle";
function validateString(value: unknown, name: string): string { function validateString(value: unknown, name: string): string {
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -611,6 +612,13 @@ function registerIpcHandlers(): void {
updateClipboardWatcher(); 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 () => { handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
const options = { const options = {
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory"> properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
@@ -667,20 +675,23 @@ function registerIpcHandlers(): void {
return controller.importOnlineBackup(key); return controller.importOnlineBackup(key);
}); });
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { const runSupportBundleExport = createSupportBundleExportRunner({
chooseFile: async () => {
const options = { const options = {
defaultPath: controller.getSupportBundleDefaultFileName(), defaultPath: controller.getSupportBundleDefaultFileName(),
filters: [{ name: "Support Bundle", extensions: ["zip"] }] filters: [{ name: "Support Bundle", extensions: ["zip"] }]
}; };
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) { return result.canceled || !result.filePath ? null : result.filePath;
return { saved: false }; },
} build: async () => (await controller.exportSupportBundle()).buffer,
const exported = await controller.exportSupportBundle(); write: writeSupportBundleAtomically,
await fs.promises.writeFile(result.filePath, exported.buffer); onSuccess: ({ filePath, bytes }) => controller.recordSupportBundleExported(filePath, bytes),
return { saved: true, filePath: result.filePath }; onFailure: (error) => controller.recordSupportBundleExportFailed(error)
}); });
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, () => runSupportBundleExport());
handleTrusted(IPC_CHANNELS.OPEN_LOG, async () => { handleTrusted(IPC_CHANNELS.OPEN_LOG, async () => {
const logPath = getLogFilePath(); const logPath = getLogFilePath();
await shell.openPath(logPath); await shell.openPath(logPath);
+25 -2
View File
@@ -230,6 +230,8 @@ export class MegaWebFallback {
private sessionGeneration = 0; private sessionGeneration = 0;
private invalidationController = new AbortController();
public constructor(getCredentials: () => MegaCredentials) { public constructor(getCredentials: () => MegaCredentials) {
this.getCredentials = getCredentials; this.getCredentials = getCredentials;
} }
@@ -239,7 +241,11 @@ export class MegaWebFallback {
signal?: AbortSignal, signal?: AbortSignal,
account?: { login: string; password: string } account?: { login: string; password: string }
): Promise<UnrestrictedLink | null> { ): Promise<UnrestrictedLink | null> {
const overallSignal = withTimeoutSignal(signal, 180000); 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()) const creds = (account && account.login.trim() && account.password.trim())
? account ? account
: this.getCredentials(); : this.getCredentials();
@@ -251,12 +257,18 @@ export class MegaWebFallback {
return this.runExclusive(async () => { return this.runExclusive(async () => {
throwIfAborted(overallSignal); throwIfAborted(overallSignal);
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration); let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
throwIfAborted(overallSignal);
let generated = await this.generate(link, cookie, overallSignal); let generated = await this.generate(link, cookie, overallSignal);
throwIfAborted(overallSignal);
if (!generated) { if (!generated) {
if (sessionGeneration === this.sessionGeneration) {
this.sessions.delete(key); this.sessions.delete(key);
}
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration); cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
throwIfAborted(overallSignal);
generated = await this.generate(link, cookie, overallSignal); generated = await this.generate(link, cookie, overallSignal);
throwIfAborted(overallSignal);
if (!generated) { if (!generated) {
return null; return null;
} }
@@ -290,7 +302,10 @@ export class MegaWebFallback {
public invalidateSession(): void { public invalidateSession(): void {
this.sessionGeneration += 1; this.sessionGeneration += 1;
this.invalidationController.abort("session_invalidated");
this.invalidationController = new AbortController();
this.sessions.clear(); this.sessions.clear();
this.queues.clear();
} }
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> { private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
@@ -317,7 +332,13 @@ export class MegaWebFallback {
}; };
const prev = this.queues.get(key) ?? Promise.resolve(); const prev = this.queues.get(key) ?? Promise.resolve();
const run = prev.then(guardedJob, guardedJob); const run = prev.then(guardedJob, guardedJob);
this.queues.set(key, run.then(() => undefined, () => undefined)); 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, () => return raceWithAbort(run, signal, () =>
workStarted workStarted
? abortError() ? abortError()
@@ -461,7 +482,9 @@ export class MegaWebFallback {
} }
public dispose(): void { public dispose(): void {
this.invalidationController.abort("dispose");
this.sessions.clear(); this.sessions.clear();
this.queues.clear();
} }
} }
+528 -101
View File
@@ -1,4 +1,5 @@
import { promises as fsp } from "node:fs"; import { promises as fsp } from "node:fs";
import { randomUUID } from "node:crypto";
import path from "node:path"; import path from "node:path";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { APP_VERSION } from "./constants"; import { APP_VERSION } from "./constants";
@@ -8,81 +9,382 @@ import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup"; import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger"; import { getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring"; import { getRecentErrors } from "./error-ring";
import { getPackageLogPath } from "./package-log";
import { getRenameLogPath } from "./rename-log"; import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-rename-log"; import { getDesktopRenameLogPath } from "./desktop-rename-log";
import { getSessionLogPath } from "./session-log"; import { getSessionLogPath } from "./session-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { createStoragePaths, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log"; import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics"; 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_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;
async function safeReadJson(filePath: string): Promise<unknown> { interface TextBudget {
remainingBytes: number;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function collectSensitiveValues(value: unknown, key = "", output = new Set<string>()): Set<string> {
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<string, unknown>)) {
collectSensitiveValues(entryValue, entryKey, output);
}
}
return output;
}
function redactUrl(value: string): string {
try { try {
const parsed = new URL(value);
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}/<redacted>`;
} catch {
return "<redacted-url>";
}
}
function redactSupportText(value: string, sensitiveValues: ReadonlySet<string>): 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"), "<redacted>");
}
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\"<redacted>\"");
output = output.replace(/\b(?:authorization|proxy-authorization)\s*[:=]\s*[^\r\n]+/gi, "Authorization: <redacted>");
output = output.replace(/\b(?:set-cookie|cookie)\s*[:=]\s*[^\r\n]+/gi, "Cookie: <redacted>");
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<redacted>");
output = output.replace(/\b[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{12,}\b/g, "<redacted>");
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, "<redacted>");
output = output.replace(/\b[A-Z]:\\[^\r\n|"<>]+/gi, "<local-path>");
output = output.replace(/\\\\[^\r\n|"<>]+/g, "<local-path>");
output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, "<local-path>");
output = output.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "<redacted-email>");
return output;
}
function redactSupportValue(value: unknown, sensitiveValues: ReadonlySet<string>, key = ""): unknown {
if (typeof value === "string") {
if (/token|api.?key|password|passwd|secret|cookie|authorization|credential|login|username/i.test(key) && value.trim()) {
return "<redacted>";
}
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<string, unknown>)
.map(([entryKey, entryValue]) => [entryKey, redactSupportValue(entryValue, sensitiveValues, entryKey)]));
}
return value;
}
function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>): string {
return redactSupportText(zipPath, sensitiveValues)
.split("/")
.map((part) => part.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry")
.join("/");
}
async function yieldToEventLoop(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
async function addJson(zip: AdmZip, zipPath: string, value: unknown, sensitiveValues: ReadonlySet<string>): Promise<void> {
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<unknown> {
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; return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
} catch { } catch {
return null; return null;
} }
} }
function addJson(zip: AdmZip, zipPath: string, value: unknown): void { function getSourcePathKey(sourcePath: string): string {
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8")); const resolved = path.resolve(sourcePath);
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
} }
async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> { async function readTextTail(filePath: string, maxBytes: number): Promise<string> {
if (!sourcePath) { const stats = await fsp.stat(filePath);
return; 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<string>,
sensitiveValues: ReadonlySet<string>,
budget: TextBudget,
maxFileBytes: number,
maxAgeMs?: number
): Promise<boolean> {
if (!sourcePath || budget.remainingBytes <= 0) {
return false;
}
const sourcePathKey = getSourcePathKey(sourcePath);
if (includedSourcePaths.has(sourcePathKey)) {
return false;
} }
try { try {
const buffer = await fsp.readFile(sourcePath); if (maxAgeMs !== undefined && (await fsp.stat(sourcePath)).mtimeMs < Date.now() - maxAgeMs) {
zip.addFile(zipPath, buffer); 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 { } catch {
return false;
} }
} }
async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> { async function addRecentDirectoryFiles(
let entries; zip: AdmZip,
dirPath: string,
zipRoot: string,
maxAgeMs: number,
maxFiles: number,
includedSourcePaths: Set<string>,
sensitiveValues: ReadonlySet<string>,
budget: TextBudget
): Promise<number> {
const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = [];
let directory;
try { try {
entries = await fsp.readdir(dirPath, { withFileTypes: true }); directory = await fsp.opendir(dirPath);
} 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<number> {
let entries;
try {
entries = await fsp.readdir(dirPath, { withFileTypes: true });
} catch { } catch {
return 0; return 0;
} }
const cutoff = Date.now() - maxAgeMs; const cutoff = Date.now() - maxAgeMs;
let added = 0; let scanned = 0;
for (const entry of entries) { for await (const entry of directory) {
if (!entry.isFile()) continue; if (scanned >= MAX_DIRECTORY_SCAN_FILES) {
break;
}
scanned += 1;
if (!entry.isFile()) {
continue;
}
const fullPath = path.join(dirPath, entry.name); const fullPath = path.join(dirPath, entry.name);
try { try {
if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) { const stats = await fsp.stat(fullPath);
await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name)); 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; added += 1;
} }
} catch { }
} }
return added; return added;
} }
function isActiveStatus(status: unknown): boolean {
return !new Set(["completed", "failed", "cancelled", "extracted", "deleted"]).has(String(status || ""));
}
function createPackageDto(entry: PackageEntry): Record<string, unknown> {
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 ? "<local-path>" : "",
extractPath: entry.extractDir ? "<local-path>" : "",
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<string, unknown> {
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 ? "<local-path>" : "",
resumable: entry.resumable,
attempts: entry.attempts,
lastError: entry.lastError,
fullStatus: entry.fullStatus,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
onlineStatus: entry.onlineStatus
};
}
function selectRelevantEntries<T extends { status: unknown; updatedAt: number }>(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<string, unknown> {
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<string, unknown> {
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 ? "<local-path>" : "",
urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0
};
}
async function loadBoundedHistory(filePath: string): Promise<{ total: number | null; entries: Array<Record<string, unknown>>; 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 { function formatTimestampForFileName(date: Date): string {
const y = date.getFullYear(); const y = date.getFullYear();
const mo = String(date.getMonth() + 1).padStart(2, "0"); const mo = String(date.getMonth() + 1).padStart(2, "0");
@@ -97,10 +399,93 @@ export function getSupportBundleDefaultFileName(): string {
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`; 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<string | null>;
build: () => Promise<Buffer>;
write: (filePath: string, buffer: Buffer) => Promise<void>;
onSuccess?: (result: SupportBundleExportSuccess) => Promise<void> | void;
onFailure?: (error: unknown) => Promise<void> | void;
}
export function createSupportBundleExportRunner(
options: SupportBundleExportRunnerOptions
): () => Promise<SupportBundleExportResult> {
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<void> {
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<ReturnType<typeof fsp.open>> | 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 { interface BuildSupportBundleOptions {
hostDiagnosticsMode?: HostDiagnosticsMode; hostDiagnosticsMode?: HostDiagnosticsMode;
debugSetupMode?: "full" | "deferred";
} }
function createDeferredHostDiagnostics(reason: string): unknown { function createDeferredHostDiagnostics(reason: string): unknown {
@@ -137,88 +522,130 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> { export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
const zip = new AdmZip(); const zip = new AdmZip();
const includedSourcePaths = new Set<string>();
const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES };
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full"; const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
const storagePaths = createStoragePaths(baseDir); const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths); const settings = loadSettings(storagePaths);
const history = loadHistory(storagePaths); const sensitiveValues = collectSensitiveValues(settings);
const snapshot = manager.getSnapshot(); const snapshot = manager.getSnapshot();
const packageIds = Object.keys(snapshot.session.packages); const packageEntries = Object.values(snapshot.session.packages);
const itemIds = Object.keys(snapshot.session.items); const itemEntries = Object.values(snapshot.session.items);
const debugSetup = getDebugSetupCheck(baseDir); 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);
addJson(zip, "overview/meta.json", { await addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION, appVersion: APP_VERSION,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
runtimeBaseDir: baseDir, runtimeBaseDir: "<local-path>",
packageCount: packageIds.length, packageCount: packageEntries.length,
itemCount: itemIds.length itemCount: itemEntries.length,
}); limits: {
addJson(zip, "overview/status.json", snapshot.session); packageDtos: MAX_PACKAGE_DTOS,
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings)); itemDtos: MAX_ITEM_DTOS,
addJson(zip, "overview/accounts.json", buildAccountSummary(settings)); textBytes: MAX_TOTAL_TEXT_BYTES,
addJson(zip, "overview/stats.json", { 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), ...buildStatsPayload(snapshot),
allTime: { allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime, totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
} }
}); }, sensitiveValues);
addJson(zip, "overview/debug-setup.json", debugSetup); await addJson(zip, "overview/debug-setup.json", debugSetup, sensitiveValues);
addJson(zip, "overview/self-check.json", debugSetup); await addJson(zip, "overview/self-check.json", debugSetup, sensitiveValues);
addJson(zip, "overview/history.json", { await addJson(zip, "overview/history.json", history, sensitiveValues);
total: history.length, await addJson(zip, "overview/packages.json", {
entries: history.map((entry) => summarizeHistoryEntry(entry)) count: packageEntries.length,
}); included: selectedPackages.length,
addJson(zip, "overview/packages.json", { omitted: Math.max(0, packageEntries.length - selectedPackages.length),
count: packageIds.length, packages: selectedPackages
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean) }, sensitiveValues);
}); await addJson(zip, "overview/items.json", {
addJson(zip, "overview/items.json", { count: itemEntries.length,
count: itemIds.length, included: selectedItems.length,
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean) omitted: Math.max(0, itemEntries.length - selectedItems.length),
}); items: selectedItems
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode)); }, sensitiveValues);
addJson(zip, "overview/trace-config.json", getTraceConfig()); await addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode), sensitiveValues);
const recentErrors = getRecentErrors(); await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues);
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }); const recentErrors = getRecentErrors().slice(-100);
await addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }, sensitiveValues);
await addFileIfExists(zip, path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`); const addRuntimeFile = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); zip,
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt"); sourcePath,
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json"); zipPath,
includedSourcePaths,
sensitiveValues,
textBudget,
MAX_RUNTIME_FILE_BYTES
);
const addCurrentLog = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
zip,
sourcePath,
zipPath,
includedSourcePaths,
sensitiveValues,
textBudget,
MAX_TEXT_FILE_BYTES
);
const addRotatedLog = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
zip,
sourcePath,
zipPath,
includedSourcePaths,
sensitiveValues,
textBudget,
MAX_TEXT_FILE_BYTES,
SUPPORT_BUNDLE_LOG_WINDOW_MS
);
await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log"); await addRuntimeFile(path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old"); await addRuntimeFile(path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log"); await addRuntimeFile(path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old"); await addRuntimeFile(getTraceConfigPath(), "runtime/trace_config.json");
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; const mainLogPath = getLogFilePath();
await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); const auditLogPath = getAuditLogPath();
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); const renameLogPath = getRenameLogPath();
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); 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");
for (const packageId of packageIds) { await addRecentDirectoryFiles(zip, path.join(baseDir, "session-logs"), "logs/session-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_SESSION_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`); 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);
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)); const supportManifest = await safeReadBoundedJson(path.join(baseDir, SUPPORT_MANIFEST_FILE), MAX_RUNTIME_FILE_BYTES);
if (supportManifest) { if (supportManifest) {
addJson(zip, "overview/support-manifest.json", supportManifest); await addJson(zip, "overview/support-manifest.json", supportManifest, sensitiveValues);
} }
return zip.toBuffer(); return await zip.toBufferPromise();
} }
+2 -1
View File
@@ -65,6 +65,7 @@ const api: ElectronApi = {
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE), 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), importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD), toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
writeClipboardText: (text: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, text),
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER), pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS), pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS), getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
@@ -78,7 +79,7 @@ const api: ElectronApi = {
cancelBackupImport: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_BACKUP_IMPORT), cancelBackupImport: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_BACKUP_IMPORT),
exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP), 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), 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<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
openLogDirectory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG_DIRECTORY), openLogDirectory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG_DIRECTORY),
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
+231 -104
View File
@@ -45,6 +45,7 @@ import type { AccountModeFilter } from "./account-ui";
import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit"; import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit";
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit"; import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons"; 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 { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
import { createUiLocalizer, normalizeLanguage } from "./i18n"; import { createUiLocalizer, normalizeLanguage } from "./i18n";
import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow"; import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow";
@@ -869,12 +870,45 @@ const historyRetentionLabels: Record<RendererSettings["historyRetentionMode"], s
const AUTO_RENDER_PACKAGE_LIMIT = 260; const AUTO_RENDER_PACKAGE_LIMIT = 260;
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number { export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
let delay = itemCount >= 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 (!running) delay = Math.min(delay, 200);
if (activeTab !== "downloads") delay = Math.max(delay, 800); if (activeTab !== "downloads") delay = Math.max(delay, 800);
return delay; 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<void> {
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 <Toast message={busy ? "Support-Bundle wird erstellt …" : message} />;
}
const KNOWN_HOSTERS: { id: string; label: string }[] = [ const KNOWN_HOSTERS: { id: string; label: string }[] = [
{ id: "rapidgator", label: "Rapidgator" }, { id: "rapidgator", label: "Rapidgator" },
{ id: "uploaded", label: "Uploaded" }, { id: "uploaded", label: "Uploaded" },
@@ -1261,7 +1295,7 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const interval = setInterval(() => { const interval = setInterval(() => {
drawChart(); drawChart();
}, reducedMotion ? 1000 : 250); }, reducedMotion ? 1000 : 500);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [drawChart, running, paused]); }, [drawChart, running, paused]);
@@ -1363,7 +1397,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps,
draw(); draw();
}; };
const id = window.setInterval(tick, 250); const id = window.setInterval(tick, 500);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, []); }, []);
@@ -1513,6 +1547,8 @@ export function App(): ReactElement {
const [updateDialogOpen, setUpdateDialogOpen] = useState(false); const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null); const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null);
const [settingsDraft, setSettingsDraft] = useState<RendererSettingsDraft>(() => createSettingsDraft(emptySnapshot().settings)); const [settingsDraft, setSettingsDraft] = useState<RendererSettingsDraft>(() => createSettingsDraft(emptySnapshot().settings));
const settingsDraftRef = useRef(settingsDraft);
settingsDraftRef.current = settingsDraft;
const [settingsThemeChoice, setSettingsThemeChoice] = useState<SettingsThemeChoice>(emptySnapshot().settings.theme); const [settingsThemeChoice, setSettingsThemeChoice] = useState<SettingsThemeChoice>(emptySnapshot().settings.theme);
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({}); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
@@ -1571,10 +1607,17 @@ export function App(): ReactElement {
const [showAllPackages, setShowAllPackages] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false); const [actionBusy, setActionBusy] = useState(false);
const [accountCheckBusy, setAccountCheckBusy] = useState(false); const [accountCheckBusy, setAccountCheckBusy] = useState(false);
const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<Record<string, boolean>>({});
const accountEnabledOverridesRef = useRef<Record<string, boolean>>({});
const accountToggleQueueRef = useRef(new SerialTaskQueue());
const accountToggleRevisionRef = useRef(0);
const accountTogglePendingRef = useRef(0);
const actionBusyRef = useRef(false); const actionBusyRef = useRef(false);
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true); 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 dragOverRef = useRef(false);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
const [openMenu, setOpenMenu] = useState<string | null>(null); const [openMenu, setOpenMenu] = useState<string | null>(null);
@@ -1755,6 +1798,14 @@ export function App(): ReactElement {
}, timeoutMs); }, timeoutMs);
}, []); }, []);
const clearToast = useCallback((): void => {
setStatusToast("");
if (toastTimerRef.current) {
clearTimeout(toastTimerRef.current);
toastTimerRef.current = null;
}
}, []);
const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => { const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
const availableIds = entries.map((entry) => entry.id); const availableIds = entries.map((entry) => entry.id);
const availableSet = new Set(availableIds); const availableSet = new Set(availableIds);
@@ -1954,7 +2005,7 @@ export function App(): ReactElement {
if (next.settings.columnOrder?.length > 0) { if (next.settings.columnOrder?.length > 0) {
setColumnOrder(next.settings.columnOrder); setColumnOrder(next.settings.columnOrder);
} }
if (!settingsDirtyRef.current) { if (!settingsDirtyRef.current && accountTogglePendingRef.current === 0) {
setSettingsDraft(createSettingsDraft(next.settings)); setSettingsDraft(createSettingsDraft(next.settings));
} }
latestStateRef.current = null; latestStateRef.current = null;
@@ -2308,8 +2359,9 @@ export function App(): ReactElement {
for (const acc of accounts) { for (const acc of accounts) {
const used = acc.dailyUsageBytes; const used = acc.dailyUsageBytes;
const limit = acc.dailyLimitBytes; const limit = acc.dailyLimitBytes;
const rowKey = `mega-${entry.kind}-${acc.accountId}`;
rows.push({ rows.push({
rowKey: `mega-${entry.kind}-${acc.accountId}`, rowKey,
entry, entry,
hosterLabel: entry.serviceLabel, hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel, modeLabel: entry.modeLabel,
@@ -2317,7 +2369,9 @@ export function App(): ReactElement {
credentialLabel: "••••••", credentialLabel: "••••••",
accountId: acc.accountId, accountId: acc.accountId,
checkable: true, checkable: true,
disabled: !acc.enabled, disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
? !accountEnabledOverrides[rowKey]
: !acc.enabled,
dailyUsedBytes: used, dailyUsedBytes: used,
dailyLimitBytes: limit, dailyLimitBytes: limit,
dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0, dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0,
@@ -2334,8 +2388,9 @@ export function App(): ReactElement {
} }
} else if (entry.kind === "debridlink-api") { } else if (entry.kind === "debridlink-api") {
for (const key of entry.debridLinkKeys) { for (const key of entry.debridLinkKeys) {
const rowKey = `dl-${key.id}`;
rows.push({ rows.push({
rowKey: `dl-${key.id}`, rowKey,
entry, entry,
hosterLabel: entry.serviceLabel, hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel, modeLabel: entry.modeLabel,
@@ -2343,7 +2398,9 @@ export function App(): ReactElement {
credentialLabel: "API-Key", credentialLabel: "API-Key",
accountId: key.id, accountId: key.id,
checkable: true, checkable: true,
disabled: key.disabled, disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
? !accountEnabledOverrides[rowKey]
: key.disabled,
dailyUsedBytes: key.dailyUsedBytes, dailyUsedBytes: key.dailyUsedBytes,
dailyLimitBytes: key.dailyLimitBytes, dailyLimitBytes: key.dailyLimitBytes,
dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0, dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0,
@@ -2360,8 +2417,9 @@ export function App(): ReactElement {
}); });
} }
} else { } else {
const rowKey = `svc-${entry.service}`;
rows.push({ rows.push({
rowKey: `svc-${entry.service}`, rowKey,
entry, entry,
hosterLabel: entry.serviceLabel, hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel, modeLabel: entry.modeLabel,
@@ -2369,7 +2427,9 @@ export function App(): ReactElement {
credentialLabel: getAccountCredentialLabel(entry.kind), credentialLabel: getAccountCredentialLabel(entry.kind),
accountId: null, accountId: null,
checkable: false, checkable: false,
disabled: entry.disabled, disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
? !accountEnabledOverrides[rowKey]
: entry.disabled,
dailyUsedBytes: entry.dailyUsedBytes, dailyUsedBytes: entry.dailyUsedBytes,
dailyLimitBytes: entry.dailyLimitBytes, dailyLimitBytes: entry.dailyLimitBytes,
dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0, dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0,
@@ -2386,7 +2446,7 @@ export function App(): ReactElement {
} }
} }
return rows; return rows;
}, [configuredAccounts, snapshot.accounts]); }, [accountEnabledOverrides, configuredAccounts, snapshot.accounts]);
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none"); const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none")); const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
@@ -2791,28 +2851,6 @@ export function App(): ReactElement {
}); });
}; };
const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise<void> => {
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<void> => { const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise<void> => {
const meta = getAccountQuickActionMeta(entry.kind); const meta = getAccountQuickActionMeta(entry.kind);
if (!meta) { if (!meta) {
@@ -2825,25 +2863,6 @@ export function App(): ReactElement {
}); });
}; };
const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", accountId: string, currentlyDisabled: boolean): Promise<void> => {
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<void> => { const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise<void> => {
const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); 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; if (!confirmed) return;
@@ -2855,61 +2874,145 @@ export function App(): ReactElement {
}, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); });
}; };
const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => { const reconcileAccountToggleState = async (revision: number): Promise<void> => {
await performQuickAction(async () => { const fresh = await window.rd.getSnapshot();
const provider = entry.service as DebridProvider; if (!mountedRef.current || revision !== accountToggleRevisionRef.current) {
const current = settingsDraft.disabledProviders || []; return;
const nextDisabledProviders = current.includes(provider) }
? current.filter((existing) => existing !== provider) masterSnapshotRef.current = fresh;
: [...current, provider]; latestStateRef.current = null;
const nextDraft: RendererSettingsDraft = { setSnapshot(fresh);
...settingsDraft, const reconciledDraft: RendererSettingsDraft = {
disabledProviders: nextDisabledProviders ...settingsDraftRef.current,
...buildAccountToggleSettingsUpdate(fresh.settings)
}; };
await persistSpecificSettings(nextDraft); settingsDraftRef.current = reconciledDraft;
showToast( setSettingsDraft(reconciledDraft);
nextDisabledProviders.includes(provider) accountEnabledOverridesRef.current = {};
? `${entry.serviceLabel} deaktiviert` setAccountEnabledOverrides({});
: `${entry.serviceLabel} aktiviert`, };
2200
); const enqueueAccountSettingsChange = (
}, (error) => { nextDraft: RendererSettingsDraft,
showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200); nextOverrides: Record<string, boolean>,
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 => { const toggleAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null); setAccountContextMenu(null);
if (row.toggleKind === "mega" && row.accountId) { const target = getAccountToggleTarget(row);
void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.accountId, row.disabled); if (!target) {
} else if (row.toggleKind === "dl" && row.dlKey) { return;
void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey); }
} else { const currentEnabled = Object.prototype.hasOwnProperty.call(accountEnabledOverridesRef.current, row.rowKey)
void onToggleAccountEnabled(row.entry); ? 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<void> => { const setAllAccountsEnabled = (enabled: boolean): void => {
await performQuickAction(async () => {
const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))]; const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))];
const nextEnabledState = buildBulkAccountEnabledState( const nextEnabledState = buildBulkAccountEnabledState(
settingsDraft.disabledProviders || [], settingsDraftRef.current.disabledProviders || [],
configuredProviderIds, configuredProviderIds,
accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string), 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), accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string),
enabled enabled
); );
const nextDraft: RendererSettingsDraft = { const nextDraft: RendererSettingsDraft = {
...settingsDraft, ...settingsDraftRef.current,
...nextEnabledState, ...nextEnabledState,
megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string), 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) megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string)
}; };
await persistSpecificSettings(nextDraft); nextDraft.megaDebridDisabledAccountIds = [...new Set([
showToast(enabled ? "Accounts aktiviert" : "Accounts deaktiviert", 2200); ...nextDraft.megaDebridApiDisabledAccountIds,
}, (error) => { ...nextDraft.megaDebridWebDisabledAccountIds
showToast(`Accounts konnten nicht umgeschaltet werden: ${String(error)}`, 3200); ])];
}); 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 => { const removeAccountTableRow = (row: AccountTableRow): void => {
@@ -4199,7 +4302,7 @@ export function App(): ReactElement {
const onCopyOnlineBackupKey = async (): Promise<void> => { const onCopyOnlineBackupKey = async (): Promise<void> => {
if (!onlineBackupDialog?.key) return; if (!onlineBackupDialog?.key) return;
try { try {
await navigator.clipboard.writeText(onlineBackupDialog.key); await window.rd.writeClipboardText(onlineBackupDialog.key);
showToast("Online-Schlüssel kopiert", 2200); showToast("Online-Schlüssel kopiert", 2200);
} catch { } catch {
showToast("Schlüssel konnte nicht kopiert werden", 2600); showToast("Schlüssel konnte nicht kopiert werden", 2600);
@@ -4208,14 +4311,21 @@ export function App(): ReactElement {
const onExportSupportBundle = async (): Promise<void> => { const onExportSupportBundle = async (): Promise<void> => {
closeMenus(); closeMenus();
await performQuickAction(async () => { if (supportBundleExportingRef.current) {
const result = await window.rd.exportSupportBundle(); showToast("Support-Bundle wird bereits erstellt …", 2600);
if (result.saved) { return;
showToast("Support-Bundle exportiert", 2600);
} }
}, (error) => { supportBundleExportingRef.current = true;
showToast(`Support-Bundle fehlgeschlagen: ${String(error)}`, 2800); try {
await runSupportBundleExportUi({
exportBundle: () => window.rd.exportSupportBundle(),
setBusy: setSupportBundleExporting,
clearMessage: clearToast,
showMessage: (message) => showToast(message, 2800)
}); });
} finally {
supportBundleExportingRef.current = false;
}
}; };
const onToggleSupportTrace = async (): Promise<void> => { const onToggleSupportTrace = async (): Promise<void> => {
@@ -4268,7 +4378,7 @@ export function App(): ReactElement {
detailsLabel: "Einträge anzeigen" detailsLabel: "Einträge anzeigen"
}); });
if (copy && entries.length > 0) { if (copy && entries.length > 0) {
await navigator.clipboard.writeText(details); await window.rd.writeClipboardText(details);
showToast("Fehlerliste kopiert", 2600); showToast("Fehlerliste kopiert", 2600);
} }
} catch (error) { } catch (error) {
@@ -4371,7 +4481,7 @@ export function App(): ReactElement {
return; return;
} }
try { try {
await navigator.clipboard.writeText(remoteDiag.code); await window.rd.writeClipboardText(remoteDiag.code);
showToast("Verbindungscode kopiert", 2200); showToast("Verbindungscode kopiert", 2200);
} catch { } catch {
showToast("Kopieren fehlgeschlagen", 2200); showToast("Kopieren fehlgeschlagen", 2200);
@@ -4624,9 +4734,26 @@ export function App(): ReactElement {
}, },
onPauseDownloads: () => { onPauseDownloads: () => {
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } })); 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: () => { onToggleSchedule: () => {
setSchedulePickerOpen((current) => !current); setSchedulePickerOpen((current) => !current);
setScheduleTimeInput(""); setScheduleTimeInput("");
@@ -5492,7 +5619,7 @@ export function App(): ReactElement {
className={`menu-submenu-dropdown${openSubmenu === "hilfe-remote" ? " is-open" : ""}`} className={`menu-submenu-dropdown${openSubmenu === "hilfe-remote" ? " is-open" : ""}`}
> >
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose </span></button> <button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose </span></button>
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button> <button className="menu-dropdown-item" disabled={supportBundleExporting} onClick={() => { void onExportSupportBundle(); }}><span>{supportBundleExporting ? "Support-Bundle wird erstellt …" : "Support-Bundle exportieren"}</span></button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button> <button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
</div> </div>
</div> </div>
@@ -5843,7 +5970,7 @@ export function App(): ReactElement {
accountEdit={accountEditDialogView} accountEdit={accountEditDialogView}
accountCreate={accountAddDialog} accountCreate={accountAddDialog}
toast={<Toast message={statusToast} />} toast={<SupportBundleToast busy={supportBundleExporting} message={statusToast} />}
dropOverlay={dragOver ? <div className="drop-overlay md-drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div> : null} dropOverlay={dragOver ? <div className="drop-overlay md-drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div> : null}
accountContextMenu={accountContextMenu && activeAccountContextRow ? ( accountContextMenu={accountContextMenu && activeAccountContextRow ? (
<ContextMenu <ContextMenu
@@ -6153,7 +6280,7 @@ export function App(): ReactElement {
type="button" type="button"
title={`${key.masked}\nMaskierte Kennung kopieren`} title={`${key.masked}\nMaskierte Kennung kopieren`}
onClick={() => { onClick={() => {
void navigator.clipboard.writeText(key.masked) void window.rd.writeClipboardText(key.masked)
.then(() => showToast("Maskierte Kennung kopiert", 1800)) .then(() => showToast("Maskierte Kennung kopiert", 1800))
.catch(() => showToast("Kopieren fehlgeschlagen", 2200)); .catch(() => showToast("Kopieren fehlgeschlagen", 2200));
}} }}
@@ -6211,8 +6338,8 @@ export function App(): ReactElement {
<div className="link-popup-list"> <div className="link-popup-list">
{linkPopup.links.map((link, i) => ( {linkPopup.links.map((link, i) => (
<div key={i} className="link-popup-row"> <div key={i} className="link-popup-row">
<button aria-label={`${link.name} kopieren`} className="link-popup-name link-popup-click" type="button" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</button> <button aria-label={`${link.name} kopieren`} className="link-popup-name link-popup-click" type="button" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void window.rd.writeClipboardText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</button>
<button aria-label="Link kopieren" className="link-popup-url link-popup-click" type="button" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button> <button aria-label="Link kopieren" className="link-popup-url link-popup-click" type="button" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void window.rd.writeClipboardText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button>
</div> </div>
))} ))}
</div> </div>
@@ -6220,13 +6347,13 @@ export function App(): ReactElement {
{linkPopup.isPackage && ( {linkPopup.isPackage && (
<button className="btn" onClick={() => { <button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.name).join("\n"); const text = linkPopup.links.map((l) => l.name).join("\n");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); void window.rd.writeClipboardText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Namen kopieren</button> }}>Alle Namen kopieren</button>
)} )}
{linkPopup.isPackage && ( {linkPopup.isPackage && (
<button className="btn" onClick={() => { <button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.url).join("\n"); const text = linkPopup.links.map((l) => l.url).join("\n");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); void window.rd.writeClipboardText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Links kopieren</button> }}>Alle Links kopieren</button>
)} )}
<button className="btn" onClick={() => setLinkPopup(null)}>Schließen</button> <button className="btn" onClick={() => setLinkPopup(null)}>Schließen</button>
+72
View File
@@ -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<T extends string>(values: T[], value: T, present: boolean): T[] {
return present
? [...new Set([...values, value])]
: values.filter((entry) => entry !== value);
}
export function setAccountTargetEnabled<T extends AccountToggleSettings>(
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<void> = Promise.resolve();
public enqueue<T>(task: () => Promise<T>): Promise<T> {
const result = this.tail.then(task);
this.tail = result.then(() => undefined, () => undefined);
return result;
}
}
+1 -1
View File
@@ -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"], ["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"], ["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-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"], ["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."], ["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"], ["In Zwischenablage kopieren", "Copy to clipboard"], ["Einträge anzeigen", "Show entries"], ["Fehlerliste kopiert", "Error list copied"], ["Debug-Token rotieren", "Rotate debug token"],
@@ -119,7 +119,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
<div className="downloads-toolbar" data-visual-region="downloads-toolbar"> <div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button> <button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button> <button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button> <button disabled={!model.canStop} onClick={actions.onStopDownloads} type="button">Stop</button>
{model.scheduleActive {model.scheduleActive
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span> ? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>} : <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
+1
View File
@@ -35,6 +35,7 @@ export const IPC_CHANNELS = {
STATE_UPDATE: "state:update", STATE_UPDATE: "state:update",
CLIPBOARD_DETECTED: "clipboard:detected", CLIPBOARD_DETECTED: "clipboard:detected",
TOGGLE_CLIPBOARD: "clipboard:toggle", TOGGLE_CLIPBOARD: "clipboard:toggle",
WRITE_CLIPBOARD_TEXT: "clipboard:write-text",
GET_SESSION_STATS: "stats:get-session-stats", GET_SESSION_STATS: "stats:get-session-stats",
RESET_SESSION_STATS: "stats:reset-session", RESET_SESSION_STATS: "stats:reset-session",
RESET_DOWNLOAD_STATS: "stats:reset-download", RESET_DOWNLOAD_STATS: "stats:reset-download",
+2 -1
View File
@@ -62,6 +62,7 @@ export interface ElectronApi {
exportQueue: () => Promise<{ saved: boolean }>; exportQueue: () => Promise<{ saved: boolean }>;
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>; importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
toggleClipboard: () => Promise<boolean>; toggleClipboard: () => Promise<boolean>;
writeClipboardText: (text: string) => Promise<boolean>;
pickFolder: () => Promise<string | null>; pickFolder: () => Promise<string | null>;
pickContainers: () => Promise<string[]>; pickContainers: () => Promise<string[]>;
getSessionStats: () => Promise<SessionStats>; getSessionStats: () => Promise<SessionStats>;
@@ -75,7 +76,7 @@ export interface ElectronApi {
cancelBackupImport: () => Promise<void>; cancelBackupImport: () => Promise<void>;
exportOnlineBackup: () => Promise<{ key: string }>; exportOnlineBackup: () => Promise<{ key: string }>;
importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: 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<void>; openLog: () => Promise<void>;
openLogDirectory: () => Promise<void>; openLogDirectory: () => Promise<void>;
openAuditLog: () => Promise<void>; openAuditLog: () => Promise<void>;
+11
View File
@@ -61,4 +61,15 @@ describe("account preload contract", () => {
IPC_CHANNELS.DELETE_ACCOUNT 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"
);
});
}); });
+63
View File
@@ -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([]);
});
});
+51 -5
View File
@@ -5,7 +5,7 @@ import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/A
import { AppHeader } from "../src/renderer/shell/AppHeader"; import { AppHeader } from "../src/renderer/shell/AppHeader";
import { AppShell } from "../src/renderer/shell/AppShell"; import { AppShell } from "../src/renderer/shell/AppShell";
import { buildMainNavigation } from "../src/renderer/shell/shell-model"; 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", () => { describe("desktop shell", () => {
it("uses keyboard-focusable controls for every copy target", () => { it("uses keyboard-focusable controls for every copy target", () => {
@@ -13,8 +13,8 @@ describe("desktop shell", () => {
expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/); expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/);
expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3); expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3);
expect(source).not.toContain("navigator.clipboard.writeText(key.token)"); expect(source).not.toContain("navigator.clipboard.writeText");
expect(source).toContain("navigator.clipboard.writeText(key.masked)"); expect(source).toContain("window.rd.writeClipboardText(key.masked)");
expect(source).toContain("Maskierte Kennung kopiert"); expect(source).toContain("Maskierte Kennung kopiert");
}); });
@@ -35,11 +35,57 @@ describe("desktop shell", () => {
expect(removal).toContain('title: "Ausgewählte Links löschen"'); expect(removal).toContain('title: "Ausgewählte Links löschen"');
}); });
it("does not stack renderer latency on the manager cadence for large active queues", () => { it("renders active download telemetry at a stable half-second cadence", () => {
expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(0); expect(getSnapshotRenderDelay(2_470, true, "downloads")).toBe(500);
expect(getSnapshotRenderDelay(2_470, true, "statistics")).toBe(800); 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(<SupportBundleToast busy message="Alte Meldung" />);
const idleHtml = renderToStaticMarkup(<SupportBundleToast busy={false} message="Export beendet" />);
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", () => { it("keeps application menus mounted for animated opening and closing", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); 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"); const css = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
+248 -16
View File
@@ -13,6 +13,7 @@ afterEach(() => {
resetDebridLinkRuntimeStateForTests(); resetDebridLinkRuntimeStateForTests();
resetMegaDebridRuntimeStateForTests(); resetMegaDebridRuntimeStateForTests();
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS; delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
delete process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS;
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -1429,6 +1430,181 @@ describe("debrid service", () => {
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<void>((resolve) => {
markConnectStarted = resolve;
});
let connectSignal: AbortSignal | undefined;
let connectCalls = 0;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
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<void>((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<typeof setTimeout> | 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<void>((resolve) => {
markOldConnectStarted = resolve;
});
const connectPasswords: string[] = [];
const getLinkTokens: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
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<void>((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 () => { it("treats a Mega-Debrid 'Fichier supprimé' as transient: no account cooldown, German message, retryable", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
@@ -1693,7 +1869,6 @@ describe("debrid service", () => {
try { try {
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i); await expect(service.unrestrictLink("https://rapidgator.net/file/abort-mega-web", controller.signal)).rejects.toThrow(/aborted/i);
expect(megaWeb).toHaveBeenCalledTimes(1); expect(megaWeb).toHaveBeenCalledTimes(1);
expect(megaWeb.mock.calls[0]?.[1]).toBe(controller.signal);
} finally { } finally {
clearTimeout(abortTimer); clearTimeout(abortTimer);
} }
@@ -2093,6 +2268,7 @@ describe("debrid service", () => {
it("single Mega-Debrid account: a long Web abort parks only the slow link and does NOT freeze the sole account", async () => { 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_ABORT_MIN_RUN_MS = "0";
process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS = "20";
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@@ -2110,13 +2286,18 @@ describe("debrid service", () => {
}; };
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const controller = new AbortController();
let calls = 0; let calls = 0;
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => { const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
calls += 1; calls += 1;
if (calls === 1) { if (calls === 1) {
controller.abort("simulated-60s-timeout"); return new Promise((_resolve, reject) => {
return Promise.reject(new Error("aborted")); const onAbort = (): void => reject(new Error("aborted"));
if (signal?.aborted) {
onAbort();
return;
}
signal?.addEventListener("abort", onAbort, { once: true });
});
} }
return Promise.resolve({ return Promise.resolve({
fileName: "healthy.rar", fileName: "healthy.rar",
@@ -2128,7 +2309,7 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); 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(err).toBeTruthy();
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i); expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
@@ -2280,7 +2461,7 @@ describe("debrid service", () => {
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true); expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
}, 20000); }, 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 process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
@@ -2310,17 +2491,68 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const user1Key = `${getMegaDebridAccountId("user1")}:web`; const user1Key = `${getMegaDebridAccountId("user1")}:web`;
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down. const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-1");
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow();
expect(loginsSeen).toContain("user1"); 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(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<void>((_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")); expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000); }, 20000);
+383 -5
View File
@@ -6,7 +6,7 @@ import crypto from "node:crypto";
import { EventEmitter, once } from "node:events"; import { EventEmitter, once } from "node:events";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
import { DiskReservationCoordinator } from "../src/main/disk-space"; import { DiskReservationCoordinator } from "../src/main/disk-space";
import { defaultSettings } from "../src/main/constants"; 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 { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; import { getItemLogPath, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log"; import { initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { createStoragePaths, emptySession } from "../src/main/storage"; import { createStoragePaths, emptySession, loadSession, saveSession } from "../src/main/storage";
import { primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid"; import { getMegaDebridAccountCooldownState, primeDebridLinkRuntimeCooldownForTests, resetDebridLinkRuntimeStateForTests, primeMegaDebridRuntimeCooldownForTests, resetMegaDebridRuntimeStateForTests, primeMegaDebridInFlightForTests } from "../src/main/debrid";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log";
import { UnrestrictedLink } from "../src/main/realdebrid"; import { UnrestrictedLink } from "../src/main/realdebrid";
import { resetVideoToolingCache } from "../src/main/video-processor"; 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 tempDirs: string[] = [];
const originalFetch = globalThis.fetch; 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", () => { describe("download start account gate", () => {
it("disables every start path when no usable account is active", async () => { it("disables every start path when no usable account is active", async () => {
const createManager = () => { const createManager = () => {
@@ -760,6 +797,8 @@ async function removeDirWithRetries(dir: string): Promise<void> {
afterEach(async () => { afterEach(async () => {
globalThis.fetch = originalFetch; 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_FFMPEG_BIN;
delete process.env.RD_FFPROBE_BIN; delete process.env.RD_FFPROBE_BIN;
resetVideoToolingCache(); resetVideoToolingCache();
@@ -861,6 +900,155 @@ describe("download manager", () => {
expect(invalidateMegaSession).toHaveBeenCalledTimes(1); 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<string, unknown>;
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<void>((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<UnrestrictedLink | null>((_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", () => { 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-mega-account-refresh-"));
tempDirs.push(root); tempDirs.push(root);
@@ -2584,6 +2772,134 @@ describe("download manager", () => {
} }
}); });
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<UnrestrictedLink> => ({
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 () => { it("treats tiny Real-Debrid resume size mismatches as completed instead of looping", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
@@ -8323,11 +8639,73 @@ describe("download manager", () => {
progressPercent: 0, progressPercent: 0,
lastError: "", lastError: "",
fullStatus: "Wartet", 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 () => { 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-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
+2 -1
View File
@@ -708,7 +708,7 @@ describe("downloads view", () => {
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect"); 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({ const pausedToolbar = DownloadsToolbar({
actions: createActions(), actions: createActions(),
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true }) 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, "Start").props.disabled).toBe(true);
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true); expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false); 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", () => { it("enables package movement only for a visible selected package row", () => {
+197 -5
View File
@@ -45,7 +45,7 @@ describe("mega-web-fallback", () => {
generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string } | null>; generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string } | null>;
sessions: Map<string, { cookie: string; setAt: number }>; sessions: Map<string, { cookie: string; setAt: number }>;
}; };
vi.spyOn(internals, "login") const login = vi.spyOn(internals, "login")
.mockResolvedValueOnce("first-stale-cookie") .mockResolvedValueOnce("first-stale-cookie")
.mockResolvedValueOnce("second-stale-cookie"); .mockResolvedValueOnce("second-stale-cookie");
vi.spyOn(internals, "generate") vi.spyOn(internals, "generate")
@@ -55,12 +55,58 @@ describe("mega-web-fallback", () => {
}) })
.mockResolvedValueOnce({ directUrl: "https://mega.direct/retry", fileName: "retry.bin" }); .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); 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<void>((resolve) => {
releaseOldGenerate = resolve;
});
const oldGenerateStarted = new Promise<void>((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<string>;
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string } | null>;
sessions: Map<string, { cookie: string; setAt: number }>;
};
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 () => { it("does not cache old credentials from a queued request after invalidation", async () => {
let releaseFirstLogin: () => void = () => {}; let releaseFirstLogin: () => void = () => {};
let markFirstLoginStarted: () => 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" }); 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 first = fallback.unrestrict("https://mega.debrid/first", undefined, { login: "old-user", password: "old-pass" });
const firstSettled = first.catch((error: unknown) => error);
await firstLoginStarted; await firstLoginStarted;
const queued = fallback.unrestrict("https://mega.debrid/queued", undefined, { login: "old-user", password: "old-pass" }); const queued = fallback.unrestrict("https://mega.debrid/queued", undefined, { login: "old-user", password: "old-pass" });
const queuedSettled = queued.catch((error: unknown) => error);
fallback.invalidateSession(); fallback.invalidateSession();
releaseFirstLogin(); releaseFirstLogin();
await expect(Promise.all([first, queued])).resolves.toHaveLength(2); await expect(firstSettled).resolves.toBeInstanceOf(Error);
expect(loginCount).toBe(2); await expect(queuedSettled).resolves.toBeInstanceOf(Error);
expect(loginCount).toBe(1);
expect(internals.sessions.size).toBe(0); 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<void>((resolve) => {
releaseOldLogin = resolve;
});
const oldLoginStarted = new Promise<void>((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<string>;
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<void>((resolve) => {
releaseFirstLogin = resolve;
});
const firstLoginStarted = new Promise<void>((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<string>;
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>;
queues: Map<string, Promise<unknown>>;
};
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<void>((resolve) => {
releaseOldLogin = resolve;
});
const oldLoginStarted = new Promise<void>((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<string>;
generate: (link: string, cookie: string, signal?: AbortSignal) => Promise<{ directUrl: string; fileName: string }>;
queues: Map<string, Promise<unknown>>;
};
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 () => { it("logs in, fetches HTML, parses code, and polls AJAX for direct url", async () => {
let fetchCallCount = 0; let fetchCallCount = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => { globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
+1 -1
View File
@@ -482,7 +482,7 @@ describe("bandwidth chart palette", () => {
expect(chartBlock).toContain('role="img"'); expect(chartBlock).toContain('role="img"');
expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"'); expect(chartBlock).toContain('aria-label="Bandbreitenverlauf der letzten 60 Sekunden"');
expect(chartBlock).toContain('window.matchMedia("(prefers-reduced-motion: reduce)")'); 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", () => { it("asks for confirmation before deleting all saved download statistics", () => {
+406 -1
View File
@@ -3,13 +3,19 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { buildSupportBundle } from "../src/main/support-bundle"; import {
buildSupportBundle,
createSupportBundleExportRunner,
writeSupportBundleAtomically
} from "../src/main/support-bundle";
import type { DownloadManager } from "../src/main/download-manager"; import type { DownloadManager } from "../src/main/download-manager";
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
const tempDirs: string[] = []; const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join(""); const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
afterEach(() => { afterEach(() => {
shutdownSessionLog();
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { } try { fs.rmSync(dir, { recursive: true, force: true }); } catch { }
} }
@@ -32,6 +38,99 @@ function fakeManager(): DownloadManager {
} as unknown as DownloadManager; } 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)", () => { describe("buildSupportBundle (async, non-blocking)", () => {
it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => { it("returns a Promise and produces a valid zip with overview + a real on-disk file", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
@@ -71,4 +170,310 @@ describe("buildSupportBundle (async, non-blocking)", () => {
await new Promise((resolve) => setTimeout(resolve, 0)); 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("<redacted>");
expect(archiveText).toContain("<local-path>");
const itemOverview = JSON.parse(zip.getEntry("overview/items.json")?.getData().toString("utf8") || "{}") as {
items?: Array<Record<string, unknown>>;
};
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<void>((resolve) => { signalBuildStarted = resolve; });
const buildPending = new Promise<Buffer>((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<void>((resolve) => { signalWriteStarted = resolve; });
const writePending = new Promise<void>((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"]);
});
}); });
+1
View File
@@ -139,6 +139,7 @@ export function createVisualElectronApi(
fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive; fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive;
return fixture.snapshot.clipboardActive; return fixture.snapshot.clipboardActive;
}, },
writeClipboardText: async () => true,
pickFolder: async () => "C:\\Visual\\Selected", pickFolder: async () => "C:\\Visual\\Selected",
pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"], pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"],
getSessionStats: async () => ({ getSessionStats: async () => ({