Backup: nur Settings als Default + 4 Selektions/Flicker-Bugfixes

Backup:
- Neues Setting backupIncludeDownloads (Default aus) — Backup sichert
  standardmaessig NUR Einstellungen, nicht die Download-Liste/History.
- buildBackupPayload/planBackupImport (testbare backup-payload.ts): Export
  omittet session+history wenn Flag aus (explizites kind-Marker); Import folgt
  dem FILE-Inhalt, nicht dem lokalen Toggle.
- importBackup: settings-only -> frueher Return nach setSettings, KEIN stop/
  Queue-Wipe/Relaunch. Return {restored,relaunch,message}; main.ts gated den
  Auto-Relaunch auf relaunch. Renderer re-seeded settingsDraft bei !relaunch.

Bugfixes:
- Ctrl+A waehlte das ungefilterte Paket-Map -> Loeschen nach Suche traf
  versteckte Pakete. Jetzt visibleOrderIds (sichtbare Zeilen, inkl. Items).
- selectedIds nie geprunt bei Delta-Removal -> aufgeblaehte Counts. Neue pure
  pruneSelection (selection.ts) + Effect.
- link-status-dot conditional -> Dateiname sprang ~14px. Platzhalter-Slot.
- sortPackagesForDisplay sortierte aktive Pakete nach Live-Progress -> Reshuffle
  pro Tick. Jetzt stabile Queue-Reihenfolge je Gruppe (Anti-Flicker).

+17 Tests (backup-payload 9, selection 5, package-order anti-flicker 3).
This commit is contained in:
Sucukdeluxe
2026-06-07 04:40:54 +02:00
parent 3ed3877ac9
commit d006a60553
15 changed files with 354 additions and 56 deletions
+32 -17
View File
@@ -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<DebridAccountStatus[]> {
}
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<DebridAccountStatus[]> {
return getSupportBundleDefaultFileName();
}
public importBackup(data: Buffer): { restored: boolean; message: string } {
public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } {
let parsed: Record<string, unknown>;
try {
const json = decryptBackup(data);
@@ -643,12 +642,14 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
const json = data.toString("utf8");
parsed = JSON.parse(json) as Record<string, unknown>;
} 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<string, unknown>;
@@ -669,6 +670,20 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
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<DebridAccountStatus[]> {
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 {
+77
View File
@@ -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<string, unknown>;
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"
};
}
+1
View File
@@ -104,6 +104,7 @@ export function defaultSettings(): AppSettings {
autoSkipExtracted: false,
hideExtractedItems: true,
confirmDeleteSelection: true,
backupIncludeDownloads: false,
totalDownloadedAllTime: 0,
totalCompletedFilesAllTime: 0,
totalRuntimeAllTimeMs: 0,
+4 -1
View File
@@ -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();
+1
View File
@@ -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,