Compare commits
2 Commits
3ed3877ac9
...
2ececf699a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ececf699a | ||
|
|
d006a60553 |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "real-debrid-downloader",
|
||||
"version": "1.7.183",
|
||||
"version": "1.7.184",
|
||||
"description": "Desktop downloader",
|
||||
"main": "build/main/main/main.js",
|
||||
"author": "Sucukdeluxe",
|
||||
|
||||
@ -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
src/main/backup-payload.ts
Normal file
77
src/main/backup-payload.ts
Normal 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"
|
||||
};
|
||||
}
|
||||
@ -104,6 +104,7 @@ export function defaultSettings(): AppSettings {
|
||||
autoSkipExtracted: false,
|
||||
hideExtractedItems: true,
|
||||
confirmDeleteSelection: true,
|
||||
backupIncludeDownloads: false,
|
||||
totalDownloadedAllTime: 0,
|
||||
totalCompletedFilesAllTime: 0,
|
||||
totalRuntimeAllTimeMs: 0,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -58,7 +58,7 @@ const api: ElectronApi = {
|
||||
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; 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<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||
|
||||
@ -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<string[]>(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 {
|
||||
<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.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) => {
|
||||
const next = e.target.checked ? "light" : "dark";
|
||||
settingsDraftRevisionRef.current += 1;
|
||||
@ -6335,7 +6359,10 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
|
||||
switch (col) {
|
||||
case "name": return (
|
||||
<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}
|
||||
</span>
|
||||
);
|
||||
|
||||
@ -36,38 +36,26 @@ export function sortPackagesForDisplay(
|
||||
return packages;
|
||||
}
|
||||
|
||||
const active: Array<{ pkg: PackageEntry; index: number; completedRatio: number; downloadedBytes: number }> = [];
|
||||
const active: PackageEntry[] = [];
|
||||
const rest: PackageEntry[] = [];
|
||||
|
||||
packages.forEach((pkg, index) => {
|
||||
const items = pkg.itemIds
|
||||
.map((id) => itemsById[id])
|
||||
.filter((item): item is DownloadItem => Boolean(item));
|
||||
const hasActive = items.some((item) => ACTIVE_PACKAGE_STATUSES.has(item.status));
|
||||
if (!hasActive) {
|
||||
rest.push(pkg);
|
||||
return;
|
||||
}
|
||||
const completedRatio = items.length > 0
|
||||
? items.filter((item) => item.status === "completed").length / items.length
|
||||
: 0;
|
||||
const downloadedBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
|
||||
active.push({ pkg, index, completedRatio, downloadedBytes });
|
||||
});
|
||||
// Float packages that have an active item to the top, but keep BOTH groups in
|
||||
// their original (queue) order. Earlier this sorted the active group by live
|
||||
// completedRatio/downloadedBytes — which change on every progress tick (every
|
||||
// 150-700ms), so active packages visibly reshuffled the whole time. A package
|
||||
// entering/leaving the active bucket is a real, discrete event (start/finish);
|
||||
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
|
||||
for (const pkg of packages) {
|
||||
const hasActive = pkg.itemIds.some((id) => {
|
||||
const item = itemsById[id];
|
||||
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
|
||||
});
|
||||
(hasActive ? active : rest).push(pkg);
|
||||
}
|
||||
|
||||
if (active.length === 0 || active.length === packages.length) {
|
||||
return packages;
|
||||
}
|
||||
|
||||
active.sort((a, b) => {
|
||||
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];
|
||||
return [...active, ...rest];
|
||||
}
|
||||
|
||||
27
src/renderer/selection.ts
Normal file
27
src/renderer/selection.ts
Normal 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;
|
||||
}
|
||||
@ -2434,6 +2434,12 @@ td {
|
||||
background: #f59e0b;
|
||||
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 {
|
||||
color: #f59e0b !important;
|
||||
|
||||
@ -55,7 +55,7 @@ export interface ElectronApi {
|
||||
restart: () => Promise<void>;
|
||||
quit: () => Promise<void>;
|
||||
exportBackup: () => Promise<{ saved: boolean }>;
|
||||
importBackup: () => Promise<{ restored: boolean; message: string }>;
|
||||
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>;
|
||||
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
||||
openLog: () => Promise<void>;
|
||||
openAuditLog: () => Promise<void>;
|
||||
|
||||
@ -129,6 +129,7 @@ export interface AppSettings {
|
||||
autoSkipExtracted: boolean;
|
||||
hideExtractedItems: boolean;
|
||||
confirmDeleteSelection: boolean;
|
||||
backupIncludeDownloads: boolean;
|
||||
totalDownloadedAllTime: number;
|
||||
totalCompletedFilesAllTime: number;
|
||||
totalRuntimeAllTimeMs: number;
|
||||
|
||||
81
tests/backup-payload.test.ts
Normal file
81
tests/backup-payload.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -44,23 +44,50 @@ function createItem(id: string, packageId: string, status: DownloadItem["status"
|
||||
}
|
||||
|
||||
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 = [
|
||||
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> = {
|
||||
a1: createItem("a1", "pkg-a", "downloading", 250),
|
||||
a2: createItem("a2", "pkg-a", "completed", 500),
|
||||
c1: createItem("c1", "pkg-c", "queued", 0),
|
||||
b1: createItem("b1", "pkg-b", "downloading", 800),
|
||||
b2: createItem("b2", "pkg-b", "completed", 900),
|
||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
||||
b2: createItem("b2", "pkg-b", "completed", 900)
|
||||
};
|
||||
|
||||
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", () => {
|
||||
|
||||
44
tests/selection.test.ts
Normal file
44
tests/selection.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user