feat: improve proxy account setup and backup portability
This commit is contained in:
+25
-8
@@ -10,8 +10,8 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält
|
|||||||
|
|
||||||
- Verifiziert am: 31. August 2026, Europe/Berlin
|
- Verifiziert am: 31. August 2026, Europe/Berlin
|
||||||
- Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader`
|
- Lokaler Pfad: `C:\Users\Sascha\Desktop\Claude & ChatGPT Projekte\Multi-Debrid-Downloader`
|
||||||
- Arbeitsbranch: `release/v2.0.80`
|
- Arbeitsbranch: `fix/account-select-all`
|
||||||
- Quellbasis: `release/v2.0.79`
|
- Quellbasis: `release/v2.0.80`
|
||||||
- Release-Tag: `v2.0.80`
|
- Release-Tag: `v2.0.80`
|
||||||
- Baseline-Commit: `1628f23023c93efcb222c63913f9d2508a9a5e6c`
|
- Baseline-Commit: `1628f23023c93efcb222c63913f9d2508a9a5e6c`
|
||||||
- Hotfix-Basis: `5cee459d0bcc5c19d8d6483948a036a43c9d91b1`
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
- 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.
|
- 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
|
## Start-, Build- und Testbefehle
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -194,6 +205,9 @@ npm exec -- tsc --noEmit
|
|||||||
|
|
||||||
## Verifizierungen vom 31. August 2026
|
## 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.
|
- 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`.
|
- 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`.
|
- 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 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.
|
- 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 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
|
## Nächste sinnvolle Schritte
|
||||||
|
|
||||||
1. Nach einer getrennt freigegebenen Serverinstallation die einheitliche Geschwindigkeitsanzeige und den selektiven Paket-Reset am echten Queue-Fall prüfen.
|
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 den Ablauf Real-Debrid-Web-Download, Hauptfenster schließen und unmittelbar neu starten am echten Zielsystem verifizieren; vor jedem Eingriff Prozessbaum und Logtail sichern.
|
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. Sicherheitsabhängigkeiten in einem separaten Upgrade-Branch aktualisieren, Electron-/Vite-/Vitest-Major-Wechsel einzeln testen und danach den vollständigen Windows-Paketpfad prüfen.
|
3. Nach einer getrennt freigegebenen Serverinstallation die einheitliche Geschwindigkeitsanzeige und den selektiven Paket-Reset am echten Queue-Fall prüfen.
|
||||||
4. Eine nichtdestruktive Strategie zur Bereinigung der divergierenden `main`-Branches abstimmen; kein Force-Push ohne ausdrückliche Freigabe.
|
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. Optional Developer Mode für lokale Symlink-Tests bereitstellen oder die Test-Fixtures plattformgerecht ohne privilegierte Symlinks gestalten.
|
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.
|
||||||
|
|||||||
+77
-10
@@ -81,6 +81,7 @@ import { CollectorStore } from "./collector-store";
|
|||||||
import type { CollectorPersistenceState } from "../shared/collector";
|
import type { CollectorPersistenceState } from "../shared/collector";
|
||||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
||||||
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
|
||||||
|
import { captureOnlineProxyList, getManagedOnlineProxyListPath, writeImportedOnlineProxyList } from "./online-proxy-list";
|
||||||
import { overlayLiveUsageCounters } from "./settings-live-overlay";
|
import { overlayLiveUsageCounters } from "./settings-live-overlay";
|
||||||
import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage";
|
import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage";
|
||||||
import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger";
|
import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger";
|
||||||
@@ -88,7 +89,8 @@ import { NotificationOutbox } from "./notification-outbox";
|
|||||||
import { sendNotification } from "./notify";
|
import { sendNotification } from "./notify";
|
||||||
import { DownloadHealthMonitor } from "./download-health-monitor";
|
import { DownloadHealthMonitor } from "./download-health-monitor";
|
||||||
import { shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler";
|
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<AppSettings>): Partial<AppSettings> {
|
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||||
@@ -512,6 +514,16 @@ export class AppController {
|
|||||||
logTraceEvent(level, "audit", 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 {
|
public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig {
|
||||||
const next = setTraceEnabled(enabled, note, durationMs);
|
const next = setTraceEnabled(enabled, note, durationMs);
|
||||||
this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note });
|
this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note });
|
||||||
@@ -545,7 +557,7 @@ export class AppController {
|
|||||||
: normalizeSettings({ ...restoredSettings, logStorageLocation: this.settings.logStorageLocation });
|
: 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 baseDir = this.storagePaths.baseDir;
|
||||||
const files = [
|
const files = [
|
||||||
this.storagePaths.configFile,
|
this.storagePaths.configFile,
|
||||||
@@ -554,7 +566,8 @@ export class AppController {
|
|||||||
`${this.storagePaths.configFile}.bak.tmp`,
|
`${this.storagePaths.configFile}.bak.tmp`,
|
||||||
path.join(baseDir, "debug_host.txt"),
|
path.join(baseDir, "debug_host.txt"),
|
||||||
path.join(baseDir, "debug_port.txt"),
|
path.join(baseDir, "debug_port.txt"),
|
||||||
path.join(baseDir, "debug_allowlist.txt")
|
path.join(baseDir, "debug_allowlist.txt"),
|
||||||
|
...additionalFiles
|
||||||
];
|
];
|
||||||
if (includeDownloads) {
|
if (includeDownloads) {
|
||||||
files.push(
|
files.push(
|
||||||
@@ -570,7 +583,7 @@ export class AppController {
|
|||||||
`${this.storagePaths.statisticsFile}.tmp`
|
`${this.storagePaths.statisticsFile}.tmp`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return createFileRollback(files);
|
return createFileRollback([...new Set(files)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private rollbackImportPersistence(rollback: () => void, context: string): void {
|
private rollbackImportPersistence(rollback: () => void, context: string): void {
|
||||||
@@ -589,7 +602,12 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): Promise<void> {
|
private async applySettingsOnlyBackup(
|
||||||
|
importedSettings: AppSettings,
|
||||||
|
remoteDiagnostics?: unknown,
|
||||||
|
restoreRemoteDiagnostics = false,
|
||||||
|
onlineProxyListContent?: string | null
|
||||||
|
): Promise<{ proxyListRestored: boolean; proxyOnlyDisabled: boolean }> {
|
||||||
const barrier = await acquirePersistenceBarrier();
|
const barrier = await acquirePersistenceBarrier();
|
||||||
const previousSettings = this.settings;
|
const previousSettings = this.settings;
|
||||||
let restoredSettings: AppSettings | null = null;
|
let restoredSettings: AppSettings | null = null;
|
||||||
@@ -597,10 +615,32 @@ export class AppController {
|
|||||||
let remoteRestore: ReturnType<typeof resolveRemoteDiagnosticsRestore> = null;
|
let remoteRestore: ReturnType<typeof resolveRemoteDiagnosticsRestore> = null;
|
||||||
let logStorageChanged = false;
|
let logStorageChanged = false;
|
||||||
let runtimeApplied = false;
|
let runtimeApplied = false;
|
||||||
|
let proxyListRestored = false;
|
||||||
|
let proxyOnlyDisabled = false;
|
||||||
try {
|
try {
|
||||||
restoredSettings = this.prepareImportedLogStorage(normalizeSettings(importedSettings));
|
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);
|
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);
|
saveSettings(this.storagePaths, restoredSettings);
|
||||||
remoteRestore = restoreRemoteDiagnostics
|
remoteRestore = restoreRemoteDiagnostics
|
||||||
? this.persistRemoteDiagnosticsFromBackup(remoteDiagnostics)
|
? this.persistRemoteDiagnosticsFromBackup(remoteDiagnostics)
|
||||||
@@ -651,6 +691,7 @@ export class AppController {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
this.auditRemoteDiagnosticsRestore(remoteRestore, restoreRemoteDiagnostics);
|
this.auditRemoteDiagnosticsRestore(remoteRestore, restoreRemoteDiagnostics);
|
||||||
|
return { proxyListRestored, proxyOnlyDisabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||||
@@ -720,6 +761,9 @@ export class AppController {
|
|||||||
const applied = applyAccountCommand(this.settings, command);
|
const applied = applyAccountCommand(this.settings, command);
|
||||||
let checkedStatus: DebridAccountStatus | null = null;
|
let checkedStatus: DebridAccountStatus | null = null;
|
||||||
const redactions = collectAccountStatusRedactionValues(applied.settings, command);
|
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")) {
|
if (command.action !== "delete" && applied.response.accountId && (command.kind === "megadebrid-api" || command.kind === "megadebrid-web")) {
|
||||||
const mode = command.kind === "megadebrid-web" ? "web" : "api";
|
const mode = command.kind === "megadebrid-web" ? "web" : "api";
|
||||||
const account = getMegaDebridAccountsForMode(applied.settings, mode).find((entry) => entry.id === applied.response.accountId);
|
const account = getMegaDebridAccountsForMode(applied.settings, mode).find((entry) => entry.id === applied.response.accountId);
|
||||||
@@ -743,6 +787,7 @@ export class AppController {
|
|||||||
checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions);
|
checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions);
|
||||||
}
|
}
|
||||||
if (checkedStatus && !checkedStatus.valid) {
|
if (checkedStatus && !checkedStatus.valid) {
|
||||||
|
this.throwProxyOnlyAccountFailure(checkedStatus.message || "");
|
||||||
throw new Error(checkedStatus.message || "Zugangsdaten ungültig");
|
throw new Error(checkedStatus.message || "Zugangsdaten ungültig");
|
||||||
}
|
}
|
||||||
this.updateSettings(applied.settings);
|
this.updateSettings(applied.settings);
|
||||||
@@ -766,6 +811,7 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
|
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
|
||||||
|
this.assertProxyOnlyAccountSetup();
|
||||||
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
||||||
if (input.kind === "deepbrid-api") {
|
if (input.kind === "deepbrid-api") {
|
||||||
const key = input.secret?.trim() || this.settings.deepbridApiKey.trim();
|
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()) {
|
if (!input.secret && input.accountId === "svc-deepbrid" && this.settings.deepbridApiKey.trim()) {
|
||||||
this.manager.applyDebridAccountStatuses([status]);
|
this.manager.applyDebridAccountStatuses([status]);
|
||||||
}
|
}
|
||||||
|
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
|
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)) {
|
if (!input.secret && getRealDebridAccounts(this.settings).some((entry) => entry.id === status.accountId)) {
|
||||||
this.manager.applyDebridAccountStatuses([status]);
|
this.manager.applyDebridAccountStatuses([status]);
|
||||||
}
|
}
|
||||||
|
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
if (input.kind === "megadebrid-api" || input.kind === "megadebrid-web") {
|
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)) {
|
if (!input.secret && getMegaDebridAccountsForMode(this.settings, mode).some((entry) => entry.id === status.accountId)) {
|
||||||
this.manager.applyDebridAccountStatuses([status]);
|
this.manager.applyDebridAccountStatuses([status]);
|
||||||
}
|
}
|
||||||
|
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
const key = input.secret?.trim()
|
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)) {
|
if (!input.secret && parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).some((entry) => entry.id === status.accountId)) {
|
||||||
this.manager.applyDebridAccountStatuses([status]);
|
this.manager.applyDebridAccountStatuses([status]);
|
||||||
}
|
}
|
||||||
|
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,6 +1015,7 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise<void> {
|
public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise<void> {
|
||||||
|
this.assertProxyOnlyAccountSetup();
|
||||||
const accountId = String(request.accountId || "").trim();
|
const accountId = String(request.accountId || "").trim();
|
||||||
if (!isRealDebridWebAccountId(accountId)) {
|
if (!isRealDebridWebAccountId(accountId)) {
|
||||||
throw new Error("Account-Payload ist ungültig");
|
throw new Error("Account-Payload ist ungültig");
|
||||||
@@ -1061,6 +1112,7 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async openAllDebridLoginWindow(): Promise<void> {
|
public async openAllDebridLoginWindow(): Promise<void> {
|
||||||
|
this.assertProxyOnlyAccountSetup();
|
||||||
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
|
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
|
||||||
await this.allDebridWebFallback.openLoginWindow();
|
await this.allDebridWebFallback.openLoginWindow();
|
||||||
}
|
}
|
||||||
@@ -1090,6 +1142,7 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async checkDebridAccounts(scope: AccountCheckScope = "active"): Promise<DebridAccountStatus[]> {
|
public async checkDebridAccounts(scope: AccountCheckScope = "active"): Promise<DebridAccountStatus[]> {
|
||||||
|
this.assertProxyOnlyAccountSetup();
|
||||||
const checkedStatuses = sanitizeDebridAccountStatuses(
|
const checkedStatuses = sanitizeDebridAccountStatuses(
|
||||||
await checkAllDebridAccounts(
|
await checkAllDebridAccounts(
|
||||||
this.settings,
|
this.settings,
|
||||||
@@ -1100,6 +1153,12 @@ export class AppController {
|
|||||||
collectAccountStatusRedactionValues(this.settings)
|
collectAccountStatusRedactionValues(this.settings)
|
||||||
);
|
);
|
||||||
const statuses = retainConfiguredRealDebridStatuses(this.settings, checkedStatuses);
|
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.manager.applyDebridAccountStatuses(statuses);
|
||||||
this.audit("INFO", "Debrid-Accounts geprueft", {
|
this.audit("INFO", "Debrid-Accounts geprueft", {
|
||||||
total: statuses.length,
|
total: statuses.length,
|
||||||
@@ -1357,20 +1416,28 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async exportOnlineBackup(): Promise<{ key: string }> {
|
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);
|
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 };
|
return { key: created.key };
|
||||||
}
|
}
|
||||||
|
|
||||||
public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> {
|
public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> {
|
||||||
const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL);
|
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", {
|
this.audit("INFO", "Online-Sicherung importiert", {
|
||||||
kind: "settings-only",
|
kind: "settings-only",
|
||||||
|
proxyListRestored: result.proxyListRestored,
|
||||||
|
proxyOnlyDisabled: result.proxyOnlyDisabled,
|
||||||
accountSummary: buildAccountSummary(this.settings)
|
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 }> {
|
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import zlib from "node:zlib";
|
import zlib from "node:zlib";
|
||||||
import type { AppSettings } from "../shared/types";
|
import type { AppSettings } from "../shared/types";
|
||||||
|
import { MAX_ONLINE_PROXY_LIST_BYTES, validateOnlineProxyListContent } from "./online-proxy-list";
|
||||||
|
|
||||||
const KEY_PREFIX = "MDD2-";
|
const KEY_PREFIX = "MDD2-";
|
||||||
const KEY_BODY_LENGTH = 70;
|
const KEY_BODY_LENGTH = 70;
|
||||||
@@ -12,7 +13,8 @@ const AUTH_TAG_LENGTH = 16;
|
|||||||
const BLOB_VERSION = 1;
|
const BLOB_VERSION = 1;
|
||||||
const MAX_BLOB_BYTES = 256 * 1024;
|
const MAX_BLOB_BYTES = 256 * 1024;
|
||||||
const MAX_RESPONSE_BYTES = 512 * 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 REQUEST_TIMEOUT_MS = 12_000;
|
||||||
const KEY_CONTEXT = Buffer.from("MDD2-ONLINE-KEY-V1", "utf8");
|
const KEY_CONTEXT = Buffer.from("MDD2-ONLINE-KEY-V1", "utf8");
|
||||||
const AAD_CONTEXT = Buffer.from("MDD-ONLINE-BACKUP-V1", "utf8");
|
const AAD_CONTEXT = Buffer.from("MDD-ONLINE-BACKUP-V1", "utf8");
|
||||||
@@ -23,6 +25,10 @@ export interface OnlineSettingsPayload {
|
|||||||
appVersion: string;
|
appVersion: string;
|
||||||
exportedAt: string;
|
exportedAt: string;
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
|
proxyList?: {
|
||||||
|
version: 1;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OnlineBackupRecord {
|
export interface OnlineBackupRecord {
|
||||||
@@ -81,6 +87,13 @@ function validatePayload(value: unknown): OnlineSettingsPayload {
|
|||||||
) {
|
) {
|
||||||
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
|
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
|
||||||
}
|
}
|
||||||
|
if (record.proxyList !== undefined) {
|
||||||
|
const proxyList = record.proxyList as Record<string, unknown> | 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;
|
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) };
|
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 idBytes = crypto.randomBytes(RECORD_ID_LENGTH);
|
||||||
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
|
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
|
||||||
const key = encodeKey(idBytes, masterKey);
|
const key = encodeKey(idBytes, masterKey);
|
||||||
const encryptionKey = deriveSecret(masterKey, idBytes, "ENCRYPTION");
|
const encryptionKey = deriveSecret(masterKey, idBytes, "ENCRYPTION");
|
||||||
const nonce = crypto.randomBytes(NONCE_LENGTH);
|
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 = {
|
const payload: OnlineSettingsPayload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
kind: "settings-only",
|
kind: "settings-only",
|
||||||
appVersion,
|
appVersion,
|
||||||
exportedAt,
|
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");
|
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
|
||||||
if (plaintext.length > MAX_PLAINTEXT_BYTES) {
|
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 compressed = zlib.gzipSync(plaintext, { level: 9 });
|
||||||
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH });
|
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 ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||||
const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]);
|
const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]);
|
||||||
if (blobBytes.length > MAX_BLOB_BYTES) {
|
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 parsed = parseOnlineBackupKey(key);
|
||||||
const deleteVerifier = crypto.createHash("sha256").update(deriveDeleteSecret(parsed)).digest("base64url");
|
const deleteVerifier = crypto.createHash("sha256").update(deriveDeleteSecret(parsed)).digest("base64url");
|
||||||
|
|||||||
@@ -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<AppSettings, "proxyDownloadEnabled" | "proxyListPath">): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AppSettings, "proxyDownloadEnabled" | "proxyListPath">,
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
+36
-23
@@ -40,8 +40,8 @@ import {
|
|||||||
getProviderUsageDayKey
|
getProviderUsageDayKey
|
||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
|
||||||
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection";
|
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, resolveSelectAllSelectionScope, shouldClearDownloadSelection } from "./selection";
|
||||||
import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui";
|
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 { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
|
||||||
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
|
||||||
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
|
||||||
@@ -1920,6 +1920,11 @@ export function App(): ReactElement {
|
|||||||
const [settingsSubTab, setSettingsSubTab] = useState<SettingsSection>("allgemein");
|
const [settingsSubTab, setSettingsSubTab] = useState<SettingsSection>("allgemein");
|
||||||
const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules" | "runtime">("overview");
|
const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules" | "runtime">("overview");
|
||||||
const [selectedAccountRowKeys, setSelectedAccountRowKeys] = useState<Set<string>>(() => new Set());
|
const [selectedAccountRowKeys, setSelectedAccountRowKeys] = useState<Set<string>>(() => new Set());
|
||||||
|
const settingsSubTabRef = useRef(settingsSubTab);
|
||||||
|
const accountManagementTabRef = useRef(accountManagementTab);
|
||||||
|
const visibleAccountRowKeysRef = useRef<string[]>([]);
|
||||||
|
settingsSubTabRef.current = settingsSubTab;
|
||||||
|
accountManagementTabRef.current = accountManagementTab;
|
||||||
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
|
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
|
||||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
||||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||||
@@ -3120,7 +3125,7 @@ export function App(): ReactElement {
|
|||||||
await window.rd.openRealDebridLogin();
|
await window.rd.openRealDebridLogin();
|
||||||
showToast("Real-Debrid Login-Fenster geöffnet", 2200);
|
showToast("Real-Debrid Login-Fenster geöffnet", 2200);
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
showToast(`Real-Debrid Login fehlgeschlagen: ${String(error)}`, 2800);
|
showToast(formatAccountOperationError("Real-Debrid Login fehlgeschlagen", error), 3200);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3130,7 +3135,7 @@ export function App(): ReactElement {
|
|||||||
await window.rd.openAllDebridLogin();
|
await window.rd.openAllDebridLogin();
|
||||||
showToast("AllDebrid Login-Fenster geöffnet", 2200);
|
showToast("AllDebrid Login-Fenster geöffnet", 2200);
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
showToast(`AllDebrid Login fehlgeschlagen: ${String(error)}`, 2800);
|
showToast(formatAccountOperationError("AllDebrid Login fehlgeschlagen", error), 3200);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3232,7 +3237,7 @@ export function App(): ReactElement {
|
|||||||
showToast(`${label}: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600);
|
showToast(`${label}: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3600);
|
showToast(formatAccountOperationError("Account-Check fehlgeschlagen", error), 3600);
|
||||||
} finally {
|
} finally {
|
||||||
setAccountCheckBusy(false);
|
setAccountCheckBusy(false);
|
||||||
}
|
}
|
||||||
@@ -3312,7 +3317,7 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
|
showToast(formatAccountOperationError("Account konnte nicht gespeichert werden", error), 3600);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3358,7 +3363,7 @@ export function App(): ReactElement {
|
|||||||
void checkAccounts("active");
|
void checkAccounts("active");
|
||||||
});
|
});
|
||||||
}, (error) => {
|
}, (error) => {
|
||||||
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
|
showToast(formatAccountOperationError("Account konnte nicht gespeichert werden", error), 3600);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3427,7 +3432,7 @@ export function App(): ReactElement {
|
|||||||
pendingAccountTogglesRef.current = settledPending;
|
pendingAccountTogglesRef.current = settledPending;
|
||||||
setPendingAccountToggles(settledPending);
|
setPendingAccountToggles(settledPending);
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
showToast(`${subject}: Umschalten fehlgeschlagen: ${String(result.error)}`, 3200);
|
showToast(formatAccountOperationError(`${subject}: Umschalten fehlgeschlagen`, result.error), 3600);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showToast(`${subject} ${requestedEnabled ? "aktiviert" : "deaktiviert"}`, 2200);
|
showToast(`${subject} ${requestedEnabled ? "aktiviert" : "deaktiviert"}`, 2200);
|
||||||
@@ -3442,7 +3447,7 @@ export function App(): ReactElement {
|
|||||||
await performQuickAction(async () => {
|
await performQuickAction(async () => {
|
||||||
await runAccountQuickAction(meta.action, row.accountId);
|
await runAccountQuickAction(meta.action, row.accountId);
|
||||||
}, (error) => {
|
}, (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 () => {
|
void performQuickAction(async () => {
|
||||||
const status = await window.rd.checkAccountCredentials({ kind, accountId });
|
const status = await window.rd.checkAccountCredentials({ kind, accountId });
|
||||||
showToast(status.valid ? "Account erfolgreich geprüft" : status.message || "Zugangsdaten ungültig", 2600);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (row.checkable) {
|
if (row.checkable) {
|
||||||
@@ -5235,19 +5240,24 @@ export function App(): ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!e.shiftKey && e.key.toLowerCase() === "a") {
|
if (!e.shiftKey && e.key.toLowerCase() === "a") {
|
||||||
if (inInput) return;
|
const inputType = target.tagName === "INPUT" ? (target as HTMLInputElement).type : "";
|
||||||
if (tabRef.current === "downloads") {
|
const selectionScope = resolveSelectAllSelectionScope(
|
||||||
e.preventDefault();
|
tabRef.current,
|
||||||
// Select exactly the VISIBLE rows (packages + their items), honouring
|
settingsSubTabRef.current,
|
||||||
// the active search / collapse / hide-extracted filters — selecting
|
accountManagementTabRef.current,
|
||||||
// the unfiltered package map would let a later delete hit hidden ones.
|
target.tagName,
|
||||||
|
inputType
|
||||||
|
);
|
||||||
|
if (!selectionScope) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectionScope === "downloads") {
|
||||||
setSelectedIds(new Set(visibleOrderIdsRef.current));
|
setSelectedIds(new Set(visibleOrderIdsRef.current));
|
||||||
} else if (tabRef.current === "collector") {
|
} else if (selectionScope === "collector") {
|
||||||
e.preventDefault();
|
|
||||||
setSelectedCollectorLinkIds((current) => setCollectorVisibleSelection(current, collectorVisibleIdsRef.current, true));
|
setSelectedCollectorLinkIds((current) => setCollectorVisibleSelection(current, collectorVisibleIdsRef.current, true));
|
||||||
} else if (tabRef.current === "history") {
|
} else if (selectionScope === "history") {
|
||||||
e.preventDefault();
|
|
||||||
setSelectedHistoryIds((current) => selectHistoryPageFromShortcut(current, historyVisibleIdsRef.current));
|
setSelectedHistoryIds((current) => selectHistoryPageFromShortcut(current, historyVisibleIdsRef.current));
|
||||||
|
} else {
|
||||||
|
setSelectedAccountRowKeys(new Set(visibleAccountRowKeysRef.current));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5768,6 +5778,9 @@ export function App(): ReactElement {
|
|||||||
const visibleAccountRows = useMemo(() => accountStatusSort === "none"
|
const visibleAccountRows = useMemo(() => accountStatusSort === "none"
|
||||||
? projectedAccountRows
|
? projectedAccountRows
|
||||||
: sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, 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<AccountWorkspaceViewModel["runtime"]>(() => {
|
const accountRuntimeModel = useMemo<AccountWorkspaceViewModel["runtime"]>(() => {
|
||||||
const runtimeEntries = snapshot.accountRuntime || [];
|
const runtimeEntries = snapshot.accountRuntime || [];
|
||||||
const runtimeByAccountId = new Map(runtimeEntries.map((entry) => [`${entry.provider}:${entry.accountId}`, entry]));
|
const runtimeByAccountId = new Map(runtimeEntries.map((entry) => [`${entry.provider}:${entry.accountId}`, entry]));
|
||||||
@@ -6311,7 +6324,7 @@ export function App(): ReactElement {
|
|||||||
} else {
|
} else {
|
||||||
showToast("Für diesen Dienst ist keine direkte Statusprüfung verfügbar.", 2800);
|
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 ? (
|
const accountEditDialogView = accountEditDialog && accountEditOption ? (
|
||||||
<AccountEditDialog
|
<AccountEditDialog
|
||||||
@@ -6807,8 +6820,8 @@ export function App(): ReactElement {
|
|||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{onlineBackupDialog.mode === "export"
|
{onlineBackupDialog.mode === "export"
|
||||||
? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf."
|
? "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. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt."}
|
: "Füge den vollständigen MDD2-Schlüssel ein. Einstellungen und eine enthaltene Proxy-Liste werden durch die gespeicherte Version ersetzt."}
|
||||||
</p>
|
</p>
|
||||||
{onlineBackupDialog.mode === "export" && onlineBackupDialog.busy && <div className="online-backup-status">Online-Sicherung wird verschlüsselt und gespeichert …</div>}
|
{onlineBackupDialog.mode === "export" && onlineBackupDialog.busy && <div className="online-backup-status">Online-Sicherung wird verschlüsselt und gespeichert …</div>}
|
||||||
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && (
|
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && (
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
import type { DebridProvider, RendererSettings, RendererSettingsUpdate } from "../shared/types";
|
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 =
|
export type AccountToggleTarget =
|
||||||
| { type: "provider"; provider: DebridProvider }
|
| { type: "provider"; provider: DebridProvider }
|
||||||
| { type: "realdebrid"; accountId: string }
|
| { type: "realdebrid"; accountId: string }
|
||||||
|
|||||||
@@ -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"],
|
["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"],
|
["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"],
|
["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."],
|
["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. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. The current settings will be replaced by the saved version."],
|
["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 …"],
|
["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."],
|
["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)"],
|
["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"],
|
["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."],
|
["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."],
|
["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"],
|
["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 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."],
|
["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."],
|
||||||
|
|||||||
@@ -13,6 +13,25 @@ export function shouldClearDownloadSelectionOnEscape(tagName: string, inputType
|
|||||||
return ["checkbox", "radio", "button"].includes(inputType.toLowerCase());
|
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(
|
export function resolveEscapeSelectionScope(
|
||||||
view: string,
|
view: string,
|
||||||
settingsSection: string,
|
settingsSection: string,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createAccountToggleQueue,
|
createAccountToggleQueue,
|
||||||
enqueueAccountToggleIntent,
|
enqueueAccountToggleIntent,
|
||||||
filterAccountDialogOptions,
|
filterAccountDialogOptions,
|
||||||
|
formatAccountOperationError,
|
||||||
getAvailableAccountOptions,
|
getAvailableAccountOptions,
|
||||||
getAccountDialogSelectableOptions,
|
getAccountDialogSelectableOptions,
|
||||||
isAccountRowSelectionKey,
|
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", () => {
|
describe("account dialog filter", () => {
|
||||||
const options = [
|
const options = [
|
||||||
{ id: "rd-api", serviceLabel: "Real-Debrid", title: "Real-Debrid API", modeLabel: "API", pickerDescription: "API-Token" },
|
{ id: "rd-api", serviceLabel: "Real-Debrid", title: "Real-Debrid API", modeLabel: "API", pickerDescription: "API-Token" },
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { defaultSettings } from "../src/main/constants";
|
|||||||
import { configureCredentialProtector } from "../src/main/credential-protection";
|
import { configureCredentialProtector } from "../src/main/credential-protection";
|
||||||
import { prepareDailyStartSettingsPatch } from "../src/main/daily-start-scheduler";
|
import { prepareDailyStartSettingsPatch } from "../src/main/daily-start-scheduler";
|
||||||
import { logger } from "../src/main/logger";
|
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 { createStatisticsLedger, loadStatisticsLedger, saveStatisticsLedger } from "../src/main/statistics-ledger";
|
||||||
import { createStoragePaths, emptySession, loadHistory, loadSession, loadSettings, saveHistory, saveSession, saveSettings } from "../src/main/storage";
|
import { createStoragePaths, emptySession, loadHistory, loadSession, loadSettings, saveHistory, saveSession, saveSettings } from "../src/main/storage";
|
||||||
import type { CollectorPersistenceState } from "../src/shared/collector";
|
import type { CollectorPersistenceState } from "../src/shared/collector";
|
||||||
@@ -202,6 +204,7 @@ afterEach(async () => {
|
|||||||
debugStorage.configError = null;
|
debugStorage.configError = null;
|
||||||
debugStorage.restartCalls = 0;
|
debugStorage.restartCalls = 0;
|
||||||
debugStorage.restartErrors = [];
|
debugStorage.restartErrors = [];
|
||||||
|
configureNetworkProxy(defaultSettings());
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
@@ -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", () => {
|
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 () => {
|
it("applies runtime settings before releasing the successful import barrier", async () => {
|
||||||
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
|
const currentSettings = { ...defaultSettings(), outputDir: "C:\\Current" };
|
||||||
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
|
const importedSettings = { ...currentSettings, outputDir: "C:\\Imported" };
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ describe("renderer localization", () => {
|
|||||||
expect(translateUiText(german, "en")).toBe(english);
|
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([
|
it.each([
|
||||||
["Erfolgsmeldungen senden", "Send success notifications"],
|
["Erfolgsmeldungen senden", "Send success notifications"],
|
||||||
["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"],
|
["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"],
|
||||||
|
|||||||
@@ -41,15 +41,19 @@ describe("online backup key", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("never places credentials or the decryption secret in the server record", () => {
|
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 serialized = JSON.stringify(created.record);
|
||||||
const parsed = parseOnlineBackupKey(created.key);
|
const parsed = parseOnlineBackupKey(created.key);
|
||||||
|
|
||||||
expect(serialized).not.toContain("rd-secret-token");
|
expect(serialized).not.toContain("rd-secret-token");
|
||||||
expect(serialized).not.toContain("fixture-deepbrid-online-key-6jK8");
|
expect(serialized).not.toContain("fixture-deepbrid-online-key-6jK8");
|
||||||
expect(serialized).not.toContain("backup-password");
|
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(serialized).not.toContain(parsed.masterKey.toString("base64url"));
|
||||||
expect(Object.keys(created.record).sort()).toEqual(["blob", "deleteVerifier", "id"]);
|
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", () => {
|
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) };
|
const oversized = { ...settings(), archivePasswordList: "x".repeat(600_000) };
|
||||||
|
|
||||||
expect(() => createOnlineBackup(oversized, "2.0.0")).toThrow(/zu groß/i);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -95,3 +95,32 @@ describe("global Escape selection routing", () => {
|
|||||||
expect(api.releaseAccountSelectionFocus?.(null)).toBe(false);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1167,6 +1167,15 @@ describe("account workspace", () => {
|
|||||||
expect(html).toContain("− Entfernen (3)");
|
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", () => {
|
it("removes the redundant global account activation switch", () => {
|
||||||
const legacyActions = { ...workspaceActions(), onSetAllEnabled: () => {} } as AccountWorkspaceActions;
|
const legacyActions = { ...workspaceActions(), onSetAllEnabled: () => {} } as AccountWorkspaceActions;
|
||||||
const legacyModel = { ...workspaceModel(), allEnabled: true } as AccountWorkspaceViewModel;
|
const legacyModel = { ...workspaceModel(), allEnabled: true } as AccountWorkspaceViewModel;
|
||||||
|
|||||||
Reference in New Issue
Block a user