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:39:03 +02:00
parent 3ed3877ac9
commit d006a60553
15 changed files with 354 additions and 56 deletions

View File

@ -39,6 +39,7 @@ import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves,
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto"; import { encryptBackup, decryptBackup } from "./backup-crypto";
import { buildBackupPayload, planBackupImport } from "./backup-payload";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log"; import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
import { runStartupHealthCheck } from "./startup-health-check"; import { runStartupHealthCheck } from "./startup-health-check";
@ -598,23 +599,21 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
} }
public exportBackup(): Buffer { public exportBackup(): Buffer {
const settings = { ...this.settings }; const includeDownloads = Boolean(this.settings.backupIncludeDownloads);
const session = this.manager.getSession(); const payloadObj = buildBackupPayload({
const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); settings: { ...this.settings },
const payload = JSON.stringify({
version: 2,
appVersion: APP_VERSION, appVersion: APP_VERSION,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
settings, session: this.manager.getSession(),
session, history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode)
history
}); });
this.audit("INFO", "Backup exportiert", { this.audit("INFO", "Backup exportiert", {
historyEntries: history.length, kind: payloadObj.kind,
sessionItems: Object.keys(session.items).length, historyEntries: payloadObj.history ? payloadObj.history.length : 0,
sessionPackages: Object.keys(session.packages).length 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 } { public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
@ -633,7 +632,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
return getSupportBundleDefaultFileName(); 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>; let parsed: Record<string, unknown>;
try { try {
const json = decryptBackup(data); const json = decryptBackup(data);
@ -643,12 +642,14 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
const json = data.toString("utf8"); const json = data.toString("utf8");
parsed = JSON.parse(json) as Record<string, unknown>; parsed = JSON.parse(json) as Record<string, unknown>;
} catch { } 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) { const plan = planBackupImport(parsed);
return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" }; if (!plan.valid) {
return { restored: false, relaunch: false, message: plan.message };
} }
const hasSession = plan.restoreDownloads;
const importedSettings = parsed.settings as AppSettings; const importedSettings = parsed.settings as AppSettings;
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>; const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
@ -669,6 +670,20 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(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.stop();
this.manager.abortAllPostProcessing(); this.manager.abortAllPostProcessing();
this.manager.clearPersistTimer(); this.manager.clearPersistTimer();
@ -698,7 +713,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
accountSummary: buildAccountSummary(this.settings) 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 { public getSessionLogPath(): string | null {

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"
};
}

View File

@ -104,6 +104,7 @@ export function defaultSettings(): AppSettings {
autoSkipExtracted: false, autoSkipExtracted: false,
hideExtractedItems: true, hideExtractedItems: true,
confirmDeleteSelection: true, confirmDeleteSelection: true,
backupIncludeDownloads: false,
totalDownloadedAllTime: 0, totalDownloadedAllTime: 0,
totalCompletedFilesAllTime: 0, totalCompletedFilesAllTime: 0,
totalRuntimeAllTimeMs: 0, totalRuntimeAllTimeMs: 0,

View File

@ -664,7 +664,10 @@ function registerIpcHandlers(): void {
} }
const data = await fs.promises.readFile(filePath); const data = await fs.promises.readFile(filePath);
const importResult = controller.importBackup(data); 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(() => { setTimeout(() => {
app.relaunch(); app.relaunch();
app.quit(); app.quit();

View File

@ -456,6 +456,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted, autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems, hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, 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, totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime, totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs, totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,

View File

@ -58,7 +58,7 @@ const api: ElectronApi = {
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART), restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT), quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), 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), exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),

View File

@ -32,6 +32,7 @@ import {
getProviderUsageDayKey getProviderUsageDayKey
} from "../shared/provider-daily-limits"; } from "../shared/provider-daily-limits";
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order"; import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
import { pruneSelection } from "./selection";
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings"; type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates"; type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
@ -850,7 +851,7 @@ const emptySnapshot = (): UiSnapshot => ({
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, 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, accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], 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]); }, [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 const hiddenPackageCount = shouldLimitPackageRendering
? Math.max(0, totalPackageCount - packages.length) ? Math.max(0, totalPackageCount - packages.length)
: 0; : 0;
@ -3539,6 +3547,11 @@ export function App(): ReactElement {
return ids; return ids;
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]); }, [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<string[]>(visibleOrderIds);
visibleOrderIdsRef.current = visibleOrderIds;
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => { const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
if (dragDidMoveRef.current) return; if (dragDidMoveRef.current) return;
if (shiftKey && lastClickedIdRef.current) { if (shiftKey && lastClickedIdRef.current) {
@ -3838,6 +3851,13 @@ export function App(): ReactElement {
const result = await window.rd.importBackup(); const result = await window.rd.importBackup();
if (result.restored) { if (result.restored) {
showToast(result.message, 4000); 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") { } else if (result.message !== "Abgebrochen") {
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000); showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
} }
@ -3961,7 +3981,10 @@ export function App(): ReactElement {
if (inInput) return; if (inInput) return;
if (tabRef.current === "downloads") { if (tabRef.current === "downloads") {
e.preventDefault(); 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") { } else if (tabRef.current === "history") {
e.preventDefault(); e.preventDefault();
setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id))); setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id)));
@ -4881,6 +4904,7 @@ export function App(): ReactElement {
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste in Sicherung mitsichern (Standard: nur Einstellungen)</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => { <label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
const next = e.target.checked ? "light" : "dark"; const next = e.target.checked ? "light" : "dark";
settingsDraftRevisionRef.current += 1; settingsDraftRevisionRef.current += 1;
@ -6335,7 +6359,10 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
switch (col) { switch (col) {
case "name": return ( case "name": return (
<span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}> <span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
{item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />} <span
className={item.onlineStatus ? `link-status-dot ${item.onlineStatus}` : "link-status-dot link-status-dot-empty"}
title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Wird geprüft..." : undefined}
/>
{item.fileName} {item.fileName}
</span> </span>
); );

View File

@ -36,38 +36,26 @@ export function sortPackagesForDisplay(
return packages; return packages;
} }
const active: Array<{ pkg: PackageEntry; index: number; completedRatio: number; downloadedBytes: number }> = []; const active: PackageEntry[] = [];
const rest: PackageEntry[] = []; const rest: PackageEntry[] = [];
packages.forEach((pkg, index) => { // Float packages that have an active item to the top, but keep BOTH groups in
const items = pkg.itemIds // their original (queue) order. Earlier this sorted the active group by live
.map((id) => itemsById[id]) // completedRatio/downloadedBytes — which change on every progress tick (every
.filter((item): item is DownloadItem => Boolean(item)); // 150-700ms), so active packages visibly reshuffled the whole time. A package
const hasActive = items.some((item) => ACTIVE_PACKAGE_STATUSES.has(item.status)); // entering/leaving the active bucket is a real, discrete event (start/finish);
if (!hasActive) { // ranking *within* the bucket by live bytes was pure jitter nobody needs.
rest.push(pkg); for (const pkg of packages) {
return; const hasActive = pkg.itemIds.some((id) => {
} const item = itemsById[id];
const completedRatio = items.length > 0 return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
? items.filter((item) => item.status === "completed").length / items.length });
: 0; (hasActive ? active : rest).push(pkg);
const downloadedBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); }
active.push({ pkg, index, completedRatio, downloadedBytes });
});
if (active.length === 0 || active.length === packages.length) { if (active.length === 0 || active.length === packages.length) {
return packages; return packages;
} }
active.sort((a, b) => { return [...active, ...rest];
if (a.completedRatio !== b.completedRatio) {
return b.completedRatio - a.completedRatio;
}
if (a.downloadedBytes !== b.downloadedBytes) {
return b.downloadedBytes - a.downloadedBytes;
}
return a.index - b.index;
});
return [...active.map((entry) => entry.pkg), ...rest];
} }

27
src/renderer/selection.ts Normal file
View File

@ -0,0 +1,27 @@
import type { SessionState } from "../shared/types";
/**
* Drop selected ids whose package OR item no longer exists in the session.
* The selection set mixes package and item ids; when entries vanish (delta
* removal, backup-driven session swap, completed-cleanup) a stale id would
* otherwise inflate the selection count and the "(N)" action labels and keep
* "multi" styling alive for ghosts.
*
* Returns the SAME set instance when nothing changed, so callers can use it
* directly as a React state updater without forcing a re-render.
*/
export function pruneSelection(
selected: ReadonlySet<string>,
session: Pick<SessionState, "packages" | "items">
): Set<string> {
if (selected.size === 0) {
return selected as Set<string>;
}
const next = new Set<string>();
for (const id of selected) {
if (session.packages[id] || session.items[id]) {
next.add(id);
}
}
return next.size === selected.size ? (selected as Set<string>) : next;
}

View File

@ -2434,6 +2434,12 @@ td {
background: #f59e0b; background: #f59e0b;
box-shadow: 0 0 4px #f59e0b80; box-shadow: 0 0 4px #f59e0b80;
} }
/* Reserve the dot's footprint even before a status exists, so the filename does
not shift ~14px right when the online/offline/checking dot first appears. */
.link-status-dot-empty {
background: transparent;
box-shadow: none;
}
.prio-high { .prio-high {
color: #f59e0b !important; color: #f59e0b !important;

View File

@ -55,7 +55,7 @@ export interface ElectronApi {
restart: () => Promise<void>; restart: () => Promise<void>;
quit: () => Promise<void>; quit: () => Promise<void>;
exportBackup: () => Promise<{ saved: boolean }>; exportBackup: () => Promise<{ saved: boolean }>;
importBackup: () => Promise<{ restored: boolean; message: string }>; importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
openLog: () => Promise<void>; openLog: () => Promise<void>;
openAuditLog: () => Promise<void>; openAuditLog: () => Promise<void>;

View File

@ -129,6 +129,7 @@ export interface AppSettings {
autoSkipExtracted: boolean; autoSkipExtracted: boolean;
hideExtractedItems: boolean; hideExtractedItems: boolean;
confirmDeleteSelection: boolean; confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean;
totalDownloadedAllTime: number; totalDownloadedAllTime: number;
totalCompletedFilesAllTime: number; totalCompletedFilesAllTime: number;
totalRuntimeAllTimeMs: number; totalRuntimeAllTimeMs: number;

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
}
const session: SessionState = {
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
reconnectReason: "", paused: false, running: true, updatedAt: 0
};
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
describe("buildBackupPayload — default is settings-only", () => {
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
expect(p.history).toBeUndefined();
expect(p.settings).toBeDefined();
});
it("includes session + history when backupIncludeDownloads is true", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
expect(p.kind).toBe("full");
expect(p.session).toBe(session);
expect(p.history).toBe(history);
});
it("treats a missing flag as settings-only (safe default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
});
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
// "Haken aus bleibt aus": the exported settings object preserves the flag,
// so importing it keeps the toggle off.
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
});
});
describe("planBackupImport — decision follows the file, not the local toggle", () => {
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
expect(plan.valid).toBe(true);
expect(plan.restoreDownloads).toBe(false);
expect(plan.message).toMatch(/Einstellungen/);
});
it("full backup (with session) -> restore downloads + relaunch", () => {
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
expect(plan.valid).toBe(true);
expect(plan.restoreDownloads).toBe(true);
});
it("rejects payloads without settings", () => {
expect(planBackupImport({ session }).valid).toBe(false);
expect(planBackupImport(null).valid).toBe(false);
expect(planBackupImport("nope").valid).toBe(false);
expect(planBackupImport({}).valid).toBe(false);
});
it("a settings-only export then import does NOT pull in the download list", () => {
// Build with toggle off, then plan the import of exactly that payload.
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
});
it("a full export then import DOES restore the download list", () => {
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
expect(plan.restoreDownloads).toBe(true);
});
});

View File

@ -44,23 +44,50 @@ function createItem(id: string, packageId: string, status: DownloadItem["status"
} }
describe("sortPackagesForDisplay", () => { describe("sortPackagesForDisplay", () => {
it("moves active packages with more progress to the top when auto sort is enabled", () => { it("floats active packages to the top, keeping queue order within each group", () => {
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
// their original queue order; pkg-c (queued only) sinks below.
const packages = [ const packages = [
createPackage("pkg-a", ["a1", "a2"]), createPackage("pkg-a", ["a1", "a2"]),
createPackage("pkg-b", ["b1", "b2"]), createPackage("pkg-c", ["c1"]),
createPackage("pkg-c", ["c1"]) createPackage("pkg-b", ["b1", "b2"])
]; ];
const items: Record<string, DownloadItem> = { const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 250), a1: createItem("a1", "pkg-a", "downloading", 250),
a2: createItem("a2", "pkg-a", "completed", 500), a2: createItem("a2", "pkg-a", "completed", 500),
c1: createItem("c1", "pkg-c", "queued", 0),
b1: createItem("b1", "pkg-b", "downloading", 800), b1: createItem("b1", "pkg-b", "downloading", 800),
b2: createItem("b2", "pkg-b", "completed", 900), b2: createItem("b2", "pkg-b", "completed", 900)
c1: createItem("c1", "pkg-c", "queued", 0)
}; };
const sorted = sortPackagesForDisplay(packages, items, true, true); const sorted = sortPackagesForDisplay(packages, items, true, true);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-a", "pkg-c"]); // active group [pkg-a, pkg-b] in queue order, then rest [pkg-c]
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"])
];
// Both active. pkg-b initially has more bytes than pkg-a.
const before: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 100),
b1: createItem("b1", "pkg-b", "downloading", 900)
};
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
// both are still active, so they keep queue order. (Old code swapped them.)
const after: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 5000),
b1: createItem("b1", "pkg-b", "downloading", 950)
};
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
expect(orderAfter).toEqual(orderBefore);
}); });
it("keeps package order untouched when auto sort is disabled", () => { it("keeps package order untouched when auto sort is disabled", () => {

44
tests/selection.test.ts Normal file
View File

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { pruneSelection } from "../src/renderer/selection";
import type { SessionState } from "../src/shared/types";
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
const packages: Record<string, never> = {};
const items: Record<string, never> = {};
for (const id of packageIds) packages[id] = {} as never;
for (const id of itemIds) items[id] = {} as never;
return { packages, items };
}
describe("pruneSelection", () => {
it("drops ids whose package/item no longer exists", () => {
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
expect([...next].sort()).toEqual(["i1", "p1"]);
});
it("returns the SAME set instance when nothing changed (no needless re-render)", () => {
const sel = new Set(["p1", "i1"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
expect(next).toBe(sel);
});
it("returns the same instance for an empty selection", () => {
const sel = new Set<string>();
expect(pruneSelection(sel, session(["p1"], ["i1"]))).toBe(sel);
});
it("prunes everything when the whole session was swapped out", () => {
const sel = new Set(["p1", "i1"]);
const next = pruneSelection(sel, session([], []));
expect(next.size).toBe(0);
expect(next).not.toBe(sel);
});
it("keeps a mixed package+item selection when both survive", () => {
const sel = new Set(["p1", "p2", "i1"]);
const next = pruneSelection(sel, session(["p1", "p2"], ["i1", "i2"]));
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
expect(next).toBe(sel); // unchanged → same instance
});
});