diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index 5c6a085..b8b82bd 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -10,8 +10,8 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält - Verifiziert am: 31. August 2026, Europe/Berlin - Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader` -- Arbeitsbranch: `release/v2.0.80` -- Quellbasis: `release/v2.0.79` +- Arbeitsbranch: `fix/account-select-all` +- Quellbasis: `release/v2.0.80` - Release-Tag: `v2.0.80` - Baseline-Commit: `1628f23023c93efcb222c63913f9d2508a9a5e6c` - Hotfix-Basis: `5cee459d0bcc5c19d8d6483948a036a43c9d91b1` @@ -52,7 +52,7 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält - Zugangsdaten bleiben im Main-Prozess und werden für Renderer-Snapshots projiziert beziehungsweise redigiert. - Der Updater lädt ausschließlich GitHub-Release-Assets, prüft EXE-Form und SHA-256/SHA-512 und verweigert standardmäßig Assets ohne gültigen Digest. - Lokale Voll-Backups verwenden scrypt und AES-256-GCM mit authentifiziertem Header; Legacy-MDD1 kann weiterhin gelesen werden. -- Online-Sicherungen sind `settings-only`, clientseitig mit AES-256-GCM verschlüsselt und durch einen zufälligen `MDD2-`-Schlüssel geschützt. Der Server erhält weder Master-Key noch Löschgeheimnis im Klartext. +- Online-Sicherungen sind `settings-only`, clientseitig mit AES-256-GCM verschlüsselt und durch einen zufälligen `MDD2-`-Schlüssel geschützt. Eine konfigurierte Proxy-Liste wird innerhalb desselben verschlüsselten Nutzdatenblocks gesichert; der Server erhält weder Einstellungen, Proxy-Zugangsdaten, Master-Key noch Löschgeheimnis im Klartext. ### Zustands- und Persistenzmodell @@ -163,6 +163,17 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält - Der Statistikpunkt „Fehler zurücksetzen“ verwendet dieselbe Fehlerklassifizierung und erfasst dadurch neben Downloadfehlern auch sichtbare Entpackfehler. - Entpack-, Passwort- und Nachbearbeitungslogik wurden nicht verändert. `v2.0.80` wurde nach ausdrücklicher Freigabe auf GitHub und Forgejo veröffentlicht; Installation und produktiver Neustart sind nicht Bestandteil des Releases. +## Unveröffentlichte Änderungen nach v2.0.80 + +- `Strg+A` markiert unter Einstellungen → Accounts → Übersicht alle aktuell sichtbaren Accountzeilen. Die bestehende Mehrfachauswahl wird dabei vollständig durch die sichtbare Liste ersetzt. +- Der Shortcut berücksichtigt den aktuellen Einstellungsbereich und den aktiven Account-Unterbereich über Live-Refs, damit der globale Tastaturhandler keinen veralteten Renderzustand verwendet. +- Fokussierte Account-Schalter erlauben den Shortcut; Textfelder und Textbereiche behalten ihr natives „Alles markieren“. Downloads, Linksammler und Verlauf behalten ihre bisherigen `Strg+A`-Bereiche. +- Account-Anlage, Einzel-/Sammelprüfung und Web-Login melden bei aktivem Proxy-only jetzt gezielt, ob keine Proxy-Liste hinterlegt ist, die Datei nicht lesbar beziehungsweise leer/ungültig ist, der feste 1-basierte Listeneintrag fehlt oder der aktive Proxy die Netzwerkverbindung nicht herstellen kann. Normale HTTP- und Zugangsdatenfehler bleiben davon getrennt. +- Online-Sicherungen nehmen den exakten Inhalt einer konfigurierten Proxy-Liste in den bereits clientseitig AES-256-GCM-verschlüsselten MDD2-Datensatz auf. Der Online-Dienst erhält weiterhin ausschließlich den authentifiziert verschlüsselten Blob und keine Proxy-Zugangsdaten im Klartext. +- Beim Import wird die Liste atomar in eine verwaltete Datei im Runtime-Verzeichnis geschrieben und der lokale Pfad darauf umgestellt. Ein Schreib- oder Settingsfehler stellt sowohl die vorherige Datei als auch die vorherigen Einstellungen wieder her. +- Ältere Online-Sicherungen ohne eingebettete Liste bleiben importierbar. Enthalten sie einen fremden Proxy-Pfad beziehungsweise aktiviertes Proxy-only, wird Proxy-only beim Import sicher deaktiviert, statt alle Netzwerkanfragen mit einem nicht portablen Pfad zu blockieren. Lokale Backup-Importe behalten ihre bisherige Pfadsemantik. +- Die Änderungen sind auf `fix/account-select-all` umgesetzt und für `v2.0.81` freigegeben, aber noch nicht veröffentlicht. Entpack-, Passwort- und Nachbearbeitungslogik wurden nicht verändert. + ## Start-, Build- und Testbefehle ```powershell @@ -194,6 +205,9 @@ npm exec -- tsc --noEmit ## Verifizierungen vom 31. August 2026 +- Kandidat für `v2.0.81`: 382 von 382 fokussierte Account-, Proxy-, Online-Backup-, Import-Transaktions-, Auswahl-, Übersetzungs- und Einstellungsansichtstests erfolgreich. Die Tests beweisen insbesondere, dass Proxy-Zugangsdaten nicht im Server-Record stehen, der verschlüsselte Roundtrip den exakten Listeninhalt erhält, ein fehlgeschlagener Import die vorherige verwaltete Liste wiederherstellt und alte Online-Sicherungen Proxy-only ohne eingebettete Liste deaktivieren. +- Vollständiger Clientlauf des Kandidaten: 142 Testdateien erfolgreich, 1 optionale JVM-Testdatei übersprungen; 2.718 Tests erfolgreich und 4 übersprungen. Nur die zwei bekannten Windows-Symlink-Fixtures endeten mangels Berechtigung vor ihrer Produktassertion mit `EPERM`. Zusätzlich erfolgreich: TypeScript, Main-Build, Renderer-Build, Node-Self-Check und 16 von 16 Backup-API-Tests. Die bekannte Vite-Warnung betrifft nun den rund 580 KiB großen Renderer-Chunk. +- Account-`Strg+A` nach `v2.0.80`: 358 von 358 Auswahl-, Account-, Einstellungs-, Download-, Linksammler- und Verlaufstests erfolgreich. TypeScript und der Produktions-Renderer-Build sind ebenfalls erfolgreich; die bekannte Vite-Warnung zum rund 577 KiB großen Renderer-Chunk bleibt bestehen. - Veröffentlichung `v2.0.80`: Der annotierte Tag zeigt auf beiden Git-Remotes exakt auf `1628f23023c93efcb222c63913f9d2508a9a5e6c`. GitHub und Forgejo veröffentlichen denselben stabilen Release mit denselben sechs Assets. Alle zwölf erneut heruntergeladenen Dateien stimmen jeweils in Größe und SHA-256 exakt mit den lokalen Originalen überein; GitHub führt `v2.0.80` als Latest Release. - Release-Build `v2.0.80`: Installer und Portable-Datei wurden aus dem versionierten Release-Vorbereitungsstand neu erzeugt. Die Release-Prüfung bestätigte Paket- und Bundle-Version, Update-Metadaten, Artefaktnamen, Dateigröße und SHA-512 des Installers, Icon, Lizenzdateien sowie den entpackten Inhalt beider EXE-Archive. Von den 24 Public-Release-Metadatentests waren 22 erfolgreich; nur die zwei unter Windows ohne Symlink-Berechtigung nicht ausführbaren Fixtures endeten vor ihrer Produktassertion mit `EPERM`. - SHA-256 des `v2.0.80`-Setups: `a841d4b52962d52e335fd7f8c2f1fcda0b4f642a66a0101db15665326f6d332a`; SHA-256 der Portable-Datei: `1a5bd12c13e33b620c83958ef7d7e376d028cee32a6f8eb5ff123216bbd01012`. @@ -278,11 +292,14 @@ npm exec -- tsc --noEmit - Der 32/40-Proxybereich und der synchrone Live-Takt von „Sitzung“ und „Verbleibend“ sind als `v2.0.78` veröffentlicht. Eine Serverinstallation oder ein produktiver Neustart wurde nicht vorgenommen. - Der Proxy-Readback- und Nullbereich-Hotfix ist als `v2.0.79` auf GitHub und Forgejo veröffentlicht, aber noch nicht auf einem produktiven System installiert oder gestartet. - Die einheitliche Geschwindigkeitsanzeige und die getrennten Paket-Reset-Optionen sind als `v2.0.80` auf GitHub und Forgejo veröffentlicht, aber noch nicht auf einem produktiven System installiert oder gestartet. +- Die Account-Übersicht unterstützt `Strg+A`; Proxy-only-Accountfehler werden verständlich aufgelöst und Online-Sicherungen können die Proxy-Liste verschlüsselt portieren. Der Kandidat ist noch nicht veröffentlicht oder installiert. ## Nächste sinnvolle Schritte -1. Nach einer getrennt freigegebenen Serverinstallation die einheitliche Geschwindigkeitsanzeige und den selektiven Paket-Reset am echten Queue-Fall prüfen. -2. Nach einer getrennt freigegebenen Serverinstallation den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern. -3. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen. -4. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe. -5. Optional Developer Mode für lokale Symlink-Tests bereitstellen oder die Test-Fixtures plattformgerecht ohne privilegierte Symlinks gestalten. +1. Den vollständig geprüften und freigegebenen Kandidaten als `v2.0.81` bauen, auf GitHub und Forgejo veröffentlichen und die Assets beider Plattformen gegeneinander verifizieren. +2. Nach einer getrennt freigegebenen Serverinstallation die Account-Fehlerführung, den verschlüsselten Proxy-Listen-Import und `Strg+A` am echten Zielsystem prüfen. +3. Nach einer getrennt freigegebenen Serverinstallation die einheitliche Geschwindigkeitsanzeige und den selektiven Paket-Reset am echten Queue-Fall prüfen. +4. Nach einer getrennt freigegebenen Serverinstallation den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern. +5. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen. +6. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe. +7. Optional Developer Mode für lokale Symlink-Tests bereitstellen oder die Test-Fixtures plattformgerecht ohne privilegierte Symlinks gestalten. diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index bae2f53..825f0a1 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -81,6 +81,7 @@ import { CollectorStore } from "./collector-store"; import type { CollectorPersistenceState } from "../shared/collector"; import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup"; +import { captureOnlineProxyList, getManagedOnlineProxyListPath, writeImportedOnlineProxyList } from "./online-proxy-list"; import { overlayLiveUsageCounters } from "./settings-live-overlay"; import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage"; import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger"; @@ -88,7 +89,8 @@ import { NotificationOutbox } from "./notification-outbox"; import { sendNotification } from "./notify"; import { DownloadHealthMonitor } from "./download-health-monitor"; import { shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler"; -import { configureNetworkProxy, shutdownNetworkProxy } from "./network-proxy"; +import { configureNetworkProxy, getNetworkProxyState, shutdownNetworkProxy } from "./network-proxy"; +import { createProxyOnlyAccountError, resolveProxyOnlyAccountErrorCode } from "./proxy-account-errors"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -507,10 +509,20 @@ export class AppController { return getDebugSetupCheck(this.storagePaths.baseDir); } - private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record): void { - logAuditEvent(level, message, fields); - logTraceEvent(level, "audit", message, fields); - } + private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record): void { + logAuditEvent(level, message, fields); + logTraceEvent(level, "audit", message, fields); + } + + private assertProxyOnlyAccountSetup(): void { + const code = resolveProxyOnlyAccountErrorCode(this.settings, getNetworkProxyState()); + if (code) throw createProxyOnlyAccountError(code); + } + + private throwProxyOnlyAccountFailure(failureText: string): void { + const code = resolveProxyOnlyAccountErrorCode(this.settings, getNetworkProxyState(), failureText); + if (code) throw createProxyOnlyAccountError(code); + } public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig { const next = setTraceEnabled(enabled, note, durationMs); @@ -545,7 +557,7 @@ export class AppController { : normalizeSettings({ ...restoredSettings, logStorageLocation: this.settings.logStorageLocation }); } - private createBackupImportRollback(includeDownloads: boolean): () => void { + private createBackupImportRollback(includeDownloads: boolean, additionalFiles: readonly string[] = []): () => void { const baseDir = this.storagePaths.baseDir; const files = [ this.storagePaths.configFile, @@ -554,7 +566,8 @@ export class AppController { `${this.storagePaths.configFile}.bak.tmp`, path.join(baseDir, "debug_host.txt"), path.join(baseDir, "debug_port.txt"), - path.join(baseDir, "debug_allowlist.txt") + path.join(baseDir, "debug_allowlist.txt"), + ...additionalFiles ]; if (includeDownloads) { files.push( @@ -570,7 +583,7 @@ export class AppController { `${this.storagePaths.statisticsFile}.tmp` ); } - return createFileRollback(files); + return createFileRollback([...new Set(files)]); } private rollbackImportPersistence(rollback: () => void, context: string): void { @@ -589,7 +602,12 @@ export class AppController { } } - private async applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): Promise { + private async applySettingsOnlyBackup( + importedSettings: AppSettings, + remoteDiagnostics?: unknown, + restoreRemoteDiagnostics = false, + onlineProxyListContent?: string | null + ): Promise<{ proxyListRestored: boolean; proxyOnlyDisabled: boolean }> { const barrier = await acquirePersistenceBarrier(); const previousSettings = this.settings; let restoredSettings: AppSettings | null = null; @@ -597,10 +615,32 @@ export class AppController { let remoteRestore: ReturnType = null; let logStorageChanged = false; let runtimeApplied = false; + let proxyListRestored = false; + let proxyOnlyDisabled = false; try { restoredSettings = this.prepareImportedLogStorage(normalizeSettings(importedSettings)); + if (typeof onlineProxyListContent === "string") { + restoredSettings = normalizeSettings({ + ...restoredSettings, + proxyListPath: getManagedOnlineProxyListPath(this.storagePaths.baseDir) + }); + } else if (onlineProxyListContent === null) { + proxyOnlyDisabled = restoredSettings.proxyDownloadEnabled || Boolean(restoredSettings.proxyListPath.trim()); + restoredSettings = normalizeSettings({ + ...restoredSettings, + proxyDownloadEnabled: false, + proxyListPath: "" + }); + } this.overlayLiveUsageCounters(restoredSettings); - rollback = this.createBackupImportRollback(false); + const managedProxyListPath = typeof onlineProxyListContent === "string" + ? getManagedOnlineProxyListPath(this.storagePaths.baseDir) + : null; + rollback = this.createBackupImportRollback(false, managedProxyListPath ? [managedProxyListPath] : []); + if (typeof onlineProxyListContent === "string") { + writeImportedOnlineProxyList(this.storagePaths.baseDir, onlineProxyListContent); + proxyListRestored = true; + } saveSettings(this.storagePaths, restoredSettings); remoteRestore = restoreRemoteDiagnostics ? this.persistRemoteDiagnosticsFromBackup(remoteDiagnostics) @@ -651,6 +691,7 @@ export class AppController { throw error; } this.auditRemoteDiagnosticsRestore(remoteRestore, restoreRemoteDiagnostics); + return { proxyListRestored, proxyOnlyDisabled }; } public updateSettings(partial: Partial): AppSettings { @@ -720,6 +761,9 @@ export class AppController { const applied = applyAccountCommand(this.settings, command); let checkedStatus: DebridAccountStatus | null = null; const redactions = collectAccountStatusRedactionValues(applied.settings, command); + if (command.action !== "delete" && ["megadebrid-api", "megadebrid-web", "debridlink-api", "realdebrid-api", "deepbrid-api"].includes(command.kind)) { + this.assertProxyOnlyAccountSetup(); + } if (command.action !== "delete" && applied.response.accountId && (command.kind === "megadebrid-api" || command.kind === "megadebrid-web")) { const mode = command.kind === "megadebrid-web" ? "web" : "api"; const account = getMegaDebridAccountsForMode(applied.settings, mode).find((entry) => entry.id === applied.response.accountId); @@ -743,6 +787,7 @@ export class AppController { checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions); } if (checkedStatus && !checkedStatus.valid) { + this.throwProxyOnlyAccountFailure(checkedStatus.message || ""); throw new Error(checkedStatus.message || "Zugangsdaten ungültig"); } this.updateSettings(applied.settings); @@ -766,6 +811,7 @@ export class AppController { } public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise { + this.assertProxyOnlyAccountSetup(); const redactions = collectAccountStatusRedactionValues(this.settings, input); if (input.kind === "deepbrid-api") { const key = input.secret?.trim() || this.settings.deepbridApiKey.trim(); @@ -774,6 +820,7 @@ export class AppController { if (!input.secret && input.accountId === "svc-deepbrid" && this.settings.deepbridApiKey.trim()) { this.manager.applyDebridAccountStatuses([status]); } + if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || ""); return status; } if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") { @@ -802,6 +849,7 @@ export class AppController { if (!input.secret && getRealDebridAccounts(this.settings).some((entry) => entry.id === status.accountId)) { this.manager.applyDebridAccountStatuses([status]); } + if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || ""); return status; } if (input.kind === "megadebrid-api" || input.kind === "megadebrid-web") { @@ -814,6 +862,7 @@ export class AppController { if (!input.secret && getMegaDebridAccountsForMode(this.settings, mode).some((entry) => entry.id === status.accountId)) { this.manager.applyDebridAccountStatuses([status]); } + if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || ""); return status; } const key = input.secret?.trim() @@ -824,6 +873,7 @@ export class AppController { if (!input.secret && parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).some((entry) => entry.id === status.accountId)) { this.manager.applyDebridAccountStatuses([status]); } + if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || ""); return status; } @@ -965,6 +1015,7 @@ export class AppController { } public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise { + this.assertProxyOnlyAccountSetup(); const accountId = String(request.accountId || "").trim(); if (!isRealDebridWebAccountId(accountId)) { throw new Error("Account-Payload ist ungültig"); @@ -1060,8 +1111,9 @@ export class AppController { } } - public async openAllDebridLoginWindow(): Promise { - this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); + public async openAllDebridLoginWindow(): Promise { + this.assertProxyOnlyAccountSetup(); + this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); await this.allDebridWebFallback.openLoginWindow(); } @@ -1090,6 +1142,7 @@ export class AppController { } public async checkDebridAccounts(scope: AccountCheckScope = "active"): Promise { + this.assertProxyOnlyAccountSetup(); const checkedStatuses = sanitizeDebridAccountStatuses( await checkAllDebridAccounts( this.settings, @@ -1100,6 +1153,12 @@ export class AppController { collectAccountStatusRedactionValues(this.settings) ); const statuses = retainConfiguredRealDebridStatuses(this.settings, checkedStatuses); + const proxyFailure = statuses.find((status) => !status.valid && resolveProxyOnlyAccountErrorCode( + this.settings, + getNetworkProxyState(), + status.message || "" + )); + if (proxyFailure) this.throwProxyOnlyAccountFailure(proxyFailure.message || ""); this.manager.applyDebridAccountStatuses(statuses); this.audit("INFO", "Debrid-Accounts geprueft", { total: statuses.length, @@ -1357,20 +1416,28 @@ export class AppController { } public async exportOnlineBackup(): Promise<{ key: string }> { - const created = createOnlineBackup({ ...this.settings }, APP_VERSION); + const proxyListContent = captureOnlineProxyList(this.settings); + const created = createOnlineBackup({ ...this.settings }, APP_VERSION, undefined, proxyListContent); await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL); - this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only" }); + this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only", proxyListIncluded: proxyListContent !== undefined }); return { key: created.key }; } public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> { const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL); - await this.applySettingsOnlyBackup(payload.settings); + const result = await this.applySettingsOnlyBackup(payload.settings, undefined, false, payload.proxyList?.content ?? null); this.audit("INFO", "Online-Sicherung importiert", { kind: "settings-only", + proxyListRestored: result.proxyListRestored, + proxyOnlyDisabled: result.proxyOnlyDisabled, accountSummary: buildAccountSummary(this.settings) }); - return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" }; + const message = result.proxyListRestored + ? "Einstellungen und Proxy-Liste aus Online-Sicherung wiederhergestellt" + : result.proxyOnlyDisabled + ? "Einstellungen wiederhergestellt; Proxy-only wurde deaktiviert, weil die Online-Sicherung keine Proxy-Liste enthält" + : "Einstellungen aus Online-Sicherung wiederhergestellt"; + return { restored: true, relaunch: false, message }; } public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> { diff --git a/src/main/online-backup.ts b/src/main/online-backup.ts index 6dfc213..9050105 100644 --- a/src/main/online-backup.ts +++ b/src/main/online-backup.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import zlib from "node:zlib"; import type { AppSettings } from "../shared/types"; +import { MAX_ONLINE_PROXY_LIST_BYTES, validateOnlineProxyListContent } from "./online-proxy-list"; const KEY_PREFIX = "MDD2-"; const KEY_BODY_LENGTH = 70; @@ -12,7 +13,8 @@ const AUTH_TAG_LENGTH = 16; const BLOB_VERSION = 1; const MAX_BLOB_BYTES = 256 * 1024; const MAX_RESPONSE_BYTES = 512 * 1024; -const MAX_PLAINTEXT_BYTES = 512 * 1024; +const MAX_SETTINGS_PLAINTEXT_BYTES = 512 * 1024; +const MAX_PLAINTEXT_BYTES = 9 * 1024 * 1024; const REQUEST_TIMEOUT_MS = 12_000; const KEY_CONTEXT = Buffer.from("MDD2-ONLINE-KEY-V1", "utf8"); const AAD_CONTEXT = Buffer.from("MDD-ONLINE-BACKUP-V1", "utf8"); @@ -23,6 +25,10 @@ export interface OnlineSettingsPayload { appVersion: string; exportedAt: string; settings: AppSettings; + proxyList?: { + version: 1; + content: string; + }; } export interface OnlineBackupRecord { @@ -81,6 +87,13 @@ function validatePayload(value: unknown): OnlineSettingsPayload { ) { throw new Error("Online-Sicherung enthält keine gültigen Einstellungen"); } + if (record.proxyList !== undefined) { + const proxyList = record.proxyList as Record | null; + if (!proxyList || Array.isArray(proxyList) || proxyList.version !== 1 || typeof proxyList.content !== "string" + || Buffer.byteLength(proxyList.content, "utf8") > MAX_ONLINE_PROXY_LIST_BYTES) { + throw new Error("Online-Sicherung enthält keine gültigen Einstellungen"); + } + } return record as unknown as OnlineSettingsPayload; } @@ -152,22 +165,35 @@ export function parseOnlineBackupKey(key: string): ParsedOnlineBackupKey { return { id: idBytes.toString("base64url"), idBytes: Buffer.from(idBytes), masterKey: Buffer.from(masterKey) }; } -export function createOnlineBackup(settings: AppSettings, appVersion: string, exportedAt = new Date().toISOString()): CreatedOnlineBackup { +export function createOnlineBackup( + settings: AppSettings, + appVersion: string, + exportedAt = new Date().toISOString(), + proxyListContent?: string +): CreatedOnlineBackup { + if (proxyListContent !== undefined) validateOnlineProxyListContent(proxyListContent); const idBytes = crypto.randomBytes(RECORD_ID_LENGTH); const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH); const key = encodeKey(idBytes, masterKey); const encryptionKey = deriveSecret(masterKey, idBytes, "ENCRYPTION"); const nonce = crypto.randomBytes(NONCE_LENGTH); + const settingsSnapshot = JSON.parse(JSON.stringify(settings)) as AppSettings; + if (Buffer.byteLength(JSON.stringify(settingsSnapshot), "utf8") > MAX_SETTINGS_PLAINTEXT_BYTES) { + throw new Error("Einstellungen sind für eine Online-Sicherung zu groß"); + } const payload: OnlineSettingsPayload = { version: 1, kind: "settings-only", appVersion, exportedAt, - settings: JSON.parse(JSON.stringify(settings)) as AppSettings + settings: settingsSnapshot, + ...(proxyListContent === undefined ? {} : { proxyList: { version: 1 as const, content: proxyListContent } }) }; const plaintext = Buffer.from(JSON.stringify(payload), "utf8"); if (plaintext.length > MAX_PLAINTEXT_BYTES) { - throw new Error("Einstellungen sind für eine Online-Sicherung zu groß"); + throw new Error(proxyListContent === undefined + ? "Einstellungen sind für eine Online-Sicherung zu groß" + : "Einstellungen und Proxy-Liste sind für eine Online-Sicherung zu groß"); } const compressed = zlib.gzipSync(plaintext, { level: 9 }); const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH }); @@ -175,7 +201,9 @@ export function createOnlineBackup(settings: AppSettings, appVersion: string, ex const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]); const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]); if (blobBytes.length > MAX_BLOB_BYTES) { - throw new Error("Einstellungen sind für eine Online-Sicherung zu groß"); + throw new Error(proxyListContent === undefined + ? "Einstellungen sind für eine Online-Sicherung zu groß" + : "Einstellungen und Proxy-Liste sind für eine Online-Sicherung zu groß"); } const parsed = parseOnlineBackupKey(key); const deleteVerifier = crypto.createHash("sha256").update(deriveDeleteSecret(parsed)).digest("base64url"); diff --git a/src/main/online-proxy-list.ts b/src/main/online-proxy-list.ts new file mode 100644 index 0000000..2450958 --- /dev/null +++ b/src/main/online-proxy-list.ts @@ -0,0 +1,67 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { AppSettings } from "../shared/types"; +import { parseProxyList } from "./proxy-segmented-download"; + +export const MAX_ONLINE_PROXY_LIST_BYTES = 8 * 1024 * 1024; +const MANAGED_PROXY_LIST_FILE = "proxy-list-online-backup.txt"; + +export function validateOnlineProxyListContent(content: string): void { + const size = Buffer.byteLength(content, "utf8"); + if (size <= 0) throw new Error("Die Proxy-Liste ist leer"); + if (size > MAX_ONLINE_PROXY_LIST_BYTES) throw new Error("Die Proxy-Liste ist für eine Online-Sicherung zu groß"); + if (parseProxyList(content) <= 0) throw new Error("Die Proxy-Liste enthält keine gültigen HTTP-Proxys"); +} + +export function captureOnlineProxyList(settings: Pick): string | undefined { + const filePath = String(settings.proxyListPath || "").trim(); + if (!filePath) { + if (settings.proxyDownloadEnabled) throw new Error("Proxy-only ist aktiviert, aber es ist keine Proxy-Liste hinterlegt"); + return undefined; + } + let stat: fs.Stats; + try { + stat = fs.statSync(path.resolve(filePath)); + } catch { + throw new Error("Die hinterlegte Proxy-Liste kann nicht gelesen werden"); + } + if (!stat.isFile()) throw new Error("Die hinterlegte Proxy-Liste kann nicht gelesen werden"); + if (stat.size <= 0) throw new Error("Die Proxy-Liste ist leer"); + if (stat.size > MAX_ONLINE_PROXY_LIST_BYTES) throw new Error("Die Proxy-Liste ist für eine Online-Sicherung zu groß"); + let content: string; + try { + content = fs.readFileSync(path.resolve(filePath), "utf8"); + } catch { + throw new Error("Die hinterlegte Proxy-Liste kann nicht gelesen werden"); + } + validateOnlineProxyListContent(content); + return content; +} + +export function getManagedOnlineProxyListPath(baseDir: string): string { + return path.join(baseDir, MANAGED_PROXY_LIST_FILE); +} + +export function writeImportedOnlineProxyList(baseDir: string, content: string): string { + validateOnlineProxyListContent(content); + const filePath = getManagedOnlineProxyListPath(baseDir); + const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + let descriptor: number | null = null; + try { + descriptor = fs.openSync(tempPath, "wx", 0o600); + fs.writeFileSync(descriptor, content, "utf8"); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = null; + fs.renameSync(tempPath, filePath); + return filePath; + } catch (error) { + if (descriptor !== null) { + try { fs.closeSync(descriptor); } catch {} + } + try { fs.rmSync(tempPath, { force: true }); } catch {} + throw error; + } +} diff --git a/src/main/proxy-account-errors.ts b/src/main/proxy-account-errors.ts new file mode 100644 index 0000000..94d1240 --- /dev/null +++ b/src/main/proxy-account-errors.ts @@ -0,0 +1,33 @@ +import type { AppSettings } from "../shared/types"; +import type { NetworkProxyState } from "./network-proxy"; + +export type ProxyOnlyAccountErrorCode = + | "proxy_list_missing" + | "proxy_list_unreadable" + | "proxy_list_empty" + | "proxy_index_unavailable" + | "proxy_unreachable"; + +export const PROXY_ONLY_ACCOUNT_ERROR_PREFIX = "proxy_only_account:"; + +const TRANSPORT_FAILURE_PATTERN = /proxy|fetch failed|network|econn|enotfound|eai_again|etimedout|und_err|socket hang up|err_proxy|tunneling socket|timeout|aborted due to timeout|HTTP 407|connection (?:closed|refused|reset|timed out)/i; + +export function resolveProxyOnlyAccountErrorCode( + settings: Pick, + state: NetworkProxyState, + failureText = "" +): ProxyOnlyAccountErrorCode | null { + if (!settings.proxyDownloadEnabled || state.status === "disabled") return null; + if (state.status === "blocked") { + if (state.reason === "proxy_file_unavailable") { + return settings.proxyListPath.trim() ? "proxy_list_unreadable" : "proxy_list_missing"; + } + if (state.reason === "no_valid_proxies") return "proxy_list_empty"; + return "proxy_index_unavailable"; + } + return TRANSPORT_FAILURE_PATTERN.test(failureText) ? "proxy_unreachable" : null; +} + +export function createProxyOnlyAccountError(code: ProxyOnlyAccountErrorCode): Error { + return new Error(`${PROXY_ONLY_ACCOUNT_ERROR_PREFIX}${code}`); +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3dbc618..5f1c678 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -40,8 +40,8 @@ import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order"; -import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection"; -import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui"; +import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, resolveSelectAllSelectionScope, shouldClearDownloadSelection } from "./selection"; +import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, formatAccountOperationError, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui"; import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit"; import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit"; import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons"; @@ -1920,6 +1920,11 @@ export function App(): ReactElement { const [settingsSubTab, setSettingsSubTab] = useState("allgemein"); const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules" | "runtime">("overview"); const [selectedAccountRowKeys, setSelectedAccountRowKeys] = useState>(() => new Set()); + const settingsSubTabRef = useRef(settingsSubTab); + const accountManagementTabRef = useRef(accountManagementTab); + const visibleAccountRowKeysRef = useRef([]); + settingsSubTabRef.current = settingsSubTab; + accountManagementTabRef.current = accountManagementTab; const [openSubmenu, setOpenSubmenu] = useState(null); const [startConflictPrompt, setStartConflictPrompt] = useState(null); const startConflictResolverRef = useRef<((result: { policy: Extract; applyToAll: boolean } | null) => void) | null>(null); @@ -3119,8 +3124,8 @@ export function App(): ReactElement { await persistDraftSettings(); await window.rd.openRealDebridLogin(); showToast("Real-Debrid Login-Fenster geöffnet", 2200); - }, (error) => { - showToast(`Real-Debrid Login fehlgeschlagen: ${String(error)}`, 2800); + }, (error) => { + showToast(formatAccountOperationError("Real-Debrid Login fehlgeschlagen", error), 3200); }); }; @@ -3129,8 +3134,8 @@ export function App(): ReactElement { await persistDraftSettings(); await window.rd.openAllDebridLogin(); showToast("AllDebrid Login-Fenster geöffnet", 2200); - }, (error) => { - showToast(`AllDebrid Login fehlgeschlagen: ${String(error)}`, 2800); + }, (error) => { + showToast(formatAccountOperationError("AllDebrid Login fehlgeschlagen", error), 3200); }); }; @@ -3231,8 +3236,8 @@ export function App(): ReactElement { const label = scope === "active" ? "Aktive Accounts" : "Alle Accounts"; showToast(`${label}: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600); } - } catch (error) { - showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3600); + } catch (error) { + showToast(formatAccountOperationError("Account-Check fehlgeschlagen", error), 3600); } finally { setAccountCheckBusy(false); } @@ -3312,7 +3317,7 @@ export function App(): ReactElement { } }); }, (error) => { - showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200); + showToast(formatAccountOperationError("Account konnte nicht gespeichert werden", error), 3600); }); }; @@ -3357,8 +3362,8 @@ export function App(): ReactElement { } void checkAccounts("active"); }); - }, (error) => { - showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200); + }, (error) => { + showToast(formatAccountOperationError("Account konnte nicht gespeichert werden", error), 3600); }); }; @@ -3427,7 +3432,7 @@ export function App(): ReactElement { pendingAccountTogglesRef.current = settledPending; setPendingAccountToggles(settledPending); if (result.status === "failed") { - showToast(`${subject}: Umschalten fehlgeschlagen: ${String(result.error)}`, 3200); + showToast(formatAccountOperationError(`${subject}: Umschalten fehlgeschlagen`, result.error), 3600); return; } showToast(`${subject} ${requestedEnabled ? "aktiviert" : "deaktiviert"}`, 2200); @@ -3442,7 +3447,7 @@ export function App(): ReactElement { await performQuickAction(async () => { await runAccountQuickAction(meta.action, row.accountId); }, (error) => { - showToast(`${row.entry.serviceLabel}: Aktion fehlgeschlagen: ${String(error)}`, 3200); + showToast(formatAccountOperationError(`${row.entry.serviceLabel}: Aktion fehlgeschlagen`, error), 3600); }); }; @@ -3518,7 +3523,7 @@ export function App(): ReactElement { void performQuickAction(async () => { const status = await window.rd.checkAccountCredentials({ kind, accountId }); showToast(status.valid ? "Account erfolgreich geprüft" : status.message || "Zugangsdaten ungültig", 2600); - }, (error) => showToast(`Prüfung fehlgeschlagen: ${String(error)}`, 3200)); + }, (error) => showToast(formatAccountOperationError("Prüfung fehlgeschlagen", error), 3600)); return; } if (row.checkable) { @@ -5233,24 +5238,29 @@ export function App(): ReactElement { setOpenMenu(null); void onImportDlcRef.current(); return; - } - if (!e.shiftKey && e.key.toLowerCase() === "a") { - if (inInput) return; - if (tabRef.current === "downloads") { - e.preventDefault(); - // 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. + } + if (!e.shiftKey && e.key.toLowerCase() === "a") { + const inputType = target.tagName === "INPUT" ? (target as HTMLInputElement).type : ""; + const selectionScope = resolveSelectAllSelectionScope( + tabRef.current, + settingsSubTabRef.current, + accountManagementTabRef.current, + target.tagName, + inputType + ); + if (!selectionScope) return; + e.preventDefault(); + if (selectionScope === "downloads") { setSelectedIds(new Set(visibleOrderIdsRef.current)); - } else if (tabRef.current === "collector") { - e.preventDefault(); + } else if (selectionScope === "collector") { setSelectedCollectorLinkIds((current) => setCollectorVisibleSelection(current, collectorVisibleIdsRef.current, true)); - } else if (tabRef.current === "history") { - e.preventDefault(); + } else if (selectionScope === "history") { setSelectedHistoryIds((current) => selectHistoryPageFromShortcut(current, historyVisibleIdsRef.current)); + } else { + setSelectedAccountRowKeys(new Set(visibleAccountRowKeysRef.current)); } - return; - } + return; + } } }; window.addEventListener("keydown", handler); @@ -5768,6 +5778,9 @@ export function App(): ReactElement { const visibleAccountRows = useMemo(() => accountStatusSort === "none" ? projectedAccountRows : sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, projectedAccountRows]); + visibleAccountRowKeysRef.current = visibleAccountRows + .map((row) => accountRowBindings.get(row.id)?.rowKey) + .filter((rowKey): rowKey is string => Boolean(rowKey)); const accountRuntimeModel = useMemo(() => { const runtimeEntries = snapshot.accountRuntime || []; const runtimeByAccountId = new Map(runtimeEntries.map((entry) => [`${entry.provider}:${entry.accountId}`, entry])); @@ -6311,7 +6324,7 @@ export function App(): ReactElement { } else { showToast("Für diesen Dienst ist keine direkte Statusprüfung verfügbar.", 2800); } - }, (error) => showToast(`Prüfung fehlgeschlagen: ${String(error)}`, 3200)); + }, (error) => showToast(formatAccountOperationError("Prüfung fehlgeschlagen", error), 3600)); }; const accountEditDialogView = accountEditDialog && accountEditOption ? (

{onlineBackupDialog.mode === "export" - ? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf." - : "Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt."} + ? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf." + : "Füge den vollständigen MDD2-Schlüssel ein. Einstellungen und eine enthaltene Proxy-Liste werden durch die gespeicherte Version ersetzt."}

{onlineBackupDialog.mode === "export" && onlineBackupDialog.busy &&
Online-Sicherung wird verschlüsselt und gespeichert …
} {onlineBackupDialog.mode === "export" && onlineBackupDialog.key && ( diff --git a/src/renderer/account-ui.ts b/src/renderer/account-ui.ts index 3076f00..bdb30d4 100644 --- a/src/renderer/account-ui.ts +++ b/src/renderer/account-ui.ts @@ -1,5 +1,20 @@ import type { DebridProvider, RendererSettings, RendererSettingsUpdate } from "../shared/types"; +const proxyOnlyAccountMessages = { + proxy_list_missing: "Proxy-only ist aktiviert, aber es ist keine Proxy-Liste hinterlegt. Hinterlege sie unter Einstellungen → Geschwindigkeit.", + proxy_list_unreadable: "Proxy-only ist aktiviert, aber die hinterlegte Proxy-Liste kann nicht gelesen werden. Prüfe die Datei unter Einstellungen → Geschwindigkeit.", + proxy_list_empty: "Proxy-only ist aktiviert, aber die hinterlegte Proxy-Liste ist leer oder enthält keine gültigen HTTP-Proxys.", + proxy_index_unavailable: "Proxy-only ist aktiviert, aber der feste API-Proxy ist in der Liste nicht verfügbar. Prüfe den Listeneintrag unter Einstellungen → Geschwindigkeit.", + proxy_unreachable: "Proxy-only ist aktiviert, aber der feste API-Proxy ist nicht erreichbar oder lehnt die Verbindung ab. Prüfe den Proxy unter Einstellungen → Geschwindigkeit." +} as const; + +export function formatAccountOperationError(prefix: string, error: unknown): string { + const raw = String(error); + const match = raw.match(/proxy_only_account:(proxy_list_missing|proxy_list_unreadable|proxy_list_empty|proxy_index_unavailable|proxy_unreachable)/); + const detail = match ? proxyOnlyAccountMessages[match[1] as keyof typeof proxyOnlyAccountMessages] : raw; + return `${prefix}: ${detail}`; +} + export type AccountToggleTarget = | { type: "provider"; provider: DebridProvider } | { type: "realdebrid"; accountId: string } diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 766d1e4..bc131a7 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -176,8 +176,10 @@ const pairs = [ ["Text mit Links analysieren", "Analyze text containing links"], ["Online-Schlüssel exportieren", "Export online key"], ["Online-Schlüssel importieren", "Import online key"], ["Logs öffnen", "Open logs"], ["Support-Bundle exportieren", "Export support bundle"], ["Support-Trace deaktivieren", "Disable support trace"], ["Support-Trace aktivieren", "Enable support trace"], ["Letzte Fehler anzeigen", "Show recent errors"], ["Einträge:", "Entries:"], ["Ausgewählt:", "Selected:"], ["Online-Schlüssel", "Online key"], - ["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials. Keep it as secure as a password."], - ["Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. The current settings will be replaced by the saved version."], + ["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"], ["Online-Sicherung wird verschlüsselt und gespeichert …", "Online backup is being encrypted and saved …"], ["Online-Sicherungsschlüssel", "Online backup key"], ["Online-Sicherungsschlüssel eingeben", "Enter online backup key"], ["Wird geladen …", "Loading …"], ["Ermöglicht einer vertrauenswürdigen Support-Stelle den geschützten Lesezugriff auf Status, Logs und Fehler. Der Verbindungscode enthält das Zugriffstoken und ist wie ein Passwort zu behandeln.", "Allows a trusted support contact protected read access to status, logs and errors. The connection code contains the access token and must be treated like a password."], ["Oeffentliche Adresse (fuer den Verbindungscode)", "Public address (for the connection code)"], ["Allowlist - erlaubte IPs/CIDR (eine pro Zeile)", "Allowlist - permitted IPs/CIDRs (one per line)"], @@ -195,6 +197,11 @@ const pairs = [ ["DDownload Login", "DDownload login"], ["Debrid-Link API", "Debrid-Link API"], ["LinkSnappy Web-Login", "LinkSnappy web login"], ["Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.", "Daily limit reached. New links will switch to the next hoster."], ["Mega-Debrid: Bitte Login und Passwort eintragen.", "Mega-Debrid: Enter a login and password."], ["Dieser Mega-Debrid-Account ist bereits vorhanden.", "This Mega-Debrid account already exists."], ["Debrid-Link: Bitte genau einen API-Key eintragen.", "Debrid-Link: Enter exactly one API key."], + ["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."], ["Die Prüfung hat nicht genau den neuen Account bestätigt.", "The check did not confirm exactly the new account."], ["Nur lokal gebunden", "Bound locally only"], ["(nur lokal)", "(local only)"], ["Nur lokal", "Local only"], ["Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante.", "Binds only to 127.0.0.1. Remote access only through a tunnel (such as Tailscale/SSH) - the safest option."], ["Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen.", "Binds to 0.0.0.0. Reachable on the network and requires an allowlist. Use only on trusted networks or VPNs."], diff --git a/src/renderer/selection.ts b/src/renderer/selection.ts index a756bc7..5572a2c 100644 --- a/src/renderer/selection.ts +++ b/src/renderer/selection.ts @@ -13,6 +13,25 @@ export function shouldClearDownloadSelectionOnEscape(tagName: string, inputType return ["checkbox", "radio", "button"].includes(inputType.toLowerCase()); } +export function resolveSelectAllSelectionScope( + view: string, + settingsSection: string, + accountPanel: string, + tagName: string, + inputType = "" +): "downloads" | "collector" | "history" | "accounts" | null { + const normalizedTag = tagName.toUpperCase(); + if (view === "settings" && settingsSection === "accounts" && accountPanel === "overview") { + if (normalizedTag === "TEXTAREA") return null; + if (normalizedTag === "INPUT" && !["checkbox", "radio", "button"].includes(inputType.toLowerCase())) return null; + return "accounts"; + } + if (normalizedTag === "INPUT" || normalizedTag === "TEXTAREA") { + return null; + } + return view === "downloads" || view === "collector" || view === "history" ? view : null; +} + export function resolveEscapeSelectionScope( view: string, settingsSection: string, diff --git a/tests/account-ui.test.ts b/tests/account-ui.test.ts index 4a3779c..2ee7544 100644 --- a/tests/account-ui.test.ts +++ b/tests/account-ui.test.ts @@ -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" }, diff --git a/tests/app-controller.test.ts b/tests/app-controller.test.ts index e8a6067..43ecd81 100644 --- a/tests/app-controller.test.ts +++ b/tests/app-controller.test.ts @@ -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" }; diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index d61acd9..5a0e12e 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -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)"], diff --git a/tests/online-backup.test.ts b/tests/online-backup.test.ts index cdab8f5..a770a1d 100644 --- a/tests/online-backup.test.ts +++ b/tests/online-backup.test.ts @@ -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); }); }); diff --git a/tests/online-proxy-list.test.ts b/tests/online-proxy-list.test.ts new file mode 100644 index 0000000..6fdcf22 --- /dev/null +++ b/tests/online-proxy-list.test.ts @@ -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(); + }); +}); diff --git a/tests/proxy-account-errors.test.ts b/tests/proxy-account-errors.test.ts new file mode 100644 index 0000000..23c8b54 --- /dev/null +++ b/tests/proxy-account-errors.test.ts @@ -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"); + }); +}); diff --git a/tests/selection.test.ts b/tests/selection.test.ts index 167fff9..c10486b 100644 --- a/tests/selection.test.ts +++ b/tests/selection.test.ts @@ -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(); + }); +}); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 1d3c576..84477cd 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -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;