feat: add encrypted online settings backup keys

Add immutable client-encrypted settings snapshots with independent MDD2 capability keys so fresh installs can restore configuration without transferring backup files. Keep credentials encrypted end to end, preserve queues and history during import, and avoid exposing identifiers in request URLs or errors. Include the persistent API with quota, rate limits, crash-safe storage locking, durability checks, and end-to-end race and recovery coverage.
This commit is contained in:
Sucukdeluxe
2026-08-07 18:29:06 +02:00
parent 5f19293ed2
commit 6e44e63167
22 changed files with 1832 additions and 44 deletions
+41 -16
View File
@@ -25,7 +25,7 @@ import {
} from "../shared/types";
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
import { importDlcContainers } from "./container";
import { APP_VERSION } from "./constants";
import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants";
import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check";
@@ -57,7 +57,8 @@ import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog
import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@@ -415,7 +416,7 @@ export class AppController {
// object about to be applied, so they are never rolled back to a stale snapshot.
// All-time totals take the max; daily/total usage and account statuses are taken
// live; per-key Debrid-Link usage is filtered to keys that still exist.
private overlayLiveUsageCounters(target: AppSettings): void {
private overlayLiveUsageCounters(target: AppSettings): void {
const liveSettings = this.manager.getSettings();
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
@@ -429,8 +430,19 @@ export class AppController {
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
);
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
}
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
}
private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void {
const restoredSettings = normalizeSettings(importedSettings);
this.overlayLiveUsageCounters(restoredSettings);
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { settingsOnlyImport: true });
if (restoreRemoteDiagnostics) {
this.restoreRemoteDiagnosticsFromBackup(remoteDiagnostics, true);
}
}
public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial);
@@ -747,7 +759,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
this.audit("INFO", "Download-Statistik zurückgesetzt");
}
public exportBackup(): Buffer {
public exportBackup(): Buffer {
let remoteDiagnostics: BackupRemoteDiagnostics | undefined;
if (Boolean(this.settings.backupIncludeRemoteDiagnostics)) {
const status = getDebugServerRuntimeStatus();
@@ -771,8 +783,25 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
});
return encryptBackup(JSON.stringify(payloadObj));
}
return encryptBackup(JSON.stringify(payloadObj));
}
public async exportOnlineBackup(): Promise<{ key: string }> {
const created = createOnlineBackup({ ...this.settings }, APP_VERSION);
await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL);
this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only" });
return { key: created.key };
}
public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> {
const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL);
this.applySettingsOnlyBackup(payload.settings);
this.audit("INFO", "Online-Sicherung importiert", {
kind: "settings-only",
accountSummary: buildAccountSummary(this.settings)
});
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");
@@ -824,20 +853,16 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
importedSettingsRecord[key] = currentSettingsRecord[key];
}
}
const restoredSettings = normalizeSettings(importedSettings);
const restoredSettings = normalizeSettings(importedSettings);
// Settings-only backup: keep the running queue AND the live counters untouched.
// Overlay the live usage/status counters so they don't roll back to the backup's
// (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
// policy still governs FUTURE completions through the normal path. Do NOT stop the
// manager, wipe the session, block persistence or relaunch.
if (!hasSession) {
this.overlayLiveUsageCounters(restoredSettings);
this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
this.restoreRemoteDiagnosticsFromBackup(parsed.remoteDiagnostics, true);
// manager, wipe the session, block persistence or relaunch.
if (!hasSession) {
this.applySettingsOnlyBackup(restoredSettings, parsed.remoteDiagnostics, true);
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings)
});
+1
View File
@@ -38,6 +38,7 @@ export const SPEED_WINDOW_SECONDS = 1;
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
export const DEFAULT_UPDATE_REPO = "Sucukdeluxe/multi-debrid-downloader";
export const ONLINE_BACKUP_API_URL = "https://backup.24-music.de";
export function defaultSettings(): AppSettings {
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
+9 -7
View File
@@ -2156,7 +2156,7 @@ export class DownloadManager extends EventEmitter {
this.emitState();
}
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean }): void {
public setSettings(next: AppSettings, opts?: { suppressRetroactiveCleanup?: boolean; settingsOnlyImport?: boolean }): void {
const previous = this.settings;
next.totalDownloadedAllTime = Math.max(next.totalDownloadedAllTime || 0, this.settings.totalDownloadedAllTime || 0);
next.totalCompletedFilesAllTime = Math.max(next.totalCompletedFilesAllTime || 0, this.settings.totalCompletedFilesAllTime || 0);
@@ -2174,7 +2174,7 @@ export class DownloadManager extends EventEmitter {
const nextOrder = JSON.stringify(next.providerOrder ?? []);
const prevRouting = JSON.stringify(previous.hosterRouting ?? {});
const nextRouting = JSON.stringify(next.hosterRouting ?? {});
if (prevOrder !== nextOrder || prevRouting !== nextRouting) {
if (!opts?.settingsOnlyImport && (prevOrder !== nextOrder || prevRouting !== nextRouting)) {
const activeItemIds = new Set([...this.activeTasks.values()].map((t) => t.itemId));
for (const item of Object.values(this.session.items)) {
if (!activeItemIds.has(item.id) && item.status !== "completed" && item.status !== "failed") {
@@ -2185,7 +2185,7 @@ export class DownloadManager extends EventEmitter {
const previousArchivePasswords = String(previous.archivePasswordList || "").replace(/\r\n|\r/g, "\n");
const nextArchivePasswords = String(next.archivePasswordList || "").replace(/\r\n|\r/g, "\n");
if (previousArchivePasswords !== nextArchivePasswords) {
if (!opts?.settingsOnlyImport && previousArchivePasswords !== nextArchivePasswords) {
this.hybridExtractedPaths.clear();
this.hybridFailedArchives.clear();
const pwCount = nextArchivePasswords.split("\n").filter(Boolean).length;
@@ -2218,10 +2218,12 @@ export class DownloadManager extends EventEmitter {
logger.info(`Settings-Update: ${clearedProviderFailures} Provider-Failure(s) gecleart wegen geaenderter Credentials`);
}
this.resolveExistingQueuedOpaqueFilenames();
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") {
this.applyRetroactiveCleanupPolicy();
if (!opts?.settingsOnlyImport) {
this.resolveExistingQueuedOpaqueFilenames();
void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (setSettings): ${compactErrorText(err)}`));
if (!opts?.suppressRetroactiveCleanup && next.completedCleanupPolicy !== "never") {
this.applyRetroactiveCleanupPolicy();
}
}
this.emitState();
}
+13 -3
View File
@@ -569,7 +569,7 @@ function registerIpcHandlers(): void {
app.quit();
});
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
const options = {
defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
@@ -580,8 +580,18 @@ function registerIpcHandlers(): void {
}
const encrypted = controller.exportBackup();
await fs.promises.writeFile(result.filePath, encrypted);
return { saved: true };
});
return { saved: true };
});
ipcMain.handle(IPC_CHANNELS.EXPORT_ONLINE_BACKUP, async () => controller.exportOnlineBackup());
ipcMain.handle(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, async (_event: IpcMainInvokeEvent, rawKey: unknown) => {
const key = validateString(rawKey, "key").trim();
if (key.length > 128) {
throw new Error("Online-Sicherungsschlüssel ist ungültig");
}
return controller.importOnlineBackup(key);
});
ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
const options = {
+273
View File
@@ -0,0 +1,273 @@
import crypto from "node:crypto";
import zlib from "node:zlib";
import type { AppSettings } from "../shared/types";
const KEY_PREFIX = "MDD2-";
const KEY_BODY_LENGTH = 70;
const RECORD_ID_LENGTH = 16;
const MASTER_KEY_LENGTH = 32;
const CHECKSUM_LENGTH = 4;
const NONCE_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const BLOB_VERSION = 1;
const MAX_BLOB_BYTES = 256 * 1024;
const MAX_RESPONSE_BYTES = 512 * 1024;
const MAX_PLAINTEXT_BYTES = 512 * 1024;
const REQUEST_TIMEOUT_MS = 12_000;
const KEY_CONTEXT = Buffer.from("MDD2-ONLINE-KEY-V1", "utf8");
const AAD_CONTEXT = Buffer.from("MDD-ONLINE-BACKUP-V1", "utf8");
export interface OnlineSettingsPayload {
version: 1;
kind: "settings-only";
appVersion: string;
exportedAt: string;
settings: AppSettings;
}
export interface OnlineBackupRecord {
id: string;
blob: string;
deleteVerifier: string;
}
export interface CreatedOnlineBackup {
key: string;
record: OnlineBackupRecord;
}
export interface ParsedOnlineBackupKey {
id: string;
idBytes: Buffer;
masterKey: Buffer;
}
function checksum(idBytes: Buffer, masterKey: Buffer): Buffer {
return crypto.createHash("sha256").update(KEY_CONTEXT).update(idBytes).update(masterKey).digest().subarray(0, CHECKSUM_LENGTH);
}
function deriveSecret(masterKey: Buffer, idBytes: Buffer, purpose: string): Buffer {
return Buffer.from(crypto.hkdfSync("sha256", masterKey, idBytes, Buffer.from(`MDD-ONLINE-${purpose}-V1`, "utf8"), 32));
}
function deriveDeleteSecret(parsed: ParsedOnlineBackupKey): Buffer {
return deriveSecret(parsed.masterKey, parsed.idBytes, "DELETE");
}
function aad(idBytes: Buffer): Buffer {
return Buffer.concat([AAD_CONTEXT, idBytes]);
}
function encodeKey(idBytes: Buffer, masterKey: Buffer): string {
const body = Buffer.concat([idBytes, masterKey, checksum(idBytes, masterKey)]).toString("base64url");
return `${KEY_PREFIX}${body}`;
}
function validatePayload(value: unknown): OnlineSettingsPayload {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
}
const record = value as Record<string, unknown>;
if (
record.version !== 1
|| record.kind !== "settings-only"
|| typeof record.appVersion !== "string"
|| typeof record.exportedAt !== "string"
|| !record.settings
|| typeof record.settings !== "object"
|| Array.isArray(record.settings)
|| "session" in record
|| "history" in record
) {
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
}
return record as unknown as OnlineSettingsPayload;
}
function endpoint(baseUrl: string, relativePath: string): string {
const normalized = String(baseUrl || "").trim().replace(/\/+$/, "");
const url = new URL(`${normalized}${relativePath}`);
if (url.protocol !== "https:" && !["127.0.0.1", "localhost", "::1"].includes(url.hostname)) {
throw new Error("Online-Sicherungen benötigen eine sichere HTTPS-Verbindung");
}
return url.toString();
}
async function request(url: string, init?: RequestInit): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch (error) {
if (controller.signal.aborted) {
throw new Error("Online-Sicherungsdienst antwortet nicht");
}
throw new Error(`Online-Sicherungsdienst nicht erreichbar: ${String((error as Error)?.message || error)}`);
} finally {
clearTimeout(timer);
}
}
async function readLimitedText(response: Response): Promise<string> {
const contentLength = Number(response.headers.get("content-length") || "0");
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
throw new Error("Antwort des Online-Sicherungsdienstes ist zu groß");
}
if (!response.body) return "";
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const result = await reader.read();
if (result.done) break;
total += result.value.byteLength;
if (total > MAX_RESPONSE_BYTES) {
await reader.cancel();
throw new Error("Antwort des Online-Sicherungsdienstes ist zu groß");
}
chunks.push(result.value);
}
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
}
export function parseOnlineBackupKey(key: string): ParsedOnlineBackupKey {
const normalized = String(key || "").trim();
if (!new RegExp(`^${KEY_PREFIX}[A-Za-z0-9_-]{${KEY_BODY_LENGTH}}$`).test(normalized)) {
throw new Error("Online-Sicherungsschlüssel ist ungültig");
}
const decoded = Buffer.from(normalized.slice(KEY_PREFIX.length), "base64url");
if (decoded.length !== RECORD_ID_LENGTH + MASTER_KEY_LENGTH + CHECKSUM_LENGTH) {
throw new Error("Online-Sicherungsschlüssel ist ungültig");
}
if (decoded.toString("base64url") !== normalized.slice(KEY_PREFIX.length)) {
throw new Error("Online-Sicherungsschlüssel ist ungültig");
}
const idBytes = decoded.subarray(0, RECORD_ID_LENGTH);
const masterKey = decoded.subarray(RECORD_ID_LENGTH, RECORD_ID_LENGTH + MASTER_KEY_LENGTH);
const actualChecksum = decoded.subarray(RECORD_ID_LENGTH + MASTER_KEY_LENGTH);
const expectedChecksum = checksum(idBytes, masterKey);
if (!crypto.timingSafeEqual(actualChecksum, expectedChecksum)) {
throw new Error("Online-Sicherungsschlüssel ist beschädigt");
}
return { id: idBytes.toString("base64url"), idBytes: Buffer.from(idBytes), masterKey: Buffer.from(masterKey) };
}
export function createOnlineBackup(settings: AppSettings, appVersion: string, exportedAt = new Date().toISOString()): CreatedOnlineBackup {
const idBytes = crypto.randomBytes(RECORD_ID_LENGTH);
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
const key = encodeKey(idBytes, masterKey);
const encryptionKey = deriveSecret(masterKey, idBytes, "ENCRYPTION");
const nonce = crypto.randomBytes(NONCE_LENGTH);
const payload: OnlineSettingsPayload = {
version: 1,
kind: "settings-only",
appVersion,
exportedAt,
settings: JSON.parse(JSON.stringify(settings)) as AppSettings
};
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
if (plaintext.length > MAX_PLAINTEXT_BYTES) {
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
}
const compressed = zlib.gzipSync(plaintext, { level: 9 });
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH });
cipher.setAAD(aad(idBytes));
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]);
if (blobBytes.length > MAX_BLOB_BYTES) {
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
}
const parsed = parseOnlineBackupKey(key);
const deleteVerifier = crypto.createHash("sha256").update(deriveDeleteSecret(parsed)).digest("base64url");
return {
key,
record: {
id: parsed.id,
blob: blobBytes.toString("base64url"),
deleteVerifier
}
};
}
export function restoreOnlineBackup(key: string, blob: string): OnlineSettingsPayload {
const parsed = parseOnlineBackupKey(key);
if (!/^[A-Za-z0-9_-]+$/.test(blob) || blob.length > Math.ceil(MAX_BLOB_BYTES * 4 / 3) + 4) {
throw new Error("Online-Sicherung ist beschädigt");
}
const bytes = Buffer.from(blob, "base64url");
if (bytes.toString("base64url") !== blob) {
throw new Error("Online-Sicherung ist beschädigt");
}
if (bytes.length < 1 + NONCE_LENGTH + AUTH_TAG_LENGTH || bytes[0] !== BLOB_VERSION) {
throw new Error("Online-Sicherung ist beschädigt");
}
const nonce = bytes.subarray(1, 1 + NONCE_LENGTH);
const tag = bytes.subarray(1 + NONCE_LENGTH, 1 + NONCE_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = bytes.subarray(1 + NONCE_LENGTH + AUTH_TAG_LENGTH);
try {
const decipher = crypto.createDecipheriv(
"aes-256-gcm",
deriveSecret(parsed.masterKey, parsed.idBytes, "ENCRYPTION"),
nonce,
{ authTagLength: AUTH_TAG_LENGTH }
);
decipher.setAAD(aad(parsed.idBytes));
decipher.setAuthTag(tag);
const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
const plaintext = zlib.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES }).toString("utf8");
return validatePayload(JSON.parse(plaintext));
} catch (error) {
if (error instanceof Error && /keine gültigen Einstellungen/.test(error.message)) throw error;
throw new Error("Online-Sicherung konnte nicht entschlüsselt werden oder ist beschädigt");
}
}
export async function uploadOnlineBackup(record: OnlineBackupRecord, baseUrl: string): Promise<void> {
const response = await request(endpoint(baseUrl, "/v1/backups"), {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify(record)
});
await readLimitedText(response);
if (response.status !== 201) {
throw new Error("Online-Sicherung konnte nicht gespeichert werden");
}
}
export async function downloadOnlineBackup(key: string, baseUrl: string): Promise<OnlineSettingsPayload> {
const parsed = parseOnlineBackupKey(key);
const response = await request(endpoint(baseUrl, "/v1/backups/restore"), {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ id: parsed.id })
});
const body = await readLimitedText(response);
if (response.status !== 200) {
throw new Error(response.status === 404 ? "Online-Sicherung wurde nicht gefunden" : "Online-Sicherung konnte nicht geladen werden");
}
let value: unknown;
try {
value = JSON.parse(body);
} catch {
throw new Error("Online-Sicherungsdienst hat ungültige Daten geliefert");
}
const blob = (value as { blob?: unknown })?.blob;
if (typeof blob !== "string") {
throw new Error("Online-Sicherungsdienst hat ungültige Daten geliefert");
}
return restoreOnlineBackup(key, blob);
}
export async function deleteOnlineBackup(key: string, baseUrl: string): Promise<void> {
const parsed = parseOnlineBackupKey(key);
const deleteSecret = deriveDeleteSecret(parsed).toString("base64url");
const response = await request(endpoint(baseUrl, "/v1/backups/delete"), {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ id: parsed.id, deleteSecret })
});
if (response.status !== 204) {
await readLimitedText(response);
throw new Error(response.status === 404 ? "Online-Sicherung wurde nicht gefunden" : "Online-Sicherung konnte nicht gelöscht werden");
}
}
+4 -2
View File
@@ -60,8 +60,10 @@ const api: ElectronApi = {
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP),
importOnlineBackup: (key: string): Promise<{ restored: boolean; relaunch: false; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, key),
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
+98 -9
View File
@@ -53,7 +53,7 @@ interface StartConflictPromptState {
applyToAll: boolean;
}
interface ConfirmPromptState {
interface ConfirmPromptState {
title: string;
message: string;
confirmLabel: string;
@@ -61,7 +61,14 @@ interface ConfirmPromptState {
danger?: boolean;
details?: string;
detailsLabel?: string;
}
}
interface OnlineBackupDialogState {
mode: "export" | "import";
key: string;
busy: boolean;
error: string;
}
interface ContextMenuState {
x: number;
@@ -1761,7 +1768,8 @@ export function App(): ReactElement {
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
const [onlineBackupDialog, setOnlineBackupDialog] = useState<OnlineBackupDialogState | null>(null);
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
const [remoteDiagOpen, setRemoteDiagOpen] = useState(false);
const [remoteDiagBusy, setRemoteDiagBusy] = useState(false);
@@ -4227,7 +4235,7 @@ export function App(): ReactElement {
});
};
const onImportBackup = async (): Promise<void> => {
const onImportBackup = async (): Promise<void> => {
closeMenus();
await performQuickAction(async () => {
const result = await window.rd.importBackup();
@@ -4246,7 +4254,49 @@ export function App(): ReactElement {
}, (error) => {
showToast(`Sicherung laden fehlgeschlagen: ${String(error)}`, 2600);
});
};
};
const onCreateOnlineBackup = async (): Promise<void> => {
closeMenus();
setOnlineBackupDialog({ mode: "export", key: "", busy: true, error: "" });
try {
const result = await window.rd.exportOnlineBackup();
setOnlineBackupDialog({ mode: "export", key: result.key, busy: false, error: "" });
showToast("Online-Schlüssel erstellt", 2600);
} catch {
setOnlineBackupDialog({ mode: "export", key: "", busy: false, error: "Online-Sicherung konnte nicht erstellt werden." });
}
};
const onOpenOnlineBackupImport = (): void => {
closeMenus();
setOnlineBackupDialog({ mode: "import", key: "", busy: false, error: "" });
};
const onImportOnlineBackup = async (): Promise<void> => {
const key = onlineBackupDialog?.mode === "import" ? onlineBackupDialog.key.trim() : "";
if (!key) return;
setOnlineBackupDialog((current) => current ? { ...current, busy: true, error: "" } : current);
try {
const result = await window.rd.importOnlineBackup(key);
const fresh = await window.rd.getSnapshot();
applyPersistedSettings(fresh.settings);
setOnlineBackupDialog(null);
showToast(result.message, 4000);
} catch {
setOnlineBackupDialog((current) => current ? { ...current, busy: false, error: "Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen." } : current);
}
};
const onCopyOnlineBackupKey = async (): Promise<void> => {
if (!onlineBackupDialog?.key) return;
try {
await navigator.clipboard.writeText(onlineBackupDialog.key);
showToast("Online-Schlüssel kopiert", 2200);
} catch {
showToast("Schlüssel konnte nicht kopiert werden", 2600);
}
};
const onExportSupportBundle = async (): Promise<void> => {
closeMenus();
@@ -4649,8 +4699,11 @@ export function App(): ReactElement {
<button className="menu-submenu-trigger">Sicherung</button>
{openSubmenu === "sicherung" && (
<div className="menu-submenu-dropdown">
<button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Exportieren</button>
<button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Importieren</button>
<button className="menu-dropdown-item" onClick={() => { void onExportBackup(); }}>Exportieren</button>
<button className="menu-dropdown-item" onClick={() => { void onImportBackup(); }}>Importieren</button>
<div className="menu-separator" />
<button className="menu-dropdown-item" onClick={() => { void onCreateOnlineBackup(); }}>Online-Schlüssel erstellen</button>
<button className="menu-dropdown-item" onClick={onOpenOnlineBackupImport}>Online-Schlüssel importieren</button>
</div>
)}
</div>
@@ -6005,7 +6058,7 @@ export function App(): ReactElement {
)}
</main>
{confirmPrompt && (
{confirmPrompt && (
<div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}>
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
<h3>{confirmPrompt.title}</h3>
@@ -6027,7 +6080,43 @@ export function App(): ReactElement {
</div>
</div>
</div>
)}
)}
{onlineBackupDialog && (
<div className="modal-backdrop" onClick={() => { if (!onlineBackupDialog.busy) setOnlineBackupDialog(null); }}>
<div className="modal-card online-backup-modal" onClick={(event) => event.stopPropagation()}>
<h3>{onlineBackupDialog.mode === "export" ? "Online-Schlüssel" : "Online-Schlüssel importieren"}</h3>
<p>
{onlineBackupDialog.mode === "export"
? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf."
: "Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt."}
</p>
{onlineBackupDialog.mode === "export" && onlineBackupDialog.busy && <div className="online-backup-status">Online-Sicherung wird verschlüsselt und gespeichert </div>}
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && (
<textarea className="online-backup-key" value={onlineBackupDialog.key} readOnly spellCheck={false} aria-label="Online-Sicherungsschlüssel" />
)}
{onlineBackupDialog.mode === "import" && (
<textarea
className="online-backup-key"
value={onlineBackupDialog.key}
onChange={(event) => setOnlineBackupDialog((current) => current ? { ...current, key: event.target.value, error: "" } : current)}
placeholder="MDD2-…"
spellCheck={false}
autoComplete="off"
disabled={onlineBackupDialog.busy}
autoFocus
aria-label="Online-Sicherungsschlüssel eingeben"
/>
)}
{onlineBackupDialog.error && <div className="online-backup-error">{onlineBackupDialog.error}</div>}
<div className="modal-actions">
<button className="btn" onClick={() => setOnlineBackupDialog(null)} disabled={onlineBackupDialog.busy}>Schließen</button>
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && <button className="btn primary" onClick={() => { void onCopyOnlineBackupKey(); }}>Kopieren</button>}
{onlineBackupDialog.mode === "import" && <button className="btn primary" onClick={() => { void onImportOnlineBackup(); }} disabled={onlineBackupDialog.busy || !onlineBackupDialog.key.trim()}>{onlineBackupDialog.busy ? "Wird geladen …" : "Importieren"}</button>}
</div>
</div>
</div>
)}
{remoteDiagOpen && (
<div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}>
+34 -2
View File
@@ -3111,12 +3111,44 @@ td {
word-break: break-all;
}
.modal-actions {
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
}
.online-backup-modal {
width: min(620px, 100%);
}
.online-backup-key {
width: 100%;
min-height: 86px;
resize: vertical;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
padding: 10px 12px;
font: 13px/1.55 "Cascadia Mono", "Consolas", monospace;
overflow-wrap: anywhere;
}
.online-backup-key:focus {
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
border-color: var(--accent);
}
.online-backup-status {
color: var(--muted);
font-size: 13px;
}
.online-backup-error {
color: var(--danger);
font-size: 13px;
}
@media (max-width: 1100px) {
.control-strip {
+4 -2
View File
@@ -36,8 +36,10 @@ export const IPC_CHANNELS = {
RESET_DOWNLOAD_STATS: "stats:reset-download",
RESTART: "app:restart",
QUIT: "app:quit",
EXPORT_BACKUP: "app:export-backup",
IMPORT_BACKUP: "app:import-backup",
EXPORT_BACKUP: "app:export-backup",
IMPORT_BACKUP: "app:import-backup",
EXPORT_ONLINE_BACKUP: "app:export-online-backup",
IMPORT_ONLINE_BACKUP: "app:import-online-backup",
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
OPEN_LOG: "app:open-log",
OPEN_AUDIT_LOG: "app:open-audit-log",
+4 -2
View File
@@ -57,8 +57,10 @@ export interface ElectronApi {
resetDownloadStats: () => Promise<void>;
restart: () => Promise<void>;
quit: () => Promise<void>;
exportBackup: () => Promise<{ saved: boolean }>;
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
exportBackup: () => Promise<{ saved: boolean }>;
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
exportOnlineBackup: () => Promise<{ key: string }>;
importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
openLog: () => Promise<void>;
openAuditLog: () => Promise<void>;