fix: harden account rotation and active download recovery

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

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

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

Add end-to-end regressions for live account switching, persisted partial recovery, support export, native copy, availability preservation, and the 500 ms telemetry cadence.
This commit is contained in:
Sucukdeluxe
2026-08-13 05:14:44 +02:00
parent ca015553f3
commit e1b4708952
26 changed files with 2811 additions and 561 deletions
+256 -129
View File
@@ -45,6 +45,7 @@ import type { AccountModeFilter } from "./account-ui";
import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit";
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
import { buildAccountToggleSettingsUpdate, SerialTaskQueue, setAccountTargetEnabled, type AccountToggleTarget } from "./account-toggle-queue";
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
import { createUiLocalizer, normalizeLanguage } from "./i18n";
import { runLocalBackupExport, runLocalBackupImport, type BackupPassphraseMode } from "./backup-flow";
@@ -869,11 +870,44 @@ const historyRetentionLabels: Record<RendererSettings["historyRetentionMode"], s
const AUTO_RENDER_PACKAGE_LIMIT = 260;
export function getSnapshotRenderDelay(itemCount: number, running: boolean, activeTab: MainView): number {
let delay = itemCount >= 700 ? 0 : itemCount >= 250 ? 50 : 100;
let delay = running ? 500 : itemCount >= 700 ? 100 : itemCount >= 250 ? 150 : 200;
if (!running) delay = Math.min(delay, 200);
if (activeTab !== "downloads") delay = Math.max(delay, 800);
return delay;
}
interface SupportBundleExportUiOptions {
exportBundle: () => Promise<{ saved: boolean; busy?: boolean }>;
setBusy: (busy: boolean) => void;
clearMessage: () => void;
showMessage: (message: string) => void;
}
export async function runSupportBundleExportUi(options: SupportBundleExportUiOptions): Promise<void> {
options.clearMessage();
options.setBusy(true);
try {
const result = await options.exportBundle();
if (result.saved) {
options.showMessage("Support-Bundle exportiert");
} else if (result.busy) {
options.showMessage("Support-Bundle wird bereits erstellt …");
}
} catch (error) {
options.showMessage(`Support-Bundle fehlgeschlagen: ${String(error)}`);
} finally {
options.setBusy(false);
}
}
interface SupportBundleToastProps {
busy: boolean;
message: string;
}
export function SupportBundleToast({ busy, message }: SupportBundleToastProps): ReactElement | null {
return <Toast message={busy ? "Support-Bundle wird erstellt …" : message} />;
}
const KNOWN_HOSTERS: { id: string; label: string }[] = [
{ id: "rapidgator", label: "Rapidgator" },
@@ -1261,7 +1295,7 @@ const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHist
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const interval = setInterval(() => {
drawChart();
}, reducedMotion ? 1000 : 250);
}, reducedMotion ? 1000 : 500);
return () => clearInterval(interval);
}, [drawChart, running, paused]);
@@ -1363,7 +1397,7 @@ const DownloadSpeedSparkline = memo(function DownloadSpeedSparkline({ speedBps,
draw();
};
const id = window.setInterval(tick, 250);
const id = window.setInterval(tick, 500);
return () => window.clearInterval(id);
}, []);
@@ -1513,6 +1547,8 @@ export function App(): ReactElement {
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
const [updateInstallProgress, setUpdateInstallProgress] = useState<UpdateInstallProgress | null>(null);
const [settingsDraft, setSettingsDraft] = useState<RendererSettingsDraft>(() => createSettingsDraft(emptySnapshot().settings));
const settingsDraftRef = useRef(settingsDraft);
settingsDraftRef.current = settingsDraft;
const [settingsThemeChoice, setSettingsThemeChoice] = useState<SettingsThemeChoice>(emptySnapshot().settings.theme);
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
@@ -1571,10 +1607,17 @@ export function App(): ReactElement {
const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
const [accountEnabledOverrides, setAccountEnabledOverrides] = useState<Record<string, boolean>>({});
const accountEnabledOverridesRef = useRef<Record<string, boolean>>({});
const accountToggleQueueRef = useRef(new SerialTaskQueue());
const accountToggleRevisionRef = useRef(0);
const accountTogglePendingRef = useRef(0);
const actionBusyRef = useRef(false);
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
const [supportBundleExporting, setSupportBundleExporting] = useState(false);
const supportBundleExportingRef = useRef(false);
const dragOverRef = useRef(false);
const dragDepthRef = useRef(0);
const [openMenu, setOpenMenu] = useState<string | null>(null);
@@ -1755,6 +1798,14 @@ export function App(): ReactElement {
}, timeoutMs);
}, []);
const clearToast = useCallback((): void => {
setStatusToast("");
if (toastTimerRef.current) {
clearTimeout(toastTimerRef.current);
toastTimerRef.current = null;
}
}, []);
const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => {
const availableIds = entries.map((entry) => entry.id);
const availableSet = new Set(availableIds);
@@ -1954,9 +2005,9 @@ export function App(): ReactElement {
if (next.settings.columnOrder?.length > 0) {
setColumnOrder(next.settings.columnOrder);
}
if (!settingsDirtyRef.current) {
if (!settingsDirtyRef.current && accountTogglePendingRef.current === 0) {
setSettingsDraft(createSettingsDraft(next.settings));
}
}
latestStateRef.current = null;
}
}, flushDelay);
@@ -2308,8 +2359,9 @@ export function App(): ReactElement {
for (const acc of accounts) {
const used = acc.dailyUsageBytes;
const limit = acc.dailyLimitBytes;
const rowKey = `mega-${entry.kind}-${acc.accountId}`;
rows.push({
rowKey: `mega-${entry.kind}-${acc.accountId}`,
rowKey,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
@@ -2317,7 +2369,9 @@ export function App(): ReactElement {
credentialLabel: "••••••",
accountId: acc.accountId,
checkable: true,
disabled: !acc.enabled,
disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
? !accountEnabledOverrides[rowKey]
: !acc.enabled,
dailyUsedBytes: used,
dailyLimitBytes: limit,
dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0,
@@ -2333,9 +2387,10 @@ export function App(): ReactElement {
});
}
} else if (entry.kind === "debridlink-api") {
for (const key of entry.debridLinkKeys) {
rows.push({
rowKey: `dl-${key.id}`,
for (const key of entry.debridLinkKeys) {
const rowKey = `dl-${key.id}`;
rows.push({
rowKey,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
@@ -2343,7 +2398,9 @@ export function App(): ReactElement {
credentialLabel: "API-Key",
accountId: key.id,
checkable: true,
disabled: key.disabled,
disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
? !accountEnabledOverrides[rowKey]
: key.disabled,
dailyUsedBytes: key.dailyUsedBytes,
dailyLimitBytes: key.dailyLimitBytes,
dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0,
@@ -2359,9 +2416,10 @@ export function App(): ReactElement {
}
});
}
} else {
rows.push({
rowKey: `svc-${entry.service}`,
} else {
const rowKey = `svc-${entry.service}`;
rows.push({
rowKey,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
@@ -2369,7 +2427,9 @@ export function App(): ReactElement {
credentialLabel: getAccountCredentialLabel(entry.kind),
accountId: null,
checkable: false,
disabled: entry.disabled,
disabled: Object.prototype.hasOwnProperty.call(accountEnabledOverrides, rowKey)
? !accountEnabledOverrides[rowKey]
: entry.disabled,
dailyUsedBytes: entry.dailyUsedBytes,
dailyLimitBytes: entry.dailyLimitBytes,
dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0,
@@ -2386,7 +2446,7 @@ export function App(): ReactElement {
}
}
return rows;
}, [configuredAccounts, snapshot.accounts]);
}, [accountEnabledOverrides, configuredAccounts, snapshot.accounts]);
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
@@ -2791,29 +2851,7 @@ export function App(): ReactElement {
});
};
const onToggleDebridLinkApiKeyEnabled = async (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): Promise<void> => {
await performQuickAction(async () => {
const currentDisabledIds = settingsDraft.debridLinkDisabledKeyIds || [];
const nextDisabledIds = key.disabled
? currentDisabledIds.filter((existingId) => existingId !== key.id)
: [...currentDisabledIds, key.id];
const nextDraft: RendererSettingsDraft = {
...settingsDraft,
debridLinkDisabledKeyIds: nextDisabledIds
};
await persistSpecificSettings(nextDraft);
showToast(
key.disabled
? `${entry.serviceLabel} ${key.label} aktiviert`
: `${entry.serviceLabel} ${key.label} deaktiviert`,
2200
);
}, (error) => {
showToast(`${entry.serviceLabel} ${key.label}: Umschalten fehlgeschlagen: ${String(error)}`, 3200);
});
};
const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise<void> => {
const onAccountRowQuickAction = async (entry: ConfiguredAccountEntry): Promise<void> => {
const meta = getAccountQuickActionMeta(entry.kind);
if (!meta) {
return;
@@ -2825,25 +2863,6 @@ export function App(): ReactElement {
});
};
const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", accountId: string, currentlyDisabled: boolean): Promise<void> => {
await performQuickAction(async () => {
const mode = kind === "megadebrid-web" ? "web" : "api";
const current = mode === "api" ? settingsDraft.megaDebridApiDisabledAccountIds : settingsDraft.megaDebridWebDisabledAccountIds;
const next = currentlyDisabled ? current.filter((id) => id !== accountId) : [...current, accountId];
const apiDisabledIds = mode === "api" ? next : settingsDraft.megaDebridApiDisabledAccountIds;
const webDisabledIds = mode === "web" ? next : settingsDraft.megaDebridWebDisabledAccountIds;
await persistSpecificSettings({
...settingsDraft,
megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])],
megaDebridApiDisabledAccountIds: apiDisabledIds,
megaDebridWebDisabledAccountIds: webDisabledIds
});
showToast(currentlyDisabled ? "Account aktiviert" : "Account deaktiviert", 2000);
}, (error) => {
showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200);
});
};
const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise<void> => {
const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true });
if (!confirmed) return;
@@ -2855,61 +2874,145 @@ export function App(): ReactElement {
}, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); });
};
const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => {
await performQuickAction(async () => {
const provider = entry.service as DebridProvider;
const current = settingsDraft.disabledProviders || [];
const nextDisabledProviders = current.includes(provider)
? current.filter((existing) => existing !== provider)
: [...current, provider];
const nextDraft: RendererSettingsDraft = {
...settingsDraft,
disabledProviders: nextDisabledProviders
};
await persistSpecificSettings(nextDraft);
showToast(
nextDisabledProviders.includes(provider)
? `${entry.serviceLabel} deaktiviert`
: `${entry.serviceLabel} aktiviert`,
2200
);
}, (error) => {
showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200);
const reconcileAccountToggleState = async (revision: number): Promise<void> => {
const fresh = await window.rd.getSnapshot();
if (!mountedRef.current || revision !== accountToggleRevisionRef.current) {
return;
}
masterSnapshotRef.current = fresh;
latestStateRef.current = null;
setSnapshot(fresh);
const reconciledDraft: RendererSettingsDraft = {
...settingsDraftRef.current,
...buildAccountToggleSettingsUpdate(fresh.settings)
};
settingsDraftRef.current = reconciledDraft;
setSettingsDraft(reconciledDraft);
accountEnabledOverridesRef.current = {};
setAccountEnabledOverrides({});
};
const enqueueAccountSettingsChange = (
nextDraft: RendererSettingsDraft,
nextOverrides: Record<string, boolean>,
successMessage: string,
errorMessage: string
): void => {
settingsDraftRevisionRef.current += 1;
settingsDraftRef.current = nextDraft;
setSettingsDraft(nextDraft);
accountEnabledOverridesRef.current = nextOverrides;
setAccountEnabledOverrides(nextOverrides);
const revision = accountToggleRevisionRef.current + 1;
accountToggleRevisionRef.current = revision;
accountTogglePendingRef.current += 1;
void accountToggleQueueRef.current.enqueue(async () => {
let error: unknown = null;
try {
await window.rd.updateSettings(buildAccountToggleSettingsUpdate(nextDraft));
} catch (caught) {
error = caught;
} finally {
accountTogglePendingRef.current = Math.max(0, accountTogglePendingRef.current - 1);
}
if (revision !== accountToggleRevisionRef.current) {
return;
}
try {
await reconcileAccountToggleState(revision);
} catch (caught) {
error = error ?? caught;
if (mountedRef.current && revision === accountToggleRevisionRef.current) {
const fallbackDraft: RendererSettingsDraft = {
...settingsDraftRef.current,
...buildAccountToggleSettingsUpdate(snapshotRef.current.settings)
};
settingsDraftRef.current = fallbackDraft;
setSettingsDraft(fallbackDraft);
accountEnabledOverridesRef.current = {};
setAccountEnabledOverrides({});
}
}
if (error) {
showToast(`${errorMessage}: ${String(error)}`, 3200);
} else {
showToast(successMessage, 1800);
}
});
};
const getAccountToggleTarget = (row: AccountTableRow): AccountToggleTarget | null => {
if (row.toggleKind === "mega" && row.accountId) {
return {
kind: row.entry.kind === "megadebrid-web" ? "mega-web" : "mega-api",
accountId: row.accountId
};
}
if (row.toggleKind === "dl" && row.accountId) {
return { kind: "debridlink", accountId: row.accountId };
}
if (row.toggleKind === "single") {
return { kind: "provider", provider: row.entry.service as DebridProvider };
}
return null;
};
const toggleAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null);
if (row.toggleKind === "mega" && row.accountId) {
void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.accountId, row.disabled);
} else if (row.toggleKind === "dl" && row.dlKey) {
void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey);
} else {
void onToggleAccountEnabled(row.entry);
const target = getAccountToggleTarget(row);
if (!target) {
return;
}
const currentEnabled = Object.prototype.hasOwnProperty.call(accountEnabledOverridesRef.current, row.rowKey)
? accountEnabledOverridesRef.current[row.rowKey]
: !row.disabled;
const nextEnabled = !currentEnabled;
const nextDraft = setAccountTargetEnabled(settingsDraftRef.current, target, nextEnabled);
const nextOverrides = {
...accountEnabledOverridesRef.current,
[row.rowKey]: nextEnabled
};
enqueueAccountSettingsChange(
nextDraft,
nextOverrides,
nextEnabled ? "Account aktiviert" : "Account deaktiviert",
"Account konnte nicht umgeschaltet werden"
);
};
const onToggleDebridLinkApiKeyEnabled = (entry: ConfiguredAccountEntry, key: DebridLinkAccountKeyEntry): void => {
const row = accountRows.find((candidate) => candidate.entry.service === entry.service && candidate.accountId === key.id);
if (row) {
toggleAccountTableRow(row);
}
};
const setAllAccountsEnabled = async (enabled: boolean): Promise<void> => {
await performQuickAction(async () => {
const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))];
const nextEnabledState = buildBulkAccountEnabledState(
settingsDraft.disabledProviders || [],
configuredProviderIds,
accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string),
accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string),
enabled
);
const nextDraft: RendererSettingsDraft = {
...settingsDraft,
...nextEnabledState,
megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string),
megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string)
};
await persistSpecificSettings(nextDraft);
showToast(enabled ? "Accounts aktiviert" : "Accounts deaktiviert", 2200);
}, (error) => {
showToast(`Accounts konnten nicht umgeschaltet werden: ${String(error)}`, 3200);
});
const setAllAccountsEnabled = (enabled: boolean): void => {
const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))];
const nextEnabledState = buildBulkAccountEnabledState(
settingsDraftRef.current.disabledProviders || [],
configuredProviderIds,
accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string),
accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string),
enabled
);
const nextDraft: RendererSettingsDraft = {
...settingsDraftRef.current,
...nextEnabledState,
megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string),
megaDebridWebDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-web" && row.accountId).map((row) => row.accountId as string)
};
nextDraft.megaDebridDisabledAccountIds = [...new Set([
...nextDraft.megaDebridApiDisabledAccountIds,
...nextDraft.megaDebridWebDisabledAccountIds
])];
const nextOverrides = Object.fromEntries(accountRows.map((row) => [row.rowKey, enabled]));
enqueueAccountSettingsChange(
nextDraft,
nextOverrides,
enabled ? "Accounts aktiviert" : "Accounts deaktiviert",
"Accounts konnten nicht umgeschaltet werden"
);
};
const removeAccountTableRow = (row: AccountTableRow): void => {
@@ -4199,24 +4302,31 @@ export function App(): ReactElement {
const onCopyOnlineBackupKey = async (): Promise<void> => {
if (!onlineBackupDialog?.key) return;
try {
await navigator.clipboard.writeText(onlineBackupDialog.key);
await window.rd.writeClipboardText(onlineBackupDialog.key);
showToast("Online-Schlüssel kopiert", 2200);
} catch {
showToast("Schlüssel konnte nicht kopiert werden", 2600);
}
};
const onExportSupportBundle = async (): Promise<void> => {
closeMenus();
await performQuickAction(async () => {
const result = await window.rd.exportSupportBundle();
if (result.saved) {
showToast("Support-Bundle exportiert", 2600);
}
}, (error) => {
showToast(`Support-Bundle fehlgeschlagen: ${String(error)}`, 2800);
});
};
const onExportSupportBundle = async (): Promise<void> => {
closeMenus();
if (supportBundleExportingRef.current) {
showToast("Support-Bundle wird bereits erstellt …", 2600);
return;
}
supportBundleExportingRef.current = true;
try {
await runSupportBundleExportUi({
exportBundle: () => window.rd.exportSupportBundle(),
setBusy: setSupportBundleExporting,
clearMessage: clearToast,
showMessage: (message) => showToast(message, 2800)
});
} finally {
supportBundleExportingRef.current = false;
}
};
const onToggleSupportTrace = async (): Promise<void> => {
closeMenus();
@@ -4268,7 +4378,7 @@ export function App(): ReactElement {
detailsLabel: "Einträge anzeigen"
});
if (copy && entries.length > 0) {
await navigator.clipboard.writeText(details);
await window.rd.writeClipboardText(details);
showToast("Fehlerliste kopiert", 2600);
}
} catch (error) {
@@ -4371,7 +4481,7 @@ export function App(): ReactElement {
return;
}
try {
await navigator.clipboard.writeText(remoteDiag.code);
await window.rd.writeClipboardText(remoteDiag.code);
showToast("Verbindungscode kopiert", 2200);
} catch {
showToast("Kopieren fehlgeschlagen", 2200);
@@ -4624,9 +4734,26 @@ export function App(): ReactElement {
},
onPauseDownloads: () => {
setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } }));
void window.rd.togglePause().catch(() => {});
void window.rd.togglePause().then((paused) => {
setSnapshot((current) => ({ ...current, session: { ...current.session, paused } }));
}).catch(async (error) => {
try {
setSnapshot(await window.rd.getSnapshot());
} catch {
}
showToast(`Pause fehlgeschlagen: ${String(error)}`, 3200);
});
},
onStopDownloads: () => {
setSnapshot((current) => ({ ...current, session: { ...current.session, running: false, paused: false } }));
void window.rd.stop().catch(async (error) => {
try {
setSnapshot(await window.rd.getSnapshot());
} catch {
}
showToast(`Stop fehlgeschlagen: ${String(error)}`, 3200);
});
},
onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); },
onToggleSchedule: () => {
setSchedulePickerOpen((current) => !current);
setScheduleTimeInput("");
@@ -5492,7 +5619,7 @@ export function App(): ReactElement {
className={`menu-submenu-dropdown${openSubmenu === "hilfe-remote" ? " is-open" : ""}`}
>
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose </span></button>
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
<button className="menu-dropdown-item" disabled={supportBundleExporting} onClick={() => { void onExportSupportBundle(); }}><span>{supportBundleExporting ? "Support-Bundle wird erstellt …" : "Support-Bundle exportieren"}</span></button>
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
</div>
</div>
@@ -5843,7 +5970,7 @@ export function App(): ReactElement {
accountEdit={accountEditDialogView}
accountCreate={accountAddDialog}
toast={<Toast message={statusToast} />}
toast={<SupportBundleToast busy={supportBundleExporting} message={statusToast} />}
dropOverlay={dragOver ? <div className="drop-overlay md-drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div> : null}
accountContextMenu={accountContextMenu && activeAccountContextRow ? (
<ContextMenu
@@ -6153,7 +6280,7 @@ export function App(): ReactElement {
type="button"
title={`${key.masked}\nMaskierte Kennung kopieren`}
onClick={() => {
void navigator.clipboard.writeText(key.masked)
void window.rd.writeClipboardText(key.masked)
.then(() => showToast("Maskierte Kennung kopiert", 1800))
.catch(() => showToast("Kopieren fehlgeschlagen", 2200));
}}
@@ -6211,8 +6338,8 @@ export function App(): ReactElement {
<div className="link-popup-list">
{linkPopup.links.map((link, i) => (
<div key={i} className="link-popup-row">
<button aria-label={`${link.name} kopieren`} className="link-popup-name link-popup-click" type="button" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</button>
<button aria-label="Link kopieren" className="link-popup-url link-popup-click" type="button" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button>
<button aria-label={`${link.name} kopieren`} className="link-popup-name link-popup-click" type="button" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void window.rd.writeClipboardText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</button>
<button aria-label="Link kopieren" className="link-popup-url link-popup-click" type="button" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void window.rd.writeClipboardText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button>
</div>
))}
</div>
@@ -6220,13 +6347,13 @@ export function App(): ReactElement {
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.name).join("\n");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
void window.rd.writeClipboardText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Namen kopieren</button>
)}
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.url).join("\n");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
void window.rd.writeClipboardText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Links kopieren</button>
)}
<button className="btn" onClick={() => setLinkPopup(null)}>Schließen</button>
+72
View File
@@ -0,0 +1,72 @@
import type { DebridProvider, RendererSettingsUpdate } from "../shared/types";
export type AccountToggleTarget =
| { kind: "provider"; provider: DebridProvider }
| { kind: "debridlink"; accountId: string }
| { kind: "mega-api"; accountId: string }
| { kind: "mega-web"; accountId: string };
export interface AccountToggleSettings {
disabledProviders: DebridProvider[];
debridLinkDisabledKeyIds: string[];
megaDebridDisabledAccountIds: string[];
megaDebridApiDisabledAccountIds: string[];
megaDebridWebDisabledAccountIds: string[];
}
function setListEntry<T extends string>(values: T[], value: T, present: boolean): T[] {
return present
? [...new Set([...values, value])]
: values.filter((entry) => entry !== value);
}
export function setAccountTargetEnabled<T extends AccountToggleSettings>(
settings: T,
target: AccountToggleTarget,
enabled: boolean
): T {
if (target.kind === "provider") {
return {
...settings,
disabledProviders: setListEntry(settings.disabledProviders, target.provider, !enabled)
};
}
if (target.kind === "debridlink") {
return {
...settings,
debridLinkDisabledKeyIds: setListEntry(settings.debridLinkDisabledKeyIds, target.accountId, !enabled)
};
}
const apiDisabled = target.kind === "mega-api"
? setListEntry(settings.megaDebridApiDisabledAccountIds, target.accountId, !enabled)
: settings.megaDebridApiDisabledAccountIds;
const webDisabled = target.kind === "mega-web"
? setListEntry(settings.megaDebridWebDisabledAccountIds, target.accountId, !enabled)
: settings.megaDebridWebDisabledAccountIds;
return {
...settings,
megaDebridApiDisabledAccountIds: apiDisabled,
megaDebridWebDisabledAccountIds: webDisabled,
megaDebridDisabledAccountIds: [...new Set([...apiDisabled, ...webDisabled])]
};
}
export function buildAccountToggleSettingsUpdate(settings: AccountToggleSettings): RendererSettingsUpdate {
return {
disabledProviders: settings.disabledProviders,
debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds,
megaDebridDisabledAccountIds: settings.megaDebridDisabledAccountIds,
megaDebridApiDisabledAccountIds: settings.megaDebridApiDisabledAccountIds,
megaDebridWebDisabledAccountIds: settings.megaDebridWebDisabledAccountIds
};
}
export class SerialTaskQueue {
private tail: Promise<void> = Promise.resolve();
public enqueue<T>(task: () => Promise<T>): Promise<T> {
const result = this.tail.then(task);
this.tail = result.then(() => undefined, () => undefined);
return result;
}
}
+1 -1
View File
@@ -137,7 +137,7 @@ const pairs = [
["Diese Sicherung ist mit einer Passphrase geschützt.", "This backup is protected with a passphrase."], ["Passphrase", "Passphrase"], ["Passphrase bestätigen", "Confirm passphrase"],
["Bitte eine Passphrase eingeben", "Enter a passphrase"], ["Die Passphrasen stimmen nicht überein", "The passphrases do not match"], ["Sicherung exportieren", "Export backup"], ["Sicherung importieren", "Import backup"],
["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."],
["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"],
["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"], ["Support-Bundle wird erstellt …", "Support bundle is being created …"], ["Support-Bundle wird bereits erstellt …", "Support bundle is already being created …"],
["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"],
["Remote-fähig konfiguriert", "Configured for remote access"], ["Debug-Setup prüfen", "Check debug setup"], ["Letzte Fehler", "Recent errors"], ["Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet.", "No errors or warnings recorded since the app started."],
["In Zwischenablage kopieren", "Copy to clipboard"], ["Einträge anzeigen", "Show entries"], ["Fehlerliste kopiert", "Error list copied"], ["Debug-Token rotieren", "Rotate debug token"],
@@ -119,7 +119,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
<button disabled={!model.canStop} onClick={actions.onStopDownloads} type="button">Stop</button>
{model.scheduleActive
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}