fix(history): remove selections atomically

This commit is contained in:
Sucukdeluxe
2026-08-21 00:59:45 +02:00
parent 3a95136ef1
commit 869dbc25be
11 changed files with 107 additions and 20 deletions
+7 -3
View File
@@ -53,7 +53,7 @@ import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
import { MegaWebFallback } from "./mega-web-fallback"; import { MegaWebFallback } from "./mega-web-fallback";
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { runInstallWithResume } from "./update-install-flow"; import { runInstallWithResume } from "./update-install-flow";
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
@@ -1382,8 +1382,12 @@ export class AppController {
} }
public removeHistoryEntry(entryId: string): void { public removeHistoryEntry(entryId: string): void {
this.audit("INFO", "Verlaufseintrag entfernt", { entryId }); this.removeHistoryEntries([entryId]);
removeHistoryEntry(this.storagePaths, entryId); }
public removeHistoryEntries(entryIds: string[]): void {
this.audit("INFO", "Verlaufseinträge entfernt", { count: entryIds.length });
removeHistoryEntries(this.storagePaths, entryIds, this.historyLimits());
} }
public addToHistory(entry: HistoryEntry): void { public addToHistory(entry: HistoryEntry): void {
+11
View File
@@ -97,6 +97,14 @@ function isDevMode(): boolean {
return process.env.NODE_ENV === "development"; return process.env.NODE_ENV === "development";
} }
function validateHistoryEntryIds(value: unknown): string[] {
const entryIds = validateStringArray(value, "entryIds");
if (entryIds.length > 100_000 || entryIds.some((entryId) => entryId.length === 0 || entryId.length > 4_096)) {
throw new Error("entryIds ist ungültig");
}
return [...new Set(entryIds)];
}
function getRendererFileUrl(): string { function getRendererFileUrl(): string {
return pathToFileURL(path.join(app.getAppPath(), "build", "renderer", "index.html")).toString(); return pathToFileURL(path.join(app.getAppPath(), "build", "renderer", "index.html")).toString();
} }
@@ -588,6 +596,9 @@ function registerIpcHandlers(): void {
validateString(entryId, "entryId"); validateString(entryId, "entryId");
return controller.removeHistoryEntry(entryId); return controller.removeHistoryEntry(entryId);
}); });
handleTrusted(IPC_CHANNELS.REMOVE_HISTORY_ENTRIES, (_event: IpcMainInvokeEvent, entryIds: unknown) => {
return controller.removeHistoryEntries(validateHistoryEntryIds(entryIds));
});
handleTrusted(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: unknown) => { handleTrusted(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: unknown) => {
return revealHistoryEntry({ entryId }, { return revealHistoryEntry({ entryId }, {
loadHistory: () => controller.getHistory(), loadHistory: () => controller.getHistory(),
+14 -4
View File
@@ -1546,13 +1546,23 @@ export function resetHistoryForRetention(paths: StoragePaths, retentionMode: His
clearHistory(paths); clearHistory(paths);
} }
export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] { export function removeHistoryEntries(paths: StoragePaths, entryIds: readonly string[], limits?: HistoryLimits): HistoryEntry[] {
const existing = loadHistory(paths); const existing = loadHistory(paths, limits);
const updated = existing.filter(e => e.id !== entryId); const removedIds = new Set(entryIds);
saveHistory(paths, updated); if (removedIds.size === 0) {
return existing;
}
const updated = existing.filter((entry) => !removedIds.has(entry.id));
if (updated.length !== existing.length) {
saveHistory(paths, updated, limits);
}
return updated; return updated;
} }
export function removeHistoryEntry(paths: StoragePaths, entryId: string, limits?: HistoryLimits): HistoryEntry[] {
return removeHistoryEntries(paths, [entryId], limits);
}
export function clearHistory(paths: StoragePaths): void { export function clearHistory(paths: StoragePaths): void {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
if (fs.existsSync(paths.historyFile)) { if (fs.existsSync(paths.historyFile)) {
+1
View File
@@ -124,6 +124,7 @@ const api: ElectronApi = {
}, },
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
removeHistoryEntries: (entryIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRIES, entryIds),
revealHistoryEntry: (entryId: string): Promise<HistoryRevealResult> => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, entryId), revealHistoryEntry: (entryId: string): Promise<HistoryRevealResult> => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, entryId),
setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority), setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
+4 -3
View File
@@ -3285,9 +3285,10 @@ export function App(): ReactElement {
if (!confirmed) { if (!confirmed) {
return; return;
} }
const results = await Promise.allSettled(ids.map((id) => window.rd.removeHistoryEntry(id))); try {
if (results.some((result) => result.status === "rejected")) { await window.rd.removeHistoryEntries(ids);
showToast("Einige Verlaufseinträge konnten nicht entfernt werden"); } catch {
showToast(ids.length === 1 ? "Verlaufseintrag konnte nicht entfernt werden" : "Verlaufseinträge konnten nicht entfernt werden");
await loadHistoryEntries(); await loadHistoryEntries();
return; return;
} }
+1
View File
@@ -82,6 +82,7 @@ export const IPC_CHANNELS = {
HISTORY_ENTRY_ADDED: "history:entry-added", HISTORY_ENTRY_ADDED: "history:entry-added",
CLEAR_HISTORY: "history:clear", CLEAR_HISTORY: "history:clear",
REMOVE_HISTORY_ENTRY: "history:remove-entry", REMOVE_HISTORY_ENTRY: "history:remove-entry",
REMOVE_HISTORY_ENTRIES: "history:remove-entries",
REVEAL_HISTORY_ENTRY: "history:reveal-entry", REVEAL_HISTORY_ENTRY: "history:reveal-entry",
SET_PACKAGE_PRIORITY: "queue:set-package-priority", SET_PACKAGE_PRIORITY: "queue:set-package-priority",
SKIP_ITEMS: "queue:skip-items", SKIP_ITEMS: "queue:skip-items",
+1
View File
@@ -145,6 +145,7 @@ export interface ElectronApi {
onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void) => () => void; onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void) => () => void;
clearHistory: () => Promise<void>; clearHistory: () => Promise<void>;
removeHistoryEntry: (entryId: string) => Promise<void>; removeHistoryEntry: (entryId: string) => Promise<void>;
removeHistoryEntries: (entryIds: string[]) => Promise<void>;
revealHistoryEntry: (entryId: string) => Promise<HistoryRevealResult>; revealHistoryEntry: (entryId: string) => Promise<HistoryRevealResult>;
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>; setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
skipItems: (itemIds: string[]) => Promise<void>; skipItems: (itemIds: string[]) => Promise<void>;
+11 -1
View File
@@ -6,6 +6,7 @@ import type { HistoryEntry } from "../src/shared/types";
const electron = vi.hoisted(() => ({ const electron = vi.hoisted(() => ({
api: undefined as ElectronApi | undefined, api: undefined as ElectronApi | undefined,
listeners: new Map<string, (...args: unknown[]) => void>(), listeners: new Map<string, (...args: unknown[]) => void>(),
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => undefined),
on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
electron.listeners.set(channel, listener); electron.listeners.set(channel, listener);
}), }),
@@ -23,7 +24,7 @@ vi.mock("electron", () => ({
} }
}, },
ipcRenderer: { ipcRenderer: {
invoke: vi.fn(), invoke: electron.invoke,
on: electron.on, on: electron.on,
removeListener: electron.removeListener, removeListener: electron.removeListener,
send: vi.fn() send: vi.fn()
@@ -48,4 +49,13 @@ describe("history preload contract", () => {
unsubscribe?.(); unsubscribe?.();
expect(electron.removeListener).toHaveBeenCalledWith(IPC_CHANNELS.HISTORY_ENTRY_ADDED, listener); expect(electron.removeListener).toHaveBeenCalledWith(IPC_CHANNELS.HISTORY_ENTRY_ADDED, listener);
}); });
it("forwards a history selection through one bulk removal channel", async () => {
electron.invoke.mockClear();
await electron.api?.removeHistoryEntries(["history-a", "history-b"]);
expect(electron.invoke).toHaveBeenCalledTimes(1);
expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.REMOVE_HISTORY_ENTRIES, ["history-a", "history-b"]);
});
}); });
+11
View File
@@ -640,6 +640,17 @@ describe("visual history states", () => {
expect(listener).not.toContain("getHistory()"); expect(listener).not.toContain("getHistory()");
}); });
it("removes the selected history ids through one backend call", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
const removalStart = source.indexOf("const removeHistoryEntries = useCallback");
const removalEnd = source.indexOf("const clearHistoryEntries", removalStart);
const removal = source.slice(removalStart, removalEnd);
expect(removal).toContain("window.rd.removeHistoryEntries(ids)");
expect(removal).not.toContain("Promise.allSettled");
expect(removal).not.toContain("window.rd.removeHistoryEntry(");
});
it("keeps bootstrap deterministic before exposing loading and error responses to the opened history view", async () => { it("keeps bootstrap deterministic before exposing loading and error responses to the opened history view", async () => {
const loadingApi = createVisualElectronApi(createVisualFixture("dense"), "?history-state=loading"); const loadingApi = createVisualElectronApi(createVisualFixture("dense"), "?history-state=loading");
await expect(loadingApi.getHistory()).resolves.toHaveLength(2); await expect(loadingApi.getHistory()).resolves.toHaveLength(2);
+34 -2
View File
@@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
@@ -9,7 +9,7 @@ import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../s
import { AppSettings } from "../src/shared/types"; import { AppSettings } from "../src/shared/types";
import { defaultSettings } from "../src/main/constants"; import { defaultSettings } from "../src/main/constants";
import { configureCredentialProtector } from "../src/main/credential-protection"; import { configureCredentialProtector } from "../src/main/credential-protection";
import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage"; import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
const tempDirs: string[] = []; const tempDirs: string[] = [];
type SettingsSaveMode = "sync" | "async"; type SettingsSaveMode = "sync" | "async";
@@ -31,6 +31,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
} }
@@ -1200,6 +1201,37 @@ describe("settings storage", () => {
})); }));
}); });
it("removes a history selection in one pass without applying the default 500-entry limit", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const limits = { maxEntries: 1_000, maxAgeDays: 0 };
const entries = Array.from({ length: 600 }, (_, index) => ({
id: `hist-${index}`,
name: `Paket ${index}`,
totalBytes: 1024,
downloadedBytes: 1024,
fileCount: 1,
provider: "realdebrid" as const,
completedAt: Date.now() - index,
durationSeconds: 12,
status: "completed" as const,
outputDir: path.join(dir, `out-${index}`),
urls: [`https://example.com/file-${index}.rar`]
}));
saveHistory(paths, entries, limits);
const renameSpy = vi.spyOn(fs, "renameSync");
renameSpy.mockClear();
const updated = removeHistoryEntries(paths, ["hist-0", "hist-599"], limits);
expect(renameSpy).toHaveBeenCalledTimes(1);
renameSpy.mockRestore();
expect(updated).toHaveLength(598);
expect(updated.some((entry) => entry.id === "hist-0" || entry.id === "hist-599")).toBe(false);
expect(loadHistory(paths, limits)).toHaveLength(598);
});
it("returns empty session when session file contains invalid JSON", () => { it("returns empty session when session file contains invalid JSON", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir); tempDirs.push(dir);
+5
View File
@@ -388,6 +388,11 @@ export function createVisualElectronApi(
fixture.history.splice(index, 1); fixture.history.splice(index, 1);
} }
}, },
removeHistoryEntries: async (entryIds) => {
const removed = new Set(entryIds);
const retained = fixture.history.filter((entry) => !removed.has(entry.id));
fixture.history.splice(0, fixture.history.length, ...retained);
},
revealHistoryEntry: async (entryId) => fixture.history.some((entry) => entry.id === entryId) revealHistoryEntry: async (entryId) => fixture.history.some((entry) => entry.id === entryId)
? { ok: true } ? { ok: true }
: { ok: false, reason: "entry-not-found" }, : { ok: false, reason: "entry-not-found" },