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:
+32
-13
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user