diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 361027b..a3c8828 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -39,6 +39,7 @@ import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { encryptBackup, decryptBackup } from "./backup-crypto"; +import { buildBackupPayload, planBackupImport } from "./backup-payload"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log"; import { runStartupHealthCheck } from "./startup-health-check"; @@ -598,23 +599,21 @@ public async checkDebridAccounts(): Promise { } public exportBackup(): Buffer { - const settings = { ...this.settings }; - const session = this.manager.getSession(); - const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - const payload = JSON.stringify({ - version: 2, + const includeDownloads = Boolean(this.settings.backupIncludeDownloads); + const payloadObj = buildBackupPayload({ + settings: { ...this.settings }, appVersion: APP_VERSION, exportedAt: new Date().toISOString(), - settings, - session, - history + session: this.manager.getSession(), + history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode) }); this.audit("INFO", "Backup exportiert", { - historyEntries: history.length, - sessionItems: Object.keys(session.items).length, - sessionPackages: Object.keys(session.packages).length + kind: payloadObj.kind, + historyEntries: payloadObj.history ? payloadObj.history.length : 0, + sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0, + sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0 }); - return encryptBackup(payload); + return encryptBackup(JSON.stringify(payloadObj)); } public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } { @@ -633,7 +632,7 @@ public async checkDebridAccounts(): Promise { return getSupportBundleDefaultFileName(); } - public importBackup(data: Buffer): { restored: boolean; message: string } { + public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } { let parsed: Record; try { const json = decryptBackup(data); @@ -643,12 +642,14 @@ public async checkDebridAccounts(): Promise { const json = data.toString("utf8"); parsed = JSON.parse(json) as Record; } catch { - return { restored: false, message: "Backup-Datei konnte nicht entschlüsselt werden" }; + return { restored: false, relaunch: false, message: "Backup-Datei konnte nicht entschlüsselt werden" }; } } - if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) { - return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" }; + const plan = planBackupImport(parsed); + if (!plan.valid) { + return { restored: false, relaunch: false, message: plan.message }; } + const hasSession = plan.restoreDownloads; const importedSettings = parsed.settings as AppSettings; const importedSettingsRecord = importedSettings as unknown as Record; @@ -669,6 +670,20 @@ public async checkDebridAccounts(): Promise { saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); + // Settings-only backup: settings are already applied live (same path as the + // normal updateSettings flow). Do NOT stop the manager, wipe the session, + // block persistence or relaunch — the running queue stays untouched. + if (!hasSession) { + this.audit("INFO", "Backup importiert (nur Einstellungen)", { + accountSummary: buildAccountSummary(this.settings) + }); + return { + restored: true, + relaunch: false, + message: "Einstellungen wiederhergestellt" + }; + } + this.manager.stop(); this.manager.abortAllPostProcessing(); this.manager.clearPersistTimer(); @@ -698,7 +713,7 @@ public async checkDebridAccounts(): Promise { historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, accountSummary: buildAccountSummary(this.settings) }); - return { restored: true, message: "Backup wiederhergestellt – App startet automatisch neu…" }; + return { restored: true, relaunch: true, message: "Backup wiederhergestellt – App startet automatisch neu…" }; } public getSessionLogPath(): string | null { diff --git a/src/main/backup-payload.ts b/src/main/backup-payload.ts new file mode 100644 index 0000000..8a3ba24 --- /dev/null +++ b/src/main/backup-payload.ts @@ -0,0 +1,77 @@ +import type { AppSettings, SessionState, HistoryEntry } from "../shared/types"; + +export type BackupKind = "full" | "settings-only"; + +export interface BackupPayload { + version: 2; + kind: BackupKind; + appVersion: string; + exportedAt: string; + settings: AppSettings; + session?: SessionState; + history?: HistoryEntry[]; +} + +export interface BuildBackupInput { + settings: AppSettings; + appVersion: string; + exportedAt: string; + /** Only bundled when includeDownloads is true. */ + session: SessionState; + history: HistoryEntry[]; +} + +/** + * Build the backup payload. By default ("Download-Liste mitsichern" off) the + * payload contains ONLY settings — no session, no history. The download list is + * bundled solely when settings.backupIncludeDownloads is true. An explicit kind + * marker makes the import side unambiguous and survives hand-edited files. + */ +export function buildBackupPayload(input: BuildBackupInput): BackupPayload { + const includeDownloads = Boolean(input.settings.backupIncludeDownloads); + const base: BackupPayload = { + version: 2, + kind: includeDownloads ? "full" : "settings-only", + appVersion: input.appVersion, + exportedAt: input.exportedAt, + settings: input.settings + }; + if (includeDownloads) { + base.session = input.session; + base.history = input.history; + } + return base; +} + +export interface ImportPlan { + valid: boolean; + /** Restore the download list (session + history) and relaunch. */ + restoreDownloads: boolean; + message: string; +} + +/** + * Decide how to apply an imported backup based on what the FILE physically + * contains — NOT the local toggle. A backup without a session restores settings + * only (no queue wipe, no relaunch); a full backup (with session) restores the + * queue too. This way an old full backup still restores fully even if the local + * toggle is currently off, and a settings-only backup never disturbs a running + * queue. + */ +export function planBackupImport(parsed: unknown): ImportPlan { + if (!parsed || typeof parsed !== "object") { + return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" }; + } + const record = parsed as Record; + if (!record.settings || typeof record.settings !== "object") { + return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" }; + } + const hasSession = Boolean(record.session) && typeof record.session === "object"; + return { + valid: true, + restoreDownloads: hasSession, + message: hasSession + ? "Backup wiederhergestellt – App startet automatisch neu…" + : "Einstellungen wiederhergestellt" + }; +} diff --git a/src/main/constants.ts b/src/main/constants.ts index 91273be..f37af15 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -104,6 +104,7 @@ export function defaultSettings(): AppSettings { autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, + backupIncludeDownloads: false, totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, diff --git a/src/main/main.ts b/src/main/main.ts index 01d085c..1e21db6 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -664,7 +664,10 @@ function registerIpcHandlers(): void { } const data = await fs.promises.readFile(filePath); const importResult = controller.importBackup(data); - if (importResult.restored) { + // Only a full restore (queue swapped) needs the auto-relaunch. A settings- + // only import applied live — relaunching would be pointless and would drop + // the running queue. + if (importResult.restored && importResult.relaunch) { setTimeout(() => { app.relaunch(); app.quit(); diff --git a/src/main/storage.ts b/src/main/storage.ts index 35e3836..6c9135a 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -456,6 +456,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted, hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems, confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, + backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads, totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime, totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs, diff --git a/src/preload/preload.ts b/src/preload/preload.ts index e390d13..acd84b1 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -58,7 +58,7 @@ const api: ElectronApi = { restart: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESTART), quit: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.QUIT), exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), - importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), + importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openAuditLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c683161..8004a11 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -32,6 +32,7 @@ import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order"; +import { pruneSelection } from "./selection"; type Tab = "collector" | "downloads" | "history" | "statistics" | "settings"; type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates"; @@ -850,7 +851,7 @@ const emptySnapshot = (): UiSnapshot => ({ autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, - theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, + theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, accountListShowDetailedDebridLinkKeys: false, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], @@ -2109,6 +2110,13 @@ export function App(): ReactElement { }); }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]); + // Prune selection when its packages/items disappear (e.g. via delta-removal or + // a backup-driven session swap). selectedIds holds BOTH package and item ids; + // a stale id would otherwise inflate the selection count and the "(N)" labels. + useEffect(() => { + setSelectedIds((prev) => pruneSelection(prev, snapshot.session)); + }, [snapshot.session.packages, snapshot.session.items]); + const hiddenPackageCount = shouldLimitPackageRendering ? Math.max(0, totalPackageCount - packages.length) : 0; @@ -3539,6 +3547,11 @@ export function App(): ReactElement { return ids; }, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]); + // Keep a ref of the currently VISIBLE ids so the (deps-[]) Ctrl+A keyboard + // handler can select exactly what the user sees — not the whole unfiltered map. + const visibleOrderIdsRef = useRef(visibleOrderIds); + visibleOrderIdsRef.current = visibleOrderIds; + const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => { if (dragDidMoveRef.current) return; if (shiftKey && lastClickedIdRef.current) { @@ -3838,6 +3851,13 @@ export function App(): ReactElement { const result = await window.rd.importBackup(); if (result.restored) { showToast(result.message, 4000); + // A settings-only import applies live without a relaunch, so the editable + // settings form would otherwise keep showing the old values. Pull the + // fresh settings and re-seed the draft so the UI reflects the import. + if (!result.relaunch) { + const fresh = await window.rd.getSnapshot(); + applyPersistedSettings(fresh.settings); + } } else if (result.message !== "Abgebrochen") { showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000); } @@ -3961,7 +3981,10 @@ export function App(): ReactElement { if (inInput) return; if (tabRef.current === "downloads") { e.preventDefault(); - setSelectedIds(new Set(Object.keys(snapshotRef.current.session.packages))); + // Select exactly the VISIBLE rows (packages + their items), honouring + // the active search / collapse / hide-extracted filters — selecting + // the unfiltered package map would let a later delete hit hidden ones. + setSelectedIds(new Set(visibleOrderIdsRef.current)); } else if (tabRef.current === "history") { e.preventDefault(); setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id))); @@ -4881,6 +4904,7 @@ export function App(): ReactElement { +