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
+32 -13
View File
@@ -859,19 +859,38 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" };
}
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
this.audit("INFO", "Support-Bundle exportiert");
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
});
return {
buffer: await buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
defaultFileName: getSupportBundleDefaultFileName()
};
}
public getSupportBundleDefaultFileName(): string {
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
const buffer = await buildSupportBundle(this.manager, this.storagePaths.baseDir, {
hostDiagnosticsMode: "cached",
debugSetupMode: "deferred"
});
return {
buffer,
defaultFileName: getSupportBundleDefaultFileName()
};
}
public recordSupportBundleExported(filePath: string, bytes: number): void {
const snapshot = this.manager.getSnapshot();
const fields = {
fileName: path.basename(filePath),
bytes,
packageCount: Object.keys(snapshot.session.packages).length,
itemCount: Object.keys(snapshot.session.items).length
};
this.audit("INFO", "Support-Bundle exportiert", fields);
logTraceEvent("INFO", "support", "Support-Bundle exportiert", fields);
}
public recordSupportBundleExportFailed(error: unknown): void {
const fields = {
error: error instanceof Error ? error.message : String(error)
};
this.audit("ERROR", "Support-Bundle-Export fehlgeschlagen", fields);
logTraceEvent("ERROR", "support", "Support-Bundle-Export fehlgeschlagen", fields);
}
public getSupportBundleDefaultFileName(): string {
return getSupportBundleDefaultFileName();
}
+166 -89
View File
@@ -288,16 +288,17 @@ function getDebridLinkKeyHostCooldownState(
type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000;
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
const MEGA_DEBRID_SLOW_LINK_RETRY_MS = 120_000;
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
const MEGA_DEBRID_ACCOUNT_ATTEMPT_TIMEOUT_MS_DEFAULT = 120_000;
// A Mega-Web account abort (the shared unrestrict timeout firing while this
// account ran) only cools the account down — so the next attempt rotates on —
// if it actually ran this long. Below this, it's treated as a quick user-cancel
// (no cooldown). Env-overridable for tests.
const MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT = 8000;
function getMegaDebridAbortMinRunMs(): number {
function getMegaDebridAbortMinRunMs(): number {
const fromEnv = Number(process.env.RD_MEGA_ABORT_MIN_RUN_MS ?? NaN);
return Number.isFinite(fromEnv) && fromEnv >= 0 ? Math.floor(fromEnv) : MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT;
}
@@ -328,9 +329,23 @@ export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
return streak;
}
export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
megaDebridEmptyResponseStreaks.delete(accountId);
}
export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
megaDebridEmptyResponseStreaks.delete(accountId);
}
export function getMegaDebridAccountAttemptTimeoutMs(): number {
const fromEnv = Number(process.env.RD_MEGA_ACCOUNT_ATTEMPT_TIMEOUT_MS ?? NaN);
return Number.isFinite(fromEnv) && fromEnv >= 10 && fromEnv <= 10 * 60 * 1000
? Math.floor(fromEnv)
: MEGA_DEBRID_ACCOUNT_ATTEMPT_TIMEOUT_MS_DEFAULT;
}
export function clearMegaDebridAccountRuntimeStates(accountKeys: Iterable<string>): void {
for (const accountKey of accountKeys) {
megaDebridAccountCooldowns.delete(accountKey);
megaDebridEmptyResponseStreaks.delete(accountKey);
}
}
export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridAccountCooldowns.clear();
@@ -720,7 +735,7 @@ function isRetryableErrorText(text: string): boolean {
|| lower.includes("html statt json");
}
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) {
await sleep(ms);
return;
@@ -745,8 +760,34 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
};
signal.addEventListener("abort", onAbort, { once: true });
});
}
});
}
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 {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -1797,7 +1838,7 @@ function buildBestDebridRequests(link: string, token: string): BestDebridRequest
];
}
class MegaDebridClient {
class MegaDebridClient {
private megaWebUnrestrict?: MegaWebUnrestrictor;
private login: string;
@@ -1808,31 +1849,47 @@ class MegaDebridClient {
private allowApiFallback: boolean;
private static cachedApiTokens = new Map<string, { token: string; at: number }>();
private static cachedApiTokens = new Map<string, { token: string; at: number; generation: number }>();
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);
}
}
private static pendingConnects = new Map<string, Promise<string | null>>();
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
const keep = new Set<string>();
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
const keep = new Set<string>();
for (const login of activeLogins) {
keep.add(String(login || "").toLowerCase());
}
for (const login of MegaDebridClient.cachedApiTokens.keys()) {
if (!keep.has(login)) {
MegaDebridClient.cachedApiTokens.delete(login);
}
}
for (const login of MegaDebridClient.pendingConnects.keys()) {
if (!keep.has(login)) {
MegaDebridClient.pendingConnects.delete(login);
}
}
}
const knownLogins = new Set<string>([
...MegaDebridClient.cachedApiTokens.keys(),
...MegaDebridClient.pendingConnects.keys()
]);
for (const login of knownLogins) {
if (!keep.has(login)) {
MegaDebridClient.invalidateCredential(login);
}
}
}
public static clearCachedApiToken(login: string): void {
const key = String(login || "").toLowerCase();
MegaDebridClient.cachedApiTokens.delete(key);
MegaDebridClient.pendingConnects.delete(key);
public static clearCachedApiToken(login: string): void {
const key = String(login || "").toLowerCase();
MegaDebridClient.invalidateCredential(key);
}
public constructor(login: string, password: string, mode: "api" | "web", allowApiFallback: boolean, megaWebUnrestrict?: MegaWebUnrestrictor) {
@@ -1847,60 +1904,69 @@ class MegaDebridClient {
return this.login.trim().toLowerCase();
}
private async connectApi(signal?: AbortSignal): Promise<string | null> {
const key = this.cacheKey;
const cached = MegaDebridClient.cachedApiTokens.get(key);
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" });
return cached.token;
}
const pending = MegaDebridClient.pendingConnects.get(key);
if (pending) {
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" });
return pending;
}
const promise = this.doConnectApi(signal).finally(() => {
MegaDebridClient.pendingConnects.delete(key);
});
MegaDebridClient.pendingConnects.set(key, promise);
return promise;
private async connectApi(signal?: AbortSignal): Promise<string | null> {
const key = this.cacheKey;
const generation = MegaDebridClient.getCredentialGeneration(key);
const cached = MegaDebridClient.cachedApiTokens.get(key);
if (cached && cached.generation === generation && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: `cached(${Math.floor((Date.now() - cached.at) / 1000)}s)`, outcome: "ok" });
return waitForPromiseWithSignal(Promise.resolve(cached.token), signal);
}
const pending = MegaDebridClient.pendingConnects.get(key);
if (pending && pending.generation === generation) {
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "pending-join", outcome: "ok" });
return waitForPromiseWithSignal(pending.promise, signal);
}
const promise = this.doConnectApi(key, generation);
const entry = { generation, promise };
MegaDebridClient.pendingConnects.set(key, entry);
const clearPending = (): void => {
if (MegaDebridClient.pendingConnects.get(key) === entry) {
MegaDebridClient.pendingConnects.delete(key);
}
};
void promise.then(clearPending, clearPending);
return waitForPromiseWithSignal(promise, signal);
}
private clearTokenCache(): void {
MegaDebridClient.cachedApiTokens.delete(this.cacheKey);
}
private async doConnectApi(signal?: AbortSignal): Promise<string | null> {
const connectStartedAt = Date.now();
const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`;
const response = await fetch(url, {
headers: { "User-Agent": DEBRID_USER_AGENT },
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
const text = await response.text();
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
this.clearTokenCache();
}
private async doConnectApi(cacheKey: string, generation: number): Promise<string | null> {
const connectStartedAt = Date.now();
const url = `${MEGA_DEBRID_API_BASE}?action=connectUser&login=${encodeURIComponent(this.login)}&password=${encodeURIComponent(this.password)}`;
const response = await fetch(url, {
headers: { "User-Agent": DEBRID_USER_AGENT },
signal: AbortSignal.timeout(API_TIMEOUT_MS)
});
const text = await response.text();
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation);
}
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `HTTP ${response.status}` });
return null;
}
const payload = parseJsonSafe(text);
if (!payload || payload.response_code !== "ok") {
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
this.clearTokenCache();
const payload = parseJsonSafe(text);
if (!payload || payload.response_code !== "ok") {
if (payload && String(payload.response_code || "").toLowerCase().includes("token")) {
MegaDebridClient.invalidateCredentialIfCurrent(cacheKey, generation);
}
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: `response_code=${payload?.response_code || "?"} ${String(payload?.response_text || "").slice(0, 80)}`.trim() });
return null;
}
const token = String(payload.token || "").trim();
if (!token) {
if (!token) {
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "error", detail: "leeres Token" });
return null;
}
MegaDebridClient.cachedApiTokens.set(this.cacheKey, { token, at: Date.now() });
return null;
}
if (MegaDebridClient.getCredentialGeneration(cacheKey) !== generation) {
return null;
}
MegaDebridClient.cachedApiTokens.set(cacheKey, { token, at: Date.now(), generation });
traceConversionPhase({ phase: "token", provider: "megadebrid-api", tokenState: "fresh-login", workMs: Date.now() - connectStartedAt, outcome: "ok" });
return token;
}
@@ -2121,11 +2187,15 @@ class MegaDebridClient {
});
const testStartedAt = Date.now();
usableAccountSeen = true;
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
try {
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
const result = await client.unrestrictLink(link, signal);
usableAccountSeen = true;
megaDebridInFlight.set(cooldownKey, (megaDebridInFlight.get(cooldownKey) ?? 0) + 1);
const accountAttemptTimeoutSignal = AbortSignal.timeout(getMegaDebridAccountAttemptTimeoutMs());
const accountAttemptSignal = signal
? AbortSignal.any([signal, accountAttemptTimeoutSignal])
: accountAttemptTimeoutSignal;
try {
const client = new MegaDebridClient(account.login, account.password, mode, allowApiFallback, megaWebUnrestrict);
const result = await client.unrestrictLink(link, accountAttemptSignal);
clearMegaDebridAccountCooldownState(cooldownKey);
clearMegaDebridEmptyResponseStreak(cooldownKey);
const elapsedMs = Date.now() - testStartedAt;
@@ -2149,9 +2219,12 @@ class MegaDebridClient {
sourceAccountId: account.id,
sourceAccountLabel: account.label
};
} catch (error) {
const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
} catch (error) {
const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
if (signal?.aborted) {
throw error;
}
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
// account-wide cooldown exists ONLY to make the retry rotate to another
// account — so it is set only when another usable account actually exists.
@@ -2160,8 +2233,9 @@ class MegaDebridClient {
// a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so
// we park just this link (mega_debrid_slow_link) and leave the account free
// for other items. A quick user-cancel (below the min run) parks nothing.
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const accountAttemptTimedOut = accountAttemptTimeoutSignal.aborted;
const ranLongEnough = accountAttemptTimedOut || elapsedMs >= getMegaDebridAbortMinRunMs();
const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
if (candidate.account.id === account.id) {
return count;
@@ -2190,15 +2264,18 @@ class MegaDebridClient {
detail: `${abortText}${rotateToAnotherAccount ? ` cd=${Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000)}s` : ranLongEnough ? ` slowlink=${Math.ceil(MEGA_DEBRID_SLOW_LINK_RETRY_MS / 1000)}s` : ""}`
});
failures.push(`Mega-Debrid${accountLabel}: ${abortText}`);
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
elapsedMs,
reason: abortText,
cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
next: rotateToAnotherAccount ? "naechster Account beim Retry" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
});
if (ranLongEnough && !rotateToAnotherAccount) {
throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
}
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
elapsedMs,
reason: abortText,
cooldownSec: rotateToAnotherAccount ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
next: rotateToAnotherAccount ? "naechster Account im selben Versuch" : "Einzel-Retry (Account bleibt fuer andere Items frei)"
});
if (rotateToAnotherAccount) {
continue;
}
if (ranLongEnough && !rotateToAnotherAccount) {
throw new Error(`mega_debrid_slow_link:${MEGA_DEBRID_SLOW_LINK_RETRY_MS}:Mega-Debrid${accountLabel}: ${abortText}`);
}
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
}
const failure = MegaDebridClient.classifyAccountFailure(error);
+184 -46
View File
@@ -54,7 +54,7 @@ function releaseTlsSkip(): void {
}
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, clearMegaDebridAccountRuntimeStates, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getMegaDebridAccountAttemptTimeoutMs, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error";
@@ -78,7 +78,7 @@ type ActiveTask = {
itemId: string;
packageId: string;
abortController: AbortController;
abortReason: "stop" | "cancel" | "reconnect" | "package_toggle" | "stall" | "shutdown" | "reset" | "none";
abortReason: "stop" | "pause" | "cancel" | "reconnect" | "package_toggle" | "settings_refresh" | "stall" | "shutdown" | "reset" | "none";
resumable: boolean;
nonResumableCounted: boolean;
freshRetryUsed?: boolean;
@@ -122,7 +122,11 @@ const DEFAULT_DOWNLOAD_STALL_TIMEOUT_MS = 10000;
const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT_MS = 25000;
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 60000;
const DEFAULT_GLOBAL_STALL_WATCHDOG_TIMEOUT_MS = 60000;
const MAX_UNRESTRICT_TIMEOUT_MS = 2_147_483_647;
const UNRESTRICT_TIMEOUT_OVERHEAD_MS = 15_000;
const DEFAULT_POST_EXTRACT_TIMEOUT_MS = 4 * 60 * 60 * 1000;
@@ -342,7 +346,7 @@ function getPostExtractTimeoutMs(): number {
return DEFAULT_POST_EXTRACT_TIMEOUT_MS;
}
function getUnrestrictTimeoutMs(): number {
function getUnrestrictTimeoutMs(): number {
const fromEnv = Number(process.env.RD_UNRESTRICT_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 5000 && fromEnv <= 15 * 60 * 1000) {
return Math.floor(fromEnv);
@@ -476,6 +480,31 @@ function isArchiveLikePath(filePath: string): boolean {
function extractHosterKey(link: string): string {
return extractHosterFromUrl(link);
}
export function getUnrestrictTimeoutMsForProviderPlan(settings: AppSettings, providerPlan: readonly DebridProvider[]): number {
const baseTimeoutMs = getUnrestrictTimeoutMs();
const megaModes = new Set<"api" | "web">();
for (const provider of providerPlan) {
const effectiveProvider = resolveMegaDebridProvider(settings, provider);
if (effectiveProvider === "megadebrid-api") {
megaModes.add("api");
} else if (effectiveProvider === "megadebrid-web") {
megaModes.add("web");
}
}
if (megaModes.size === 0) {
return baseTimeoutMs;
}
let accountCount = 0;
for (const mode of megaModes) {
accountCount += getAvailableMegaDebridAccounts(settings, mode).length;
}
if (accountCount === 0) {
return baseTimeoutMs;
}
const calculatedTimeoutMs = baseTimeoutMs + UNRESTRICT_TIMEOUT_OVERHEAD_MS + accountCount * getMegaDebridAccountAttemptTimeoutMs();
return Math.min(MAX_UNRESTRICT_TIMEOUT_MS, Math.max(baseTimeoutMs, calculatedTimeoutMs));
}
function isLargeBinaryLikePath(filePath: string): boolean {
const lower = path.basename(String(filePath || "")).toLowerCase();
@@ -552,9 +581,14 @@ function shouldPreflightFinalizeItemFromDisk(item: DownloadItem): boolean {
|| text.includes("server ignorierte range");
}
function isResumeHardResetReason(errorText: string): boolean {
const text = String(errorText || "");
return text.startsWith("resume_download_underflow:");
export function isResumeHardResetReason(errorText: string, renewedLinkFailures = 0): boolean {
const text = String(errorText || "").toLowerCase();
return text.startsWith("resume_download_underflow:")
|| (renewedLinkFailures > 0 && (
text.startsWith("range_ignored_on_resume:")
|| text.startsWith("range_mismatch_on_resume:")
|| text.includes("server ignorierte range")
));
}
function isRealDebridProvider(provider: string | null | undefined): boolean {
@@ -833,7 +867,7 @@ function providerLabel(provider: DownloadItem["provider"]): string {
return "Debrid";
}
function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider | null): DebridProvider | null {
function resolveMegaDebridProvider(settings: AppSettings, provider: DebridProvider | null): DebridProvider | null {
if (provider !== "megadebrid") {
return provider;
}
@@ -1701,6 +1735,15 @@ function retryDelayWithJitter(attempt: number, baseMs: number): number {
return Math.floor(jitter);
}
function isMegaDebridProviderKey(value: string): boolean {
return value === "megadebrid"
|| value === "megadebrid-api"
|| value === "megadebrid-web"
|| value.startsWith("megadebrid:")
|| value.startsWith("megadebrid-api:")
|| value.startsWith("megadebrid-web:");
}
export function getDiskWriteWaitReason(error: unknown): string | null {
const text = compactErrorText(error).replace(/^Error:\s*/i, "");
const marked = text.match(/^disk_write_wait:(.+)$/i);
@@ -2194,25 +2237,31 @@ export class DownloadManager extends EventEmitter {
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
const previous = this.settings;
const previousMegaPool = (["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => `${mode}:${account.id}:${account.password}`))
const previousMegaAccounts = (["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(previous, mode).map((account) => ({ mode, account })));
const previousMegaPoolEntries = new Map<string, string>(previousMegaAccounts.map(({ mode, account }) => [`${account.id}:${mode}`, account.password]));
const previousMegaPool = [...previousMegaPoolEntries]
.map(([key, password]) => `${key}:${password}`)
.sort()
.join("\n");
const nextMegaAccounts = (["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode));
const nextMegaPool = (["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => `${mode}:${account.id}:${account.password}`))
const nextMegaPoolEntries = new Map<string, string>((["api", "web"] as const)
.flatMap((mode) => getAvailableMegaDebridAccounts(next, mode).map((account) => [`${account.id}:${mode}`, account.password] as const)));
const nextMegaPool = [...nextMegaPoolEntries]
.map(([key, password]) => `${key}:${password}`)
.sort()
.join("\n");
const previousMegaWebCredentials = getMegaDebridAccountsForMode(previous, "web")
const previousMegaWebPool = getAvailableMegaDebridAccounts(previous, "web")
.map((account) => `${account.id}:${account.password}`)
.sort()
.join("\n");
const nextMegaWebCredentials = getMegaDebridAccountsForMode(next, "web")
const nextMegaWebPool = getAvailableMegaDebridAccounts(next, "web")
.map((account) => `${account.id}:${account.password}`)
.sort()
.join("\n");
const megaPoolChanged = previousMegaPool !== nextMegaPool && nextMegaAccounts.length > 0;
const megaPoolChanged = previousMegaPool !== nextMegaPool;
const megaWebPoolChanged = previousMegaWebPool !== nextMegaWebPool;
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
const now = nowMs();
@@ -2222,7 +2271,7 @@ export class DownloadManager extends EventEmitter {
this.runtimePersistedTotalMs = this.settings.totalRuntimeAllTimeMs || 0;
this.runtimePersistedAt = now;
this.ensureProviderDailyUsageFresh(nowMs());
if (previousMegaWebCredentials !== nextMegaWebCredentials) {
if (megaWebPoolChanged) {
this.invalidateMegaSessionFn?.();
}
this.debridService.setSettings(next);
@@ -2280,7 +2329,34 @@ export class DownloadManager extends EventEmitter {
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
}
if (!opts?.settingsOnlyImport && megaPoolChanged) {
if (megaPoolChanged) {
const changedAccountKeys = new Set<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();
}
@@ -5372,8 +5448,7 @@ export class DownloadManager extends EventEmitter {
item.targetPath = "";
item.provider = null;
item.fullStatus = "Wartet";
item.onlineStatus = undefined;
item.updatedAt = nowMs();
item.updatedAt = nowMs();
}
const postProcessTasks = this.abortPackagePostProcessing(packageId, "reset");
@@ -5456,8 +5531,7 @@ export class DownloadManager extends EventEmitter {
item.targetPath = "";
item.provider = null;
item.fullStatus = "Wartet";
item.onlineStatus = undefined;
item.updatedAt = nowMs();
item.updatedAt = nowMs();
if (this.session.running) {
this.runItemIds.add(itemId);
@@ -5923,7 +5997,7 @@ export class DownloadManager extends EventEmitter {
});
}
public stop(options?: { parkForRestart?: boolean }): void {
public stop(options?: { parkForRestart?: boolean }): void {
const parkForRestart = options?.parkForRestart === true;
const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop";
const keepExtraction = this.settings.autoExtractWhenStopped;
@@ -5936,7 +6010,13 @@ export class DownloadManager extends EventEmitter {
this.retryAfterByItem.clear();
this.providerStartReservations.clear();
this.pacedStartReservationByItem.clear();
this.retryStateByItem.clear();
this.retryStateByItem.clear();
this.invalidateMegaSessionFn?.();
for (const key of [...this.providerFailures.keys()]) {
if (isMegaDebridProviderKey(key)) {
this.providerFailures.delete(key);
}
}
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
this.lastGlobalProgressAt = nowMs();
this.speedEvents = [];
@@ -6086,11 +6166,16 @@ export class DownloadManager extends EventEmitter {
}
this.session.paused = !this.session.paused;
if (!wasPaused && this.session.paused) {
this.speedEvents = [];
this.speedBytesLastWindow = 0;
this.speedBytesPerPackage.clear();
this.speedEventsHead = 0;
if (!wasPaused && this.session.paused) {
this.speedEvents = [];
this.speedBytesLastWindow = 0;
this.speedBytesPerPackage.clear();
this.speedEventsHead = 0;
this.invalidateMegaSessionFn?.();
for (const active of this.activeTasks.values()) {
active.abortReason = "pause";
active.abortController.abort("pause");
}
}
if (wasPaused && !this.session.paused) {
@@ -8177,7 +8262,7 @@ export class DownloadManager extends EventEmitter {
}
}
private getProviderOrder(): DebridProvider[] {
private getProviderOrder(): DebridProvider[] {
if (this.settings.providerOrder && this.settings.providerOrder.length > 0) {
return [...this.settings.providerOrder];
}
@@ -8185,8 +8270,36 @@ export class DownloadManager extends EventEmitter {
this.settings.providerPrimary,
this.settings.providerSecondary !== "none" ? this.settings.providerSecondary : null,
this.settings.providerTertiary !== "none" ? this.settings.providerTertiary : null
].filter(Boolean) as DebridProvider[];
}
].filter(Boolean) as DebridProvider[];
}
private getReachableUnrestrictProviderPlan(item: DownloadItem, preferredLeadProvider: DebridProvider | null): DebridProvider[] {
const baseOrder = this.getProviderOrder();
const order = preferredLeadProvider && baseOrder.includes(preferredLeadProvider)
? [preferredLeadProvider, ...baseOrder.filter((provider) => provider !== preferredLeadProvider)]
: baseOrder;
const candidates: DebridProvider[] = [];
const hosterKey = extractHosterKey(item.url);
const routedProvider = hosterKey ? (this.settings.hosterRouting || {})[hosterKey] : undefined;
if (routedProvider && this.isProviderConfigured(routedProvider)) {
candidates.push(routedProvider);
if (!this.settings.autoProviderFallback) {
return [resolveMegaDebridProvider(this.settings, routedProvider) || routedProvider];
}
}
const configuredOrder = order.filter((provider) => this.isProviderConfigured(provider));
candidates.push(...(this.settings.autoProviderFallback ? configuredOrder : configuredOrder.slice(0, 1)));
const seen = new Set<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 {
const hosterKey = extractHosterKey(item.url);
@@ -8677,17 +8790,17 @@ export class DownloadManager extends EventEmitter {
return;
}
const VALIDATING_STUCK_MS = getUnrestrictTimeoutMs() + 15000;
for (const active of this.activeTasks.values()) {
for (const active of this.activeTasks.values()) {
if (active.abortController.signal.aborted) {
continue;
}
const item = this.session.items[active.itemId];
if (!item || item.status !== "validating") {
continue;
}
const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0;
if (ageMs > VALIDATING_STUCK_MS) {
if (!item || item.status !== "validating") {
continue;
}
const validatingStuckMs = getUnrestrictTimeoutMsForProviderPlan(this.settings, this.getReachableUnrestrictProviderPlan(item, null)) + 15_000;
const ageMs = item.updatedAt > 0 ? now - item.updatedAt : 0;
if (ageMs > validatingStuckMs) {
logger.warn(`Validating-Stuck erkannt: item=${item.fileName || active.itemId}, ${Math.floor(ageMs / 1000)}s ohne Fortschritt`);
active.abortReason = "stall";
active.abortController.abort("stall");
@@ -9172,7 +9285,9 @@ export class DownloadManager extends EventEmitter {
this.emitState();
return;
}
const unrestrictTimeoutSignal = AbortSignal.timeout(getUnrestrictTimeoutMs());
const unrestrictProviderPlan = this.getReachableUnrestrictProviderPlan(item, preferredLeadProvider);
const unrestrictTimeoutMs = getUnrestrictTimeoutMsForProviderPlan(this.settings, unrestrictProviderPlan);
const unrestrictTimeoutSignal = AbortSignal.timeout(unrestrictTimeoutMs);
const unrestrictedSignal = AbortSignal.any([active.abortController.signal, unrestrictTimeoutSignal]);
let unrestricted;
try {
@@ -9193,7 +9308,7 @@ export class DownloadManager extends EventEmitter {
traceConversionPhase({
phase: "caller-timeout",
outcome: "timeout",
detail: `Caller-Budget ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
detail: `Caller-Budget ${Math.ceil(unrestrictTimeoutMs / 1000)}s erschoepft (siehe letzte Phase fuer in-flight Provider/Account)`
});
}
throw innerError;
@@ -9203,7 +9318,7 @@ export class DownloadManager extends EventEmitter {
} catch (unrestrictError) {
if (!active.abortController.signal.aborted && unrestrictTimeoutSignal.aborted) {
this.recordProviderFailure(cooldownProvider);
throw new Error(`Unrestrict Timeout nach ${Math.ceil(getUnrestrictTimeoutMs() / 1000)}s`);
throw new Error(`Unrestrict Timeout nach ${Math.ceil(unrestrictTimeoutMs / 1000)}s`);
}
const errText = compactErrorText(unrestrictError);
if (isUnrestrictFailure(errText) && !isHosterUnavailableError(errText)) {
@@ -9484,7 +9599,19 @@ export class DownloadManager extends EventEmitter {
this.dropItemContribution(item.id);
}
this.retryStateByItem.delete(item.id);
} else if (reason === "shutdown") {
} else if (reason === "pause") {
item.status = "queued";
item.speedBps = 0;
item.fullStatus = "Pausiert";
this.retryAfterByItem.delete(item.id);
this.retryStateByItem.set(item.id, {
freshRetryUsed: Boolean(active.freshRetryUsed),
resumeHardResetUsed: Boolean(active.resumeHardResetUsed),
stallRetries: Number(active.stallRetries || 0),
genericErrorRetries: Number(active.genericErrorRetries || 0),
unrestrictRetries: Number(active.unrestrictRetries || 0)
});
} else if (reason === "shutdown") {
this.logPackageForItem(item, "WARN", "Download für Shutdown geparkt", {
reason
});
@@ -9507,9 +9634,20 @@ export class DownloadManager extends EventEmitter {
genericErrorRetries: Number(active.genericErrorRetries || 0),
unrestrictRetries: Number(active.unrestrictRetries || 0)
});
} else if (reason === "reset") {
this.retryStateByItem.delete(item.id);
} else if (reason === "package_toggle") {
} else if (reason === "reset") {
this.retryStateByItem.delete(item.id);
} else if (reason === "settings_refresh") {
item.status = "queued";
item.speedBps = 0;
item.fullStatus = "Wartet";
item.lastError = "";
item.provider = null;
item.providerLabel = undefined;
item.providerAccountId = undefined;
item.providerAccountLabel = undefined;
this.retryAfterByItem.delete(item.id);
this.retryStateByItem.delete(item.id);
} else if (reason === "package_toggle") {
this.logPackageForItem(item, "WARN", "Download wegen Paket-Toggle pausiert", {
reason
});
@@ -9640,7 +9778,7 @@ export class DownloadManager extends EventEmitter {
this.escalateHttp416OrFail(item, active, claimedTargetPath, exhaustedReason);
return;
}
if (isResumeHardResetReason(exhaustedReason) && !active.resumeHardResetUsed) {
if (isResumeHardResetReason(exhaustedReason, active.genericErrorRetries) && !active.resumeHardResetUsed) {
active.resumeHardResetUsed = true;
item.retries += 1;
logger.warn(`Resume-Neustart: item=${item.fileName || item.id}, error=${exhaustedReason}, provider=${item.provider || "?"}`);
+27 -16
View File
@@ -21,6 +21,7 @@ import { createRendererSettings } from "./renderer-state";
import { validateRendererSettingsUpdate } from "./renderer-settings";
import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security";
import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
import { createSupportBundleExportRunner, writeSupportBundleAtomically } from "./support-bundle";
function validateString(value: unknown, name: string): string {
if (typeof value !== "string") {
@@ -604,13 +605,20 @@ function registerIpcHandlers(): void {
}
return controller.importQueue(json);
});
handleTrusted(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {
handleTrusted(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {
const settings = controller.getSettings();
const next = !settings.clipboardWatch;
controller.updateSettings({ clipboardWatch: next });
updateClipboardWatcher();
return next;
});
return next;
});
handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, text: unknown) => {
if (typeof text !== "string" || text.length > 16 * 1024 * 1024) {
throw new Error("Ungültiger Zwischenablageinhalt");
}
clipboard.writeText(text);
return true;
});
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
const options = {
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
@@ -667,19 +675,22 @@ function registerIpcHandlers(): void {
return controller.importOnlineBackup(key);
});
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
const options = {
defaultPath: controller.getSupportBundleDefaultFileName(),
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
};
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) {
return { saved: false };
}
const exported = await controller.exportSupportBundle();
await fs.promises.writeFile(result.filePath, exported.buffer);
return { saved: true, filePath: result.filePath };
});
const runSupportBundleExport = createSupportBundleExportRunner({
chooseFile: async () => {
const options = {
defaultPath: controller.getSupportBundleDefaultFileName(),
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
};
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
return result.canceled || !result.filePath ? null : result.filePath;
},
build: async () => (await controller.exportSupportBundle()).buffer,
write: writeSupportBundleAtomically,
onSuccess: ({ filePath, bytes }) => controller.recordSupportBundleExported(filePath, bytes),
onFailure: (error) => controller.recordSupportBundleExportFailed(error)
});
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, () => runSupportBundleExport());
handleTrusted(IPC_CHANNELS.OPEN_LOG, async () => {
const logPath = getLogFilePath();
+38 -15
View File
@@ -229,17 +229,23 @@ export class MegaWebFallback {
private sessions = new Map<string, { cookie: string; setAt: number }>();
private sessionGeneration = 0;
private invalidationController = new AbortController();
public constructor(getCredentials: () => MegaCredentials) {
this.getCredentials = getCredentials;
}
public async unrestrict(
public async unrestrict(
link: string,
signal?: AbortSignal,
account?: { login: string; password: string }
): Promise<UnrestrictedLink | null> {
const overallSignal = withTimeoutSignal(signal, 180000);
): Promise<UnrestrictedLink | null> {
const invalidationSignal = this.invalidationController.signal;
const requestSignal = signal
? AbortSignal.any([signal, invalidationSignal])
: invalidationSignal;
const overallSignal = withTimeoutSignal(requestSignal, 180000);
const creds = (account && account.login.trim() && account.password.trim())
? account
: this.getCredentials();
@@ -251,14 +257,20 @@ export class MegaWebFallback {
return this.runExclusive(async () => {
throwIfAborted(overallSignal);
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
throwIfAborted(overallSignal);
let generated = await this.generate(link, cookie, overallSignal);
throwIfAborted(overallSignal);
if (!generated) {
this.sessions.delete(key);
if (sessionGeneration === this.sessionGeneration) {
this.sessions.delete(key);
}
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
generated = await this.generate(link, cookie, overallSignal);
if (!generated) {
return null;
throwIfAborted(overallSignal);
generated = await this.generate(link, cookie, overallSignal);
throwIfAborted(overallSignal);
if (!generated) {
return null;
}
}
return {
@@ -290,7 +302,10 @@ export class MegaWebFallback {
public invalidateSession(): void {
this.sessionGeneration += 1;
this.invalidationController.abort("session_invalidated");
this.invalidationController = new AbortController();
this.sessions.clear();
this.queues.clear();
}
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
@@ -314,11 +329,17 @@ export class MegaWebFallback {
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) });
throw jobError;
}
};
const prev = this.queues.get(key) ?? Promise.resolve();
const run = prev.then(guardedJob, guardedJob);
this.queues.set(key, run.then(() => undefined, () => undefined));
return raceWithAbort(run, signal, () =>
};
const prev = this.queues.get(key) ?? Promise.resolve();
const run = prev.then(guardedJob, guardedJob);
const tail = run.then(() => undefined, () => undefined);
this.queues.set(key, tail);
void tail.finally(() => {
if (this.queues.get(key) === tail) {
this.queues.delete(key);
}
});
return raceWithAbort(run, signal, () =>
workStarted
? abortError()
: new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`)
@@ -460,9 +481,11 @@ export class MegaWebFallback {
return null;
}
public dispose(): void {
this.sessions.clear();
}
public dispose(): void {
this.invalidationController.abort("dispose");
this.sessions.clear();
this.queues.clear();
}
}
export function compactMegaWebError(error: unknown): string {
+594 -167
View File
@@ -1,87 +1,389 @@
import { promises as fsp } from "node:fs";
import path from "node:path";
import { promises as fsp } from "node:fs";
import { randomUUID } from "node:crypto";
import path from "node:path";
import AdmZip from "adm-zip";
import { APP_VERSION } from "./constants";
import { getAccountRotationLogPath } from "./account-rotation-log";
import { getConversionLogPath } from "./conversion-trace";
import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getPackageLogPath } from "./package-log";
import { getRenameLogPath } from "./rename-log";
import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-rename-log";
import { getSessionLogPath } from "./session-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { createStoragePaths, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager";
import type { DownloadManager } from "./download-manager";
import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types";
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
const MAX_TEXT_FILE_BYTES = 128 * 1024;
const MAX_RUNTIME_FILE_BYTES = 64 * 1024;
const MAX_TOTAL_TEXT_BYTES = 4 * 1024 * 1024;
const MAX_DIRECTORY_SCAN_FILES = 2_048;
const MAX_SESSION_LOG_FILES = 4;
const MAX_PACKAGE_LOG_FILES = 8;
const MAX_ITEM_LOG_FILES = 16;
const MAX_PACKAGE_DTOS = 200;
const MAX_ITEM_DTOS = 500;
const MAX_HISTORY_FILE_BYTES = 1024 * 1024;
const MAX_HISTORY_ENTRIES = 100;
interface TextBudget {
remainingBytes: number;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function collectSensitiveValues(value: unknown, key = "", output = new Set<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 {
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;
} catch {
return null;
}
}
async function safeReadJson(filePath: string): Promise<unknown> {
try {
return JSON.parse(await fsp.readFile(filePath, "utf8")) as unknown;
} catch {
return null;
}
}
function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
}
async function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): Promise<void> {
if (!sourcePath) {
return;
}
try {
const buffer = await fsp.readFile(sourcePath);
zip.addFile(zipPath, buffer);
} catch {
}
}
async function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): Promise<void> {
let entries;
try {
entries = await fsp.readdir(dirPath, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const zipPath = path.posix.join(zipRoot, entry.name);
if (entry.isDirectory()) {
await addDirectoryIfExists(zip, fullPath, zipPath);
continue;
}
await addFileIfExists(zip, fullPath, zipPath);
}
}
async function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): Promise<number> {
let entries;
try {
entries = await fsp.readdir(dirPath, { withFileTypes: true });
} catch {
return 0;
}
const cutoff = Date.now() - maxAgeMs;
let added = 0;
for (const entry of entries) {
if (!entry.isFile()) continue;
const fullPath = path.join(dirPath, entry.name);
try {
if ((await fsp.stat(fullPath)).mtimeMs >= cutoff) {
await addFileIfExists(zip, fullPath, path.posix.join(zipRoot, entry.name));
added += 1;
}
} catch { }
}
return added;
}
function getSourcePathKey(sourcePath: string): string {
const resolved = path.resolve(sourcePath);
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}
async function readTextTail(filePath: string, maxBytes: number): Promise<string> {
const stats = await fsp.stat(filePath);
const bytesToRead = Math.min(stats.size, Math.max(0, maxBytes));
if (bytesToRead <= 0) {
return "";
}
const handle = await fsp.open(filePath, "r");
try {
const buffer = Buffer.alloc(bytesToRead);
const { bytesRead } = await handle.read(buffer, 0, bytesToRead, Math.max(0, stats.size - bytesToRead));
const text = buffer.subarray(0, bytesRead).toString("utf8");
return stats.size > bytesRead ? `[gekürzt: letzte ${bytesRead} Bytes]\n${text}` : text;
} finally {
await handle.close();
}
}
async function addTextFileIfExists(
zip: AdmZip,
sourcePath: string | null,
zipPath: string,
includedSourcePaths: Set<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 {
if (maxAgeMs !== undefined && (await fsp.stat(sourcePath)).mtimeMs < Date.now() - maxAgeMs) {
return false;
}
const allowedBytes = Math.min(maxFileBytes, budget.remainingBytes);
const text = redactSupportText(await readTextTail(sourcePath, allowedBytes), sensitiveValues);
let buffer = Buffer.from(text, "utf8");
if (buffer.length > allowedBytes) {
buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8");
}
await yieldToEventLoop();
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues), buffer);
includedSourcePaths.add(sourcePathKey);
budget.remainingBytes = Math.max(0, budget.remainingBytes - buffer.length);
return true;
} catch {
return false;
}
}
async function addRecentDirectoryFiles(
zip: AdmZip,
dirPath: string,
zipRoot: string,
maxAgeMs: number,
maxFiles: number,
includedSourcePaths: Set<string>,
sensitiveValues: ReadonlySet<string>,
budget: TextBudget
): Promise<number> {
const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = [];
let directory;
try {
directory = await fsp.opendir(dirPath);
} catch {
return 0;
}
const cutoff = Date.now() - maxAgeMs;
let scanned = 0;
for await (const entry of directory) {
if (scanned >= MAX_DIRECTORY_SCAN_FILES) {
break;
}
scanned += 1;
if (!entry.isFile()) {
continue;
}
const fullPath = path.join(dirPath, entry.name);
try {
const stats = await fsp.stat(fullPath);
if (stats.mtimeMs >= cutoff) {
candidates.push({ name: entry.name, fullPath, mtimeMs: stats.mtimeMs });
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
if (candidates.length > maxFiles) {
candidates.length = maxFiles;
}
}
} catch {
}
}
let added = 0;
for (const candidate of candidates) {
if (budget.remainingBytes <= 0) {
break;
}
if (await addTextFileIfExists(
zip,
candidate.fullPath,
path.posix.join(zipRoot, candidate.name),
includedSourcePaths,
sensitiveValues,
budget,
MAX_TEXT_FILE_BYTES
)) {
added += 1;
}
}
return added;
}
function isActiveStatus(status: unknown): boolean {
return !new Set(["completed", "failed", "cancelled", "extracted", "deleted"]).has(String(status || ""));
}
function createPackageDto(entry: PackageEntry): Record<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 {
const y = date.getFullYear();
@@ -93,15 +395,98 @@ function formatTimestampForFileName(date: Date): string {
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
}
export function getSupportBundleDefaultFileName(): string {
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
}
export function getSupportBundleDefaultFileName(): string {
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
}
export interface SupportBundleExportResult {
saved: boolean;
busy: boolean;
filePath?: string;
message?: string;
}
interface SupportBundleExportSuccess {
filePath: string;
bytes: number;
}
interface SupportBundleExportRunnerOptions {
chooseFile: () => Promise<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 {
hostDiagnosticsMode?: HostDiagnosticsMode;
}
interface BuildSupportBundleOptions {
hostDiagnosticsMode?: HostDiagnosticsMode;
debugSetupMode?: "full" | "deferred";
}
function createDeferredHostDiagnostics(reason: string): unknown {
return {
@@ -135,90 +520,132 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
return getWindowsHostDiagnostics();
}
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
const zip = new AdmZip();
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths);
const history = loadHistory(storagePaths);
const snapshot = manager.getSnapshot();
const packageIds = Object.keys(snapshot.session.packages);
const itemIds = Object.keys(snapshot.session.items);
const debugSetup = getDebugSetupCheck(baseDir);
addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION,
generatedAt: new Date().toISOString(),
runtimeBaseDir: baseDir,
packageCount: packageIds.length,
itemCount: itemIds.length
});
addJson(zip, "overview/status.json", snapshot.session);
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
addJson(zip, "overview/stats.json", {
...buildStatsPayload(snapshot),
allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
}
});
addJson(zip, "overview/debug-setup.json", debugSetup);
addJson(zip, "overview/self-check.json", debugSetup);
addJson(zip, "overview/history.json", {
total: history.length,
entries: history.map((entry) => summarizeHistoryEntry(entry))
});
addJson(zip, "overview/packages.json", {
count: packageIds.length,
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean)
});
addJson(zip, "overview/items.json", {
count: itemIds.length,
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
});
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
addJson(zip, "overview/trace-config.json", getTraceConfig());
const recentErrors = getRecentErrors();
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
await addFileIfExists(zip, path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
await addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
await addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
await addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
await addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
await addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
await addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
await addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
await addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
await addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
await addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
await addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
await addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
await addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
await addFileIfExists(zip, getAccountRotationLogPath(), "logs/account-rotation.log");
await addFileIfExists(zip, getAccountRotationLogPath() ? `${getAccountRotationLogPath()}.old` : null, "logs/account-rotation.log.old");
await addFileIfExists(zip, getConversionLogPath(), "logs/conversion.log");
await addFileIfExists(zip, getConversionLogPath() ? `${getConversionLogPath()}.old` : null, "logs/conversion.log.old");
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
await addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
for (const packageId of packageIds) {
await addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
}
for (const itemId of itemIds) {
await addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
}
const supportManifest = await safeReadJson(path.join(baseDir, SUPPORT_MANIFEST_FILE));
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
const zip = new AdmZip();
const includedSourcePaths = new Set<string>();
const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES };
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths);
const sensitiveValues = collectSensitiveValues(settings);
const snapshot = manager.getSnapshot();
const packageEntries = Object.values(snapshot.session.packages);
const itemEntries = Object.values(snapshot.session.items);
const selectedPackages = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS).map(createPackageDto);
const selectedItems = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS).map(createItemDto);
const history = await loadBoundedHistory(storagePaths.historyFile);
const debugSetup = options.debugSetupMode === "deferred"
? { status: "deferred", generatedAt: new Date().toISOString(), reason: "Tiefer Setup-Scan wurde beim interaktiven Export ausgelassen." }
: getDebugSetupCheck(baseDir);
await addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION,
generatedAt: new Date().toISOString(),
runtimeBaseDir: "<local-path>",
packageCount: packageEntries.length,
itemCount: itemEntries.length,
limits: {
packageDtos: MAX_PACKAGE_DTOS,
itemDtos: MAX_ITEM_DTOS,
textBytes: MAX_TOTAL_TEXT_BYTES,
textFileBytes: MAX_TEXT_FILE_BYTES,
logWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000
}
}, sensitiveValues);
await addJson(zip, "overview/status.json", createSessionDto(snapshot.session), sensitiveValues);
await addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings), sensitiveValues);
await addJson(zip, "overview/accounts.json", buildAccountSummary(settings), sensitiveValues);
await addJson(zip, "overview/stats.json", {
...buildStatsPayload(snapshot),
allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
}
}, sensitiveValues);
await addJson(zip, "overview/debug-setup.json", debugSetup, sensitiveValues);
await addJson(zip, "overview/self-check.json", debugSetup, sensitiveValues);
await addJson(zip, "overview/history.json", history, sensitiveValues);
await addJson(zip, "overview/packages.json", {
count: packageEntries.length,
included: selectedPackages.length,
omitted: Math.max(0, packageEntries.length - selectedPackages.length),
packages: selectedPackages
}, sensitiveValues);
await addJson(zip, "overview/items.json", {
count: itemEntries.length,
included: selectedItems.length,
omitted: Math.max(0, itemEntries.length - selectedItems.length),
items: selectedItems
}, sensitiveValues);
await addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode), sensitiveValues);
await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues);
const recentErrors = getRecentErrors().slice(-100);
await addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors }, sensitiveValues);
const addRuntimeFile = (sourcePath: string | null, zipPath: string): Promise<boolean> => addTextFileIfExists(
zip,
sourcePath,
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 addRuntimeFile(path.join(baseDir, SUPPORT_MANIFEST_FILE), `runtime/${SUPPORT_MANIFEST_FILE}`);
await addRuntimeFile(path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
await addRuntimeFile(path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
await addRuntimeFile(getTraceConfigPath(), "runtime/trace_config.json");
const mainLogPath = getLogFilePath();
const auditLogPath = getAuditLogPath();
const renameLogPath = getRenameLogPath();
const traceLogPath = getTraceLogPath();
const accountRotationLogPath = getAccountRotationLogPath();
const conversionLogPath = getConversionLogPath();
await addCurrentLog(mainLogPath, "logs/rd_downloader.log");
await addRotatedLog(`${mainLogPath}.old`, "logs/rd_downloader.log.old");
await addCurrentLog(auditLogPath, "logs/audit.log");
await addRotatedLog(auditLogPath ? `${auditLogPath}.old` : null, "logs/audit.log.old");
await addCurrentLog(renameLogPath, "logs/rename.log");
await addRotatedLog(renameLogPath ? `${renameLogPath}.old` : null, "logs/rename.log.old");
await addCurrentLog(getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
await addCurrentLog(getSessionLogPath(), "logs/session.log");
await addCurrentLog(traceLogPath, "logs/trace.log");
await addRotatedLog(traceLogPath ? `${traceLogPath}.old` : null, "logs/trace.log.old");
await addCurrentLog(accountRotationLogPath, "logs/account-rotation.log");
await addRotatedLog(accountRotationLogPath ? `${accountRotationLogPath}.old` : null, "logs/account-rotation.log.old");
await addCurrentLog(conversionLogPath, "logs/conversion.log");
await addRotatedLog(conversionLogPath ? `${conversionLogPath}.old` : null, "logs/conversion.log.old");
await addRecentDirectoryFiles(zip, path.join(baseDir, "session-logs"), "logs/session-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_SESSION_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_PACKAGE_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_ITEM_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
const supportManifest = await safeReadBoundedJson(path.join(baseDir, SUPPORT_MANIFEST_FILE), MAX_RUNTIME_FILE_BYTES);
if (supportManifest) {
addJson(zip, "overview/support-manifest.json", supportManifest);
}
return zip.toBuffer();
}
await addJson(zip, "overview/support-manifest.json", supportManifest, sensitiveValues);
}
return await zip.toBufferPromise();
}
+3 -2
View File
@@ -64,7 +64,8 @@ const api: ElectronApi = {
exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds),
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
toggleClipboard: (): Promise<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),
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
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),
exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP),
importOnlineBackup: (key: string): Promise<{ restored: boolean; relaunch: false; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, key),
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string; busy?: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
openLogDirectory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG_DIRECTORY),
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
+256 -129
View File
@@ -45,6 +45,7 @@ import type { AccountModeFilter } from "./account-ui";
import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit";
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
import { buildAccountToggleSettingsUpdate, SerialTaskQueue, setAccountTargetEnabled, type AccountToggleTarget } from "./account-toggle-queue";
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
import { createUiLocalizer, normalizeLanguage } from "./i18n";
import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow";
@@ -869,11 +870,44 @@ const historyRetentionLabels: Record<RendererSettings["historyRetentionMode"], s
const AUTO_RENDER_PACKAGE_LIMIT = 260;
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 (activeTab !== "downloads") delay = Math.max(delay, 800);
return delay;
}
interface SupportBundleExportUiOptions {
exportBundle: () => Promise<{ saved: boolean; busy?: boolean }>;
setBusy: (busy: boolean) => void;
clearMessage: () => void;
showMessage: (message: string) => void;
}
export async function runSupportBundleExportUi(options: SupportBundleExportUiOptions): Promise<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 }[] = [
{ id: "rapidgator", label: "Rapidgator" },
@@ -1261,7 +1295,7 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const interval = setInterval(() => {
drawChart();
}, reducedMotion ? 1000 : 250);
}, reducedMotion ? 1000 : 500);
return () => clearInterval(interval);
}, [drawChart, running, paused]);
@@ -1363,7 +1397,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps,
draw();
};
const id = window.setInterval(tick, 250);
const id = window.setInterval(tick, 500);
return () => window.clearInterval(id);
}, []);
@@ -1513,6 +1547,8 @@ export function App(): ReactElement {
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null);
const [settingsDraft, setSettingsDraft] = useState<RendererSettingsDraft>(() => createSettingsDraft(emptySnapshot().settings));
const settingsDraftRef = useRef(settingsDraft);
settingsDraftRef.current = settingsDraft;
const [settingsThemeChoice, setSettingsThemeChoice] = useState<SettingsThemeChoice>(emptySnapshot().settings.theme);
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
@@ -1571,10 +1607,17 @@ export function App(): ReactElement {
const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<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 actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
const [supportBundleExporting, setSupportBundleExporting] = useState(false);
const supportBundleExportingRef = useRef(false);
const dragOverRef = useRef(false);
const dragDepthRef = useRef(0);
const [openMenu, setOpenMenu] = useState<string | null>(null);
@@ -1755,6 +1798,14 @@ export function App(): ReactElement {
}, timeoutMs);
}, []);
const clearToast = useCallback((): void => {
setStatusToast("");
if (toastTimerRef.current) {
clearTimeout(toastTimerRef.current);
toastTimerRef.current = null;
}
}, []);
const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
const availableIds = entries.map((entry) => entry.id);
const availableSet = new Set(availableIds);
@@ -1954,9 +2005,9 @@ export function App(): ReactElement {
if (next.settings.columnOrder?.length > 0) {
setColumnOrder(next.settings.columnOrder);
}
if (!settingsDirtyRef.current) {
if (!settingsDirtyRef.current && accountTogglePendingRef.current === 0) {
setSettingsDraft(createSettingsDraft(next.settings));
}
}
latestStateRef.current = null;
}
}, flushDelay);
@@ -2308,8 +2359,9 @@ export function App(): ReactElement {
for (const acc of accounts) {
const used = acc.dailyUsageBytes;
const limit = acc.dailyLimitBytes;
const rowKey = `mega-${entry.kind}-${acc.accountId}`;
rows.push({
rowKey: `mega-${entry.kind}-${acc.accountId}`,
rowKey,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
@@ -2317,7 +2369,9 @@ export function App(): ReactElement {
credentialLabel: "••••••",
accountId: acc.accountId,
checkable: true,
disabled: !acc.enabled,
disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
? !accountEnabledOverrides[rowKey]
: !acc.enabled,
dailyUsedBytes: used,
dailyLimitBytes: limit,
dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0,
@@ -2333,9 +2387,10 @@ export function App(): ReactElement {
});
}
} else if (entry.kind === "debridlink-api") {
for (const key of entry.debridLinkKeys) {
rows.push({
rowKey: `dl-${key.id}`,
for (const key of entry.debridLinkKeys) {
const rowKey = `dl-${key.id}`;
rows.push({
rowKey,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
@@ -2343,7 +2398,9 @@ export function App(): ReactElement {
credentialLabel: "API-Key",
accountId: key.id,
checkable: true,
disabled: key.disabled,
disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
? !accountEnabledOverrides[rowKey]
: key.disabled,
dailyUsedBytes: key.dailyUsedBytes,
dailyLimitBytes: key.dailyLimitBytes,
dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0,
@@ -2359,9 +2416,10 @@ export function App(): ReactElement {
}
});
}
} else {
rows.push({
rowKey: `svc-${entry.service}`,
} else {
const rowKey = `svc-${entry.service}`;
rows.push({
rowKey,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
@@ -2369,7 +2427,9 @@ export function App(): ReactElement {
credentialLabel: getAccountCredentialLabel(entry.kind),
accountId: null,
checkable: false,
disabled: entry.disabled,
disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
? !accountEnabledOverrides[rowKey]
: entry.disabled,
dailyUsedBytes: entry.dailyUsedBytes,
dailyLimitBytes: entry.dailyLimitBytes,
dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0,
@@ -2386,7 +2446,7 @@ export function App(): ReactElement {
}
}
return rows;
}, [configuredAccounts, snapshot.accounts]);
}, [accountEnabledOverrides, configuredAccounts, snapshot.accounts]);
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
@@ -2791,29 +2851,7 @@ export function App(): ReactElement {
});
};
const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise<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);
if (!meta) {
return;
@@ -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 confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true });
if (!confirmed) return;
@@ -2855,61 +2874,145 @@ export function App(): ReactElement {
}, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); });
};
const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => {
await performQuickAction(async () => {
const provider = entry.service as DebridProvider;
const current = settingsDraft.disabledProviders || [];
const nextDisabledProviders = current.includes(provider)
? current.filter((existing) => existing !== provider)
: [...current, provider];
const nextDraft: RendererSettingsDraft = {
...settingsDraft,
disabledProviders: nextDisabledProviders
};
await persistSpecificSettings(nextDraft);
showToast(
nextDisabledProviders.includes(provider)
? `${entry.serviceLabel} deaktiviert`
: `${entry.serviceLabel} aktiviert`,
2200
);
}, (error) => {
showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200);
const reconcileAccountToggleState = async (revision: number): Promise<void> => {
const fresh = await window.rd.getSnapshot();
if (!mountedRef.current || revision !== accountToggleRevisionRef.current) {
return;
}
masterSnapshotRef.current = fresh;
latestStateRef.current = null;
setSnapshot(fresh);
const reconciledDraft: RendererSettingsDraft = {
...settingsDraftRef.current,
...buildAccountToggleSettingsUpdate(fresh.settings)
};
settingsDraftRef.current = reconciledDraft;
setSettingsDraft(reconciledDraft);
accountEnabledOverridesRef.current = {};
setAccountEnabledOverrides({});
};
const enqueueAccountSettingsChange = (
nextDraft: RendererSettingsDraft,
nextOverrides: Record<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 => {
setAccountContextMenu(null);
if (row.toggleKind === "mega" && row.accountId) {
void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.accountId, row.disabled);
} else if (row.toggleKind === "dl" && row.dlKey) {
void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey);
} else {
void onToggleAccountEnabled(row.entry);
const target = getAccountToggleTarget(row);
if (!target) {
return;
}
const currentEnabled = Object.prototype.hasOwnProperty.call(accountEnabledOverridesRef.current, row.rowKey)
? accountEnabledOverridesRef.current[row.rowKey]
: !row.disabled;
const nextEnabled = !currentEnabled;
const nextDraft = setAccountTargetEnabled(settingsDraftRef.current, target, nextEnabled);
const nextOverrides = {
...accountEnabledOverridesRef.current,
[row.rowKey]: nextEnabled
};
enqueueAccountSettingsChange(
nextDraft,
nextOverrides,
nextEnabled ? "Account aktiviert" : "Account deaktiviert",
"Account konnte nicht umgeschaltet werden"
);
};
const onToggleDebridLinkApiKeyEnabled = (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): void => {
const row = accountRows.find((candidate) => candidate.entry.service === entry.service && candidate.accountId === key.id);
if (row) {
toggleAccountTableRow(row);
}
};
const setAllAccountsEnabled = async (enabled: boolean): Promise<void> => {
await performQuickAction(async () => {
const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))];
const nextEnabledState = buildBulkAccountEnabledState(
settingsDraft.disabledProviders || [],
configuredProviderIds,
accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string),
accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string),
enabled
);
const nextDraft: RendererSettingsDraft = {
...settingsDraft,
...nextEnabledState,
megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string),
megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string)
};
await persistSpecificSettings(nextDraft);
showToast(enabled ? "Accounts aktiviert" : "Accounts deaktiviert", 2200);
}, (error) => {
showToast(`Accounts konnten nicht umgeschaltet werden: ${String(error)}`, 3200);
});
const setAllAccountsEnabled = (enabled: boolean): void => {
const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))];
const nextEnabledState = buildBulkAccountEnabledState(
settingsDraftRef.current.disabledProviders || [],
configuredProviderIds,
accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string),
accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string),
enabled
);
const nextDraft: RendererSettingsDraft = {
...settingsDraftRef.current,
...nextEnabledState,
megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string),
megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string)
};
nextDraft.megaDebridDisabledAccountIds = [...new Set([
...nextDraft.megaDebridApiDisabledAccountIds,
...nextDraft.megaDebridWebDisabledAccountIds
])];
const nextOverrides = Object.fromEntries(accountRows.map((row) => [row.rowKey, enabled]));
enqueueAccountSettingsChange(
nextDraft,
nextOverrides,
enabled ? "Accounts aktiviert" : "Accounts deaktiviert",
"Accounts konnten nicht umgeschaltet werden"
);
};
const removeAccountTableRow = (row: AccountTableRow): void => {
@@ -4199,24 +4302,31 @@ export function App(): ReactElement {
const onCopyOnlineBackupKey = async (): Promise<void> => {
if (!onlineBackupDialog?.key) return;
try {
await navigator.clipboard.writeText(onlineBackupDialog.key);
await window.rd.writeClipboardText(onlineBackupDialog.key);
showToast("Online-Schlüssel kopiert", 2200);
} catch {
showToast("Schlüssel konnte nicht kopiert werden", 2600);
}
};
const onExportSupportBundle = async (): Promise<void> => {
closeMenus();
await performQuickAction(async () => {
const result = await window.rd.exportSupportBundle();
if (result.saved) {
showToast("Support-Bundle exportiert", 2600);
}
}, (error) => {
showToast(`Support-Bundle fehlgeschlagen: ${String(error)}`, 2800);
});
};
const onExportSupportBundle = async (): Promise<void> => {
closeMenus();
if (supportBundleExportingRef.current) {
showToast("Support-Bundle wird bereits erstellt …", 2600);
return;
}
supportBundleExportingRef.current = true;
try {
await runSupportBundleExportUi({
exportBundle: () => window.rd.exportSupportBundle(),
setBusy: setSupportBundleExporting,
clearMessage: clearToast,
showMessage: (message) => showToast(message, 2800)
});
} finally {
supportBundleExportingRef.current = false;
}
};
const onToggleSupportTrace = async (): Promise<void> => {
closeMenus();
@@ -4268,7 +4378,7 @@ export function App(): ReactElement {
detailsLabel: "Einträge anzeigen"
});
if (copy && entries.length > 0) {
await navigator.clipboard.writeText(details);
await window.rd.writeClipboardText(details);
showToast("Fehlerliste kopiert", 2600);
}
} catch (error) {
@@ -4371,7 +4481,7 @@ export function App(): ReactElement {
return;
}
try {
await navigator.clipboard.writeText(remoteDiag.code);
await window.rd.writeClipboardText(remoteDiag.code);
showToast("Verbindungscode kopiert", 2200);
} catch {
showToast("Kopieren fehlgeschlagen", 2200);
@@ -4624,9 +4734,26 @@ export function App(): ReactElement {
},
onPauseDownloads: () => {
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
void window.rd.togglePause().catch(() => {});
void window.rd.togglePause().then((paused) => {
setSnapshot((current) => ({ ...current, session: { ...current.session, paused } }));
}).catch(async (error) => {
try {
setSnapshot(await window.rd.getSnapshot());
} catch {
}
showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200);
});
},
onStopDownloads: () => {
setSnapshot((current) => ({ ...current, session: { ...current.session, running: false, paused: false } }));
void window.rd.stop().catch(async (error) => {
try {
setSnapshot(await window.rd.getSnapshot());
} catch {
}
showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200);
});
},
onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); },
onToggleSchedule: () => {
setSchedulePickerOpen((current) => !current);
setScheduleTimeInput("");
@@ -5492,7 +5619,7 @@ export function App(): ReactElement {
className={`menu-submenu-dropdown${openSubmenu === "hilfe-remote" ? " is-open" : ""}`}
>
<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>
</div>
</div>
@@ -5843,7 +5970,7 @@ export function App(): ReactElement {
accountEdit={accountEditDialogView}
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}
accountContextMenu={accountContextMenu && activeAccountContextRow ? (
<ContextMenu
@@ -6153,7 +6280,7 @@ export function App(): ReactElement {
type="button"
title={`${key.masked}\nMaskierte Kennung kopieren`}
onClick={() => {
void navigator.clipboard.writeText(key.masked)
void window.rd.writeClipboardText(key.masked)
.then(() => showToast("Maskierte Kennung kopiert", 1800))
.catch(() => showToast("Kopieren fehlgeschlagen", 2200));
}}
@@ -6211,8 +6338,8 @@ export function App(): ReactElement {
<div className="link-popup-list">
{linkPopup.links.map((link, i) => (
<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 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.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 window.rd.writeClipboardText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button>
</div>
))}
</div>
@@ -6220,13 +6347,13 @@ export function App(): ReactElement {
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
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>
)}
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
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>
)}
<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"],
["Bitte eine Passphrase eingeben", "Enter a passphrase"], ["Die Passphrasen stimmen nicht überein", "The passphrases do not match"], ["Sicherung exportieren", "Export backup"], ["Sicherung importieren", "Import backup"],
["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."],
["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"],
["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"], ["Support-Bundle wird erstellt …", "Support bundle is being created …"], ["Support-Bundle wird bereits erstellt …", "Support bundle is already being created …"],
["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"],
["Remote-fähig konfiguriert", "Configured for remote access"], ["Debug-Setup prüfen", "Check debug setup"], ["Letzte Fehler", "Recent errors"], ["Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet.", "No errors or warnings recorded since the app started."],
["In Zwischenablage kopieren", "Copy to clipboard"], ["Einträge anzeigen", "Show entries"], ["Fehlerliste kopiert", "Error list copied"], ["Debug-Token rotieren", "Rotate debug token"],
@@ -119,7 +119,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
<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.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
? <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}</>}
+3 -2
View File
@@ -33,8 +33,9 @@ export const IPC_CHANNELS = {
PICK_FOLDER: "dialog:pick-folder",
PICK_CONTAINERS: "dialog:pick-containers",
STATE_UPDATE: "state:update",
CLIPBOARD_DETECTED: "clipboard:detected",
TOGGLE_CLIPBOARD: "clipboard:toggle",
CLIPBOARD_DETECTED: "clipboard:detected",
TOGGLE_CLIPBOARD: "clipboard:toggle",
WRITE_CLIPBOARD_TEXT: "clipboard:write-text",
GET_SESSION_STATS: "stats:get-session-stats",
RESET_SESSION_STATS: "stats:reset-session",
RESET_DOWNLOAD_STATS: "stats:reset-download",
+3 -2
View File
@@ -61,7 +61,8 @@ export interface ElectronApi {
exportItemSelection: (itemIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
exportQueue: () => Promise<{ saved: boolean }>;
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
toggleClipboard: () => Promise<boolean>;
toggleClipboard: () => Promise<boolean>;
writeClipboardText: (text: string) => Promise<boolean>;
pickFolder: () => Promise<string | null>;
pickContainers: () => Promise<string[]>;
getSessionStats: () => Promise<SessionStats>;
@@ -75,7 +76,7 @@ export interface ElectronApi {
cancelBackupImport: () => Promise<void>;
exportOnlineBackup: () => Promise<{ key: string }>;
importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string; busy?: boolean }>;
openLog: () => Promise<void>;
openLogDirectory: () => Promise<void>;
openAuditLog: () => Promise<void>;