fix: make clipboard collector navigation opt-in

This commit is contained in:
Sucukdeluxe
2026-09-06 12:24:20 +02:00
parent bd11b57fb8
commit 8957aa3c23
17 changed files with 174 additions and 14 deletions
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { shouldFocusCollectorImport } from "../src/renderer/collector-navigation";
describe("collector import navigation", () => {
it("keeps automatic clipboard imports in the background unless explicitly enabled", () => {
expect(shouldFocusCollectorImport("clipboard", undefined)).toBe(false);
expect(shouldFocusCollectorImport("clipboard", false)).toBe(false);
expect(shouldFocusCollectorImport("clipboard", true)).toBe(true);
});
it("keeps explicit manual imports focused regardless of the automatic setting", () => {
for (const enabled of [undefined, false, true]) {
expect(shouldFocusCollectorImport("manual", enabled)).toBe(true);
}
});
});
+5
View File
@@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest";
import { normalizeLanguage, translateUiText } from "../src/renderer/i18n";
describe("renderer localization", () => {
it("translates the optional clipboard navigation setting", () => {
expect(translateUiText("Bei erkannten Zwischenablage-Links zum Linksammler wechseln", "en")).toBe("Switch to Link Collector when clipboard links are detected");
expect(translateUiText("Switch to Link Collector when clipboard links are detected", "de")).toBe("Bei erkannten Zwischenablage-Links zum Linksammler wechseln");
expect(translateUiText("Standardmäßig aus: Links werden im Hintergrund gesammelt, ohne den aktuellen Tab zu wechseln. Gilt nur bei aktivierter Zwischenablage-Überwachung.", "en")).toBe("Off by default: links are collected in the background without switching tabs. Applies only when clipboard monitoring is enabled.");
});
it("falls back to English", () => {
expect(normalizeLanguage(undefined)).toBe("en");
expect(normalizeLanguage("fr")).toBe("en");
+9
View File
@@ -4,6 +4,15 @@ import { createRendererSettings } from "../src/main/renderer-state";
import { validateRendererSettingsUpdate } from "../src/main/renderer-settings";
describe("renderer settings validation", () => {
it("projects and validates the optional clipboard tab switch as a boolean", () => {
const current = defaultSettings();
expect(createRendererSettings(current).switchToCollectorOnClipboard).toBe(false);
for (const enabled of [true, false]) {
expect(validateRendererSettingsUpdate({ switchToCollectorOnClipboard: enabled }, current)).toEqual({ switchToCollectorOnClipboard: enabled });
expect(createRendererSettings({ ...current, switchToCollectorOnClipboard: enabled }).switchToCollectorOnClipboard).toBe(enabled);
}
expect(() => validateRendererSettingsUpdate({ switchToCollectorOnClipboard: "true" }, current)).toThrow("Settings-Payload ist ungültig");
});
it("projects and validates startup work-directory creation", () => {
const current = { ...defaultSettings(), createWorkDirectoriesOnStartup: true };
const projected = createRendererSettings(current);
+2
View File
@@ -589,6 +589,8 @@ describe("settings views", () => {
scheduleSpeedInputs: {}
});
const language = form.groups.flatMap((group) => group.fields).find((field) => field.id === "language");
const clipboardSwitch = form.groups.flatMap((group) => group.fields).find((field) => field.id === "switchToCollectorOnClipboard");
expect(clipboardSwitch).toMatchObject({ kind: "switch", value: false, label: "Bei erkannten Zwischenablage-Links zum Linksammler wechseln" });
expect(language).toEqual({
id: "language",
+17
View File
@@ -12,6 +12,23 @@ import { configureCredentialProtector } from "../src/main/credential-protection"
import { acquirePersistenceBarrier, addHistoryEntryForRetention, clearHistory, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSessionWithStatus, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, replaceHistory, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage";
const tempDirs: string[] = [];
describe("automatic collector navigation settings", () => {
it("defaults new and existing installations to background collection", () => {
expect(defaultSettings().switchToCollectorOnClipboard).toBe(false);
expect(loadSettingsFrom({ clipboardWatch: true }).switchToCollectorOnClipboard).toBe(false);
expect(loadSettingsFrom({ switchToCollectorOnClipboard: "false" }).switchToCollectorOnClipboard).toBe(false);
});
it("persists opt-in and subsequent opt-out across reloads", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-collector-navigation-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
for (const enabled of [true, false]) {
saveSettings(paths, { ...defaultSettings(), switchToCollectorOnClipboard: enabled });
expect(loadSettings(paths).switchToCollectorOnClipboard).toBe(enabled);
}
});
});
type SettingsSaveMode = "sync" | "async";
async function saveSettingsInMode(mode: SettingsSaveMode, paths: ReturnType<typeof createStoragePaths>, settings: AppSettings): Promise<void> {
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="de">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Linksammler-Navigationsprüfung</title></head>
<body><div id="root"></div><script type="module" src="/collector-navigation.tsx"></script></body>
</html>
+81
View File
@@ -0,0 +1,81 @@
import { createRoot } from "react-dom/client";
import { App } from "../../src/renderer/App";
import type { ElectronApi } from "../../src/shared/preload-api";
import type { CollectorPersistenceState } from "../../src/shared/collector";
import { createVisualFixture } from "./fixtures";
import { createVisualElectronApi } from "./mock-electron-api";
import "../../src/renderer/theme.css";
import "../../src/renderer/styles.css";
const fixture = createVisualFixture("dense");
fixture.snapshot.settings.switchToCollectorOnClipboard = false;
let clipboardListener: ((links: string[]) => void) | undefined;
const stateListeners = new Set<Parameters<ElectronApi["onStateUpdate"]>[0]>();
let saved: CollectorPersistenceState = { packages: [], collapsedPackageIds: [] };
let preparedCount = 0;
let releasePreparation: (() => void) | undefined;
let delayPreparation = false;
window.rd = {
...createVisualElectronApi(fixture),
onClipboardDetected: (listener) => { clipboardListener = listener; return () => { clipboardListener = undefined; }; },
onStateUpdate: (listener) => { stateListeners.add(listener); return () => { stateListeners.delete(listener); }; },
updateSettings: async (settings) => {
Object.assign(fixture.snapshot.settings, settings);
fixture.snapshot.snapshotRevision = (fixture.snapshot.snapshotRevision ?? 0) + 1;
for (const listener of stateListeners) listener(structuredClone(fixture.snapshot));
return structuredClone(fixture.snapshot.settings);
},
prepareCollectorText: async ({ rawText, addedAt }) => {
const id = `navigation-${++preparedCount}`;
if (delayPreparation) await new Promise<void>((resolve) => { releasePreparation = resolve; });
return {
packages: [{ id, name: id, nameSource: "explicit", addedAt, links: [{ id: `${id}-link`, url: rawText, fileName: `${id}.rar`, fileSizeBytes: null, hoster: "example.test", availability: "unknown", status: "unknown", addedAt }] }],
invalidCount: 0, duplicateCount: 0
};
},
saveCollectorState: async (state) => { saved = structuredClone(state); return structuredClone(state); }
};
const report = document.createElement("output");
document.body.append(report);
createRoot(document.getElementById("root")!).render(<App />);
const waitFor = async (condition: () => boolean): Promise<void> => {
const deadline = Date.now() + 10000;
while (!condition()) {
if (Date.now() > deadline) throw new Error("Navigation check timed out");
await new Promise((resolve) => setTimeout(resolve, 50));
}
};
const activeView = (): string | undefined => document.querySelector<HTMLElement>("[data-visual-active-view]")?.dataset.visualActiveView;
const navigate = async (view: string): Promise<void> => {
document.querySelector<HTMLButtonElement>(`[data-main-view="${view}"]`)!.click();
await waitFor(() => activeView() === view);
};
const check = (condition: boolean, message: string): void => { if (!condition) throw new Error(message); };
async function verify(): Promise<void> {
await waitFor(() => Boolean(clipboardListener) && activeView() === "downloads");
clipboardListener!(["https://example.test/background.rar"]);
await waitFor(() => saved.packages.length === 1);
check(activeView() === "downloads", "Default changed the downloads tab");
await navigate("settings");
clipboardListener!(["https://example.test/settings.rar"]);
await waitFor(() => saved.packages.length === 2);
check(activeView() === "settings", "Background import interrupted settings");
await window.rd.updateSettings({ switchToCollectorOnClipboard: true });
await new Promise((resolve) => setTimeout(resolve, 900));
clipboardListener!(["https://example.test/enabled.rar"]);
await waitFor(() => saved.packages.length === 3 && activeView() === "collector");
await navigate("downloads");
delayPreparation = true;
clipboardListener!(["https://example.test/disabled-during-import.rar"]);
await waitFor(() => Boolean(releasePreparation));
await window.rd.updateSettings({ switchToCollectorOnClipboard: false });
await new Promise((resolve) => setTimeout(resolve, 900));
releasePreparation!();
await waitFor(() => saved.packages.length === 4);
check(activeView() === "downloads", "An in-flight import ignored disabling the setting");
report.textContent = JSON.stringify({ passed: true, checks: ["default keeps downloads", "background preserves settings", "opt-in opens collector", "opt-out during import prevents switch"], savedPackages: saved.packages.length });
}
void verify().catch((error) => { report.textContent = String(error); });
+1
View File
@@ -126,6 +126,7 @@ function createSettings(): AppSettings {
updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader",
autoUpdateCheck: true,
clipboardWatch: true,
switchToCollectorOnClipboard: false,
minimizeToTray: false,
theme: "dark",
themePreference: "dark",