feat: improve proxy account setup and backup portability

This commit is contained in:
Sucukdeluxe
2026-08-31 22:52:36 +02:00
parent 67dcc305ef
commit 486507d4b2
17 changed files with 618 additions and 63 deletions
+19
View File
@@ -7,6 +7,7 @@ import {
createAccountToggleQueue,
enqueueAccountToggleIntent,
filterAccountDialogOptions,
formatAccountOperationError,
getAvailableAccountOptions,
getAccountDialogSelectableOptions,
isAccountRowSelectionKey,
@@ -33,6 +34,24 @@ describe("account mode filter", () => {
});
});
describe("account operation errors", () => {
it("replaces wrapped IPC proxy markers with actionable messages", () => {
expect(formatAccountOperationError(
"Account konnte nicht gespeichert werden",
new Error("Error invoking remote method: proxy_only_account:proxy_list_missing")
)).toBe("Account konnte nicht gespeichert werden: Proxy-only ist aktiviert, aber es ist keine Proxy-Liste hinterlegt. Hinterlege sie unter Einstellungen → Geschwindigkeit.");
expect(formatAccountOperationError(
"Prüfung fehlgeschlagen",
"proxy_only_account:proxy_unreachable"
)).toContain("feste API-Proxy ist nicht erreichbar");
});
it("preserves non-proxy account failures", () => {
expect(formatAccountOperationError("Prüfung fehlgeschlagen", new Error("Ungültiger API-Key")))
.toBe("Prüfung fehlgeschlagen: Error: Ungültiger API-Key");
});
});
describe("account dialog filter", () => {
const options = [
{ id: "rd-api", serviceLabel: "Real-Debrid", title: "Real-Debrid API", modeLabel: "API", pickerDescription: "API-Token" },
+97
View File
@@ -7,6 +7,8 @@ import { defaultSettings } from "../src/main/constants";
import { configureCredentialProtector } from "../src/main/credential-protection";
import { prepareDailyStartSettingsPatch } from "../src/main/daily-start-scheduler";
import { logger } from "../src/main/logger";
import { getManagedOnlineProxyListPath } from "../src/main/online-proxy-list";
import { configureNetworkProxy } from "../src/main/network-proxy";
import { createStatisticsLedger, loadStatisticsLedger, saveStatisticsLedger } from "../src/main/statistics-ledger";
import { createStoragePaths, emptySession, loadHistory, loadSession, loadSettings, saveHistory, saveSession, saveSettings } from "../src/main/storage";
import type { CollectorPersistenceState } from "../src/shared/collector";
@@ -202,6 +204,7 @@ afterEach(async () => {
debugStorage.configError = null;
debugStorage.restartCalls = 0;
debugStorage.restartErrors = [];
configureNetworkProxy(defaultSettings());
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
@@ -431,7 +434,101 @@ describe("AppController full backup history restore", () => {
});
});
describe("AppController Proxy-only account validation", () => {
it("rejects account checks before network access when no proxy list is configured", async () => {
const controller = createController({
...defaultSettings(),
proxyDownloadEnabled: true,
proxyListPath: ""
}) as any;
controller.applyNetworkProxyConfiguration();
await expect(controller.checkAccountCredentials({
kind: "realdebrid-api",
secret: "test-token"
})).rejects.toThrow("proxy_only_account:proxy_list_missing");
});
});
describe("AppController settings-only backup transactions", () => {
it("restores an online proxy list to a managed local path", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = {
...currentSettings,
outputDir: "C:\\Imported",
proxyDownloadEnabled: true,
proxyListPath: "C:\\OtherServer\\proxy.txt",
proxyApiProxyIndex: 1
};
const controller = createController(currentSettings) as any;
controller.applyNetworkProxyConfiguration = vi.fn();
const content = "proxy-user:proxy-secret@192.0.2.10:8080\n";
const result = await controller.applySettingsOnlyBackup(importedSettings, undefined, false, content);
const managedPath = getManagedOnlineProxyListPath(controller.storagePaths.baseDir);
expect(result).toEqual({ proxyListRestored: true, proxyOnlyDisabled: false });
expect(fs.readFileSync(managedPath, "utf8")).toBe(content);
expect(controller.getSettings().proxyDownloadEnabled).toBe(true);
expect(controller.getSettings().proxyListPath).toBe(managedPath);
expect(loadSettings(controller.storagePaths).proxyListPath).toBe(managedPath);
});
it("disables Proxy-only when an older online backup has no embedded proxy list", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = {
...currentSettings,
outputDir: "C:\\Imported",
proxyDownloadEnabled: true,
proxyListPath: "C:\\OtherServer\\proxy.txt"
};
const controller = createController(currentSettings) as any;
controller.applyNetworkProxyConfiguration = vi.fn();
const result = await controller.applySettingsOnlyBackup(importedSettings, undefined, false, null);
expect(result).toEqual({ proxyListRestored: false, proxyOnlyDisabled: true });
expect(controller.getSettings().proxyDownloadEnabled).toBe(false);
expect(controller.getSettings().proxyListPath).toBe("");
});
it("restores the previous managed proxy file when online settings persistence fails", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported", proxyDownloadEnabled: true };
const controller = createController(currentSettings) as any;
controller.applyNetworkProxyConfiguration = vi.fn();
const managedPath = getManagedOnlineProxyListPath(controller.storagePaths.baseDir);
fs.writeFileSync(managedPath, "192.0.2.1:8080\n", "utf8");
bootStorage.settingsSaveError = new Error("online settings locked");
await expect(controller.applySettingsOnlyBackup(
importedSettings,
undefined,
false,
"198.51.100.1:3128\n"
)).rejects.toThrow("online settings locked");
expect(fs.readFileSync(managedPath, "utf8")).toBe("192.0.2.1:8080\n");
expect(controller.getSettings().outputDir).toBe("C:\\Current");
});
it("preserves local-backup proxy paths when no online proxy-list mode is supplied", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = {
...currentSettings,
outputDir: "C:\\Imported",
proxyDownloadEnabled: true,
proxyListPath: "C:\\LocalBackup\\proxy.txt"
};
const controller = createController(currentSettings) as any;
controller.applyNetworkProxyConfiguration = vi.fn();
await controller.applySettingsOnlyBackup(importedSettings);
expect(controller.getSettings().proxyDownloadEnabled).toBe(true);
expect(controller.getSettings().proxyListPath).toBe("C:\\LocalBackup\\proxy.txt");
});
it("applies runtime settings before releasing the successful import barrier", async () => {
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
+21
View File
@@ -28,6 +28,27 @@ describe("renderer localization", () => {
expect(translateUiText(german, "en")).toBe(english);
});
it.each([
["Proxy-only ist aktiviert, aber es ist keine Proxy-Liste hinterlegt. Hinterlege sie unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but no proxy list is configured. Add one under Settings → Speed."],
["Proxy-only ist aktiviert, aber die hinterlegte Proxy-Liste kann nicht gelesen werden. Prüfe die Datei unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but the configured proxy list cannot be read. Check the file under Settings → Speed."],
["Proxy-only ist aktiviert, aber die hinterlegte Proxy-Liste ist leer oder enthält keine gültigen HTTP-Proxys.", "Proxy-only is enabled, but the configured proxy list is empty or contains no valid HTTP proxies."],
["Proxy-only ist aktiviert, aber der feste API-Proxy ist in der Liste nicht verfügbar. Prüfe den Listeneintrag unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but the fixed API proxy is not available in the list. Check the list entry under Settings → Speed."],
["Proxy-only ist aktiviert, aber der feste API-Proxy ist nicht erreichbar oder lehnt die Verbindung ab. Prüfe den Proxy unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but the fixed API proxy is unreachable or refuses the connection. Check the proxy under Settings → Speed."]
])("translates Proxy-only account guidance %s", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials and the configured proxy list. Keep it as secure as a password."],
["Füge den vollständigen MDD2-Schlüssel ein. Einstellungen und eine enthaltene Proxy-Liste werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. Settings and any included proxy list will be replaced by the stored version."],
["Einstellungen und Proxy-Liste aus Online-Sicherung wiederhergestellt", "Settings and proxy list restored from online backup"],
["Einstellungen wiederhergestellt; Proxy-only wurde deaktiviert, weil die Online-Sicherung keine Proxy-Liste enthält", "Settings restored; Proxy-only was disabled because the online backup contains no proxy list"]
])("translates online proxy-list backup text %s", (german, english) => {
expect(translateUiText(german, "en")).toBe(english);
expect(translateUiText(english, "de")).toBe(german);
});
it.each([
["Erfolgsmeldungen senden", "Send success notifications"],
["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"],
+6 -1
View File
@@ -41,15 +41,19 @@ describe("online backup key", () => {
});
it("never places credentials or the decryption secret in the server record", () => {
const created = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z");
const proxyList = "proxy-user:proxy-secret@192.0.2.10:8080\n";
const created = createOnlineBackup(settings(), "2.0.0", "2026-08-07T00:00:00.000Z", proxyList);
const serialized = JSON.stringify(created.record);
const parsed = parseOnlineBackupKey(created.key);
expect(serialized).not.toContain("rd-secret-token");
expect(serialized).not.toContain("fixture-deepbrid-online-key-6jK8");
expect(serialized).not.toContain("backup-password");
expect(serialized).not.toContain("proxy-user");
expect(serialized).not.toContain("proxy-secret");
expect(serialized).not.toContain(parsed.masterKey.toString("base64url"));
expect(Object.keys(created.record).sort()).toEqual(["blob", "deleteVerifier", "id"]);
expect(restoreOnlineBackup(created.key, created.record.blob).proxyList).toEqual({ version: 1, content: proxyList });
});
it("rejects corrupted keys and encrypted payloads before returning settings", () => {
@@ -67,6 +71,7 @@ describe("online backup key", () => {
const oversized = { ...settings(), archivePasswordList: "x".repeat(600_000) };
expect(() => createOnlineBackup(oversized, "2.0.0")).toThrow(/zu groß/i);
expect(() => createOnlineBackup(settings(), "2.0.0", undefined, "invalid proxy line\n")).toThrow(/keine gültigen/i);
});
});
+61
View File
@@ -0,0 +1,61 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { captureOnlineProxyList, getManagedOnlineProxyListPath, MAX_ONLINE_PROXY_LIST_BYTES, writeImportedOnlineProxyList } from "../src/main/online-proxy-list";
const tempDirs: string[] = [];
function tempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-online-proxy-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
describe("online proxy list", () => {
it("captures and restores the exact proxy-list content into the managed runtime file", () => {
const dir = tempDir();
const source = path.join(dir, "premium.txt");
const content = "user:secret@192.0.2.10:8080\r\nhttp://198.51.100.2:3128\r\n";
fs.writeFileSync(source, content, "utf8");
const captured = captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: source });
const target = writeImportedOnlineProxyList(dir, captured as string);
expect(target).toBe(getManagedOnlineProxyListPath(dir));
expect(fs.readFileSync(target, "utf8")).toBe(content);
});
it("replaces a previously imported managed list", () => {
const dir = tempDir();
const first = writeImportedOnlineProxyList(dir, "192.0.2.1:8080\n");
const second = writeImportedOnlineProxyList(dir, "198.51.100.1:3128\n");
expect(second).toBe(first);
expect(fs.readFileSync(second, "utf8")).toBe("198.51.100.1:3128\n");
});
it("rejects missing, unreadable, empty, invalid and oversized configured lists", () => {
const dir = tempDir();
const empty = path.join(dir, "empty.txt");
const invalid = path.join(dir, "invalid.txt");
const oversized = path.join(dir, "oversized.txt");
fs.writeFileSync(empty, "", "utf8");
fs.writeFileSync(invalid, "not a proxy\n", "utf8");
fs.writeFileSync(oversized, Buffer.alloc(MAX_ONLINE_PROXY_LIST_BYTES + 1, 120));
expect(() => captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: "" })).toThrow(/keine Proxy-Liste/i);
expect(() => captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: path.join(dir, "missing.txt") })).toThrow(/nicht gelesen/i);
expect(() => captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: empty })).toThrow(/leer/i);
expect(() => captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: invalid })).toThrow(/keine gültigen/i);
expect(() => captureOnlineProxyList({ proxyDownloadEnabled: true, proxyListPath: oversized })).toThrow(/zu groß/i);
});
it("omits an unconfigured list while Proxy-only is disabled", () => {
expect(captureOnlineProxyList({ proxyDownloadEnabled: false, proxyListPath: "" })).toBeUndefined();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { createProxyOnlyAccountError, resolveProxyOnlyAccountErrorCode } from "../src/main/proxy-account-errors";
const enabledSettings = { proxyDownloadEnabled: true, proxyListPath: "C:\\proxy.txt" };
describe("Proxy-only account errors", () => {
it("distinguishes missing, unreadable, empty and unavailable fixed proxy settings", () => {
expect(resolveProxyOnlyAccountErrorCode(
{ proxyDownloadEnabled: true, proxyListPath: "" },
{ status: "blocked", reason: "proxy_file_unavailable" }
)).toBe("proxy_list_missing");
expect(resolveProxyOnlyAccountErrorCode(
enabledSettings,
{ status: "blocked", reason: "proxy_file_unavailable" }
)).toBe("proxy_list_unreadable");
expect(resolveProxyOnlyAccountErrorCode(
enabledSettings,
{ status: "blocked", reason: "no_valid_proxies" }
)).toBe("proxy_list_empty");
expect(resolveProxyOnlyAccountErrorCode(
enabledSettings,
{ status: "blocked", reason: "proxy_index_unavailable" }
)).toBe("proxy_index_unavailable");
});
it("maps only transport failures from an active fixed proxy", () => {
const active = { status: "active", selectedIndex: 1, proxyCount: 20 } as const;
expect(resolveProxyOnlyAccountErrorCode(enabledSettings, active, "Prüfung fehlgeschlagen: fetch failed"))
.toBe("proxy_unreachable");
expect(resolveProxyOnlyAccountErrorCode(enabledSettings, active, "Prüfung fehlgeschlagen: The operation was aborted due to timeout"))
.toBe("proxy_unreachable");
expect(resolveProxyOnlyAccountErrorCode(enabledSettings, active, "Prüfung fehlgeschlagen (HTTP 407)"))
.toBe("proxy_unreachable");
expect(resolveProxyOnlyAccountErrorCode(enabledSettings, active, "Prüfung fehlgeschlagen (HTTP 503)"))
.toBeNull();
expect(resolveProxyOnlyAccountErrorCode(enabledSettings, active, "Ungültiger API-Token"))
.toBeNull();
});
it("does not classify errors while Proxy-only is disabled", () => {
expect(resolveProxyOnlyAccountErrorCode(
{ proxyDownloadEnabled: false, proxyListPath: "" },
{ status: "disabled" },
"fetch failed"
)).toBeNull();
expect(createProxyOnlyAccountError("proxy_list_missing").message).toBe("proxy_only_account:proxy_list_missing");
});
});
+29
View File
@@ -95,3 +95,32 @@ describe("global Escape selection routing", () => {
expect(api.releaseAccountSelectionFocus?.(null)).toBe(false);
});
});
describe("global Ctrl+A selection routing", () => {
it("routes the account overview to account selection and preserves text editing", () => {
const api = selection as typeof selection & {
resolveSelectAllSelectionScope?: (
view: string,
settingsSection: string,
accountPanel: string,
tagName: string,
inputType?: string
) => string | null;
};
expect(api.resolveSelectAllSelectionScope).toBeTypeOf("function");
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "overview", "BODY")).toBe("accounts");
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "overview", "INPUT", "checkbox")).toBe("accounts");
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "rules", "BODY")).toBeNull();
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "runtime", "BODY")).toBeNull();
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "overview", "INPUT", "text")).toBeNull();
expect(api.resolveSelectAllSelectionScope?.("settings", "accounts", "overview", "TEXTAREA")).toBeNull();
});
it("preserves the existing shortcut scopes outside account management", () => {
expect(selection.resolveSelectAllSelectionScope("downloads", "allgemein", "overview", "BODY")).toBe("downloads");
expect(selection.resolveSelectAllSelectionScope("collector", "allgemein", "overview", "BODY")).toBe("collector");
expect(selection.resolveSelectAllSelectionScope("history", "allgemein", "overview", "BODY")).toBe("history");
expect(selection.resolveSelectAllSelectionScope("downloads", "allgemein", "overview", "INPUT", "checkbox")).toBeNull();
});
});
+9
View File
@@ -1167,6 +1167,15 @@ describe("account workspace", () => {
expect(html).toContain(" Entfernen (3)");
});
it("selects every visible account row from the global Ctrl+A shortcut", () => {
const shortcutBlock = sourceBlock(appSource, "if (!e.shiftKey && e.key.toLowerCase() === \"a\")", "return;\n }");
expect(shortcutBlock).toContain("resolveSelectAllSelectionScope");
expect(shortcutBlock).toContain("settingsSubTabRef.current");
expect(shortcutBlock).toContain("accountManagementTabRef.current");
expect(shortcutBlock).toContain("setSelectedAccountRowKeys(new Set(visibleAccountRowKeysRef.current))");
});
it("removes the redundant global account activation switch", () => {
const legacyActions = { ...workspaceActions(), onSetAllEnabled: () => {} } as AccountWorkspaceActions;
const legacyModel = { ...workspaceModel(), allEnabled: true } as AccountWorkspaceViewModel;