feat: improve proxy account setup and backup portability

This commit is contained in:
Sucukdeluxe
2026-08-31 22:52:36 +02:00
parent 67dcc305ef
commit 486507d4b2
17 changed files with 618 additions and 63 deletions
+83 -16
View File
@@ -81,6 +81,7 @@ import { CollectorStore } from "./collector-store";
import type { CollectorPersistenceState } from "../shared/collector";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup";
import { captureOnlineProxyList, getManagedOnlineProxyListPath, writeImportedOnlineProxyList } from "./online-proxy-list";
import { overlayLiveUsageCounters } from "./settings-live-overlay";
import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage";
import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger";
@@ -88,7 +89,8 @@ import { NotificationOutbox } from "./notification-outbox";
import { sendNotification } from "./notify";
import { DownloadHealthMonitor } from "./download-health-monitor";
import { shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler";
import { configureNetworkProxy, shutdownNetworkProxy } from "./network-proxy";
import { configureNetworkProxy, getNetworkProxyState, shutdownNetworkProxy } from "./network-proxy";
import { createProxyOnlyAccountError, resolveProxyOnlyAccountErrorCode } from "./proxy-account-errors";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@@ -507,10 +509,20 @@ export class AppController {
return getDebugSetupCheck(this.storagePaths.baseDir);
}
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
logAuditEvent(level, message, fields);
logTraceEvent(level, "audit", message, fields);
}
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
logAuditEvent(level, message, fields);
logTraceEvent(level, "audit", message, fields);
}
private assertProxyOnlyAccountSetup(): void {
const code = resolveProxyOnlyAccountErrorCode(this.settings, getNetworkProxyState());
if (code) throw createProxyOnlyAccountError(code);
}
private throwProxyOnlyAccountFailure(failureText: string): void {
const code = resolveProxyOnlyAccountErrorCode(this.settings, getNetworkProxyState(), failureText);
if (code) throw createProxyOnlyAccountError(code);
}
public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig {
const next = setTraceEnabled(enabled, note, durationMs);
@@ -545,7 +557,7 @@ export class AppController {
: normalizeSettings({ ...restoredSettings, logStorageLocation: this.settings.logStorageLocation });
}
private createBackupImportRollback(includeDownloads: boolean): () => void {
private createBackupImportRollback(includeDownloads: boolean, additionalFiles: readonly string[] = []): () => void {
const baseDir = this.storagePaths.baseDir;
const files = [
this.storagePaths.configFile,
@@ -554,7 +566,8 @@ export class AppController {
`${this.storagePaths.configFile}.bak.tmp`,
path.join(baseDir, "debug_host.txt"),
path.join(baseDir, "debug_port.txt"),
path.join(baseDir, "debug_allowlist.txt")
path.join(baseDir, "debug_allowlist.txt"),
...additionalFiles
];
if (includeDownloads) {
files.push(
@@ -570,7 +583,7 @@ export class AppController {
`${this.storagePaths.statisticsFile}.tmp`
);
}
return createFileRollback(files);
return createFileRollback([...new Set(files)]);
}
private rollbackImportPersistence(rollback: () => void, context: string): void {
@@ -589,7 +602,12 @@ export class AppController {
}
}
private async applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): Promise<void> {
private async applySettingsOnlyBackup(
importedSettings: AppSettings,
remoteDiagnostics?: unknown,
restoreRemoteDiagnostics = false,
onlineProxyListContent?: string | null
): Promise<{ proxyListRestored: boolean; proxyOnlyDisabled: boolean }> {
const barrier = await acquirePersistenceBarrier();
const previousSettings = this.settings;
let restoredSettings: AppSettings | null = null;
@@ -597,10 +615,32 @@ export class AppController {
let remoteRestore: ReturnType<typeof resolveRemoteDiagnosticsRestore> = null;
let logStorageChanged = false;
let runtimeApplied = false;
let proxyListRestored = false;
let proxyOnlyDisabled = false;
try {
restoredSettings = this.prepareImportedLogStorage(normalizeSettings(importedSettings));
if (typeof onlineProxyListContent === "string") {
restoredSettings = normalizeSettings({
...restoredSettings,
proxyListPath: getManagedOnlineProxyListPath(this.storagePaths.baseDir)
});
} else if (onlineProxyListContent === null) {
proxyOnlyDisabled = restoredSettings.proxyDownloadEnabled || Boolean(restoredSettings.proxyListPath.trim());
restoredSettings = normalizeSettings({
...restoredSettings,
proxyDownloadEnabled: false,
proxyListPath: ""
});
}
this.overlayLiveUsageCounters(restoredSettings);
rollback = this.createBackupImportRollback(false);
const managedProxyListPath = typeof onlineProxyListContent === "string"
? getManagedOnlineProxyListPath(this.storagePaths.baseDir)
: null;
rollback = this.createBackupImportRollback(false, managedProxyListPath ? [managedProxyListPath] : []);
if (typeof onlineProxyListContent === "string") {
writeImportedOnlineProxyList(this.storagePaths.baseDir, onlineProxyListContent);
proxyListRestored = true;
}
saveSettings(this.storagePaths, restoredSettings);
remoteRestore = restoreRemoteDiagnostics
? this.persistRemoteDiagnosticsFromBackup(remoteDiagnostics)
@@ -651,6 +691,7 @@ export class AppController {
throw error;
}
this.auditRemoteDiagnosticsRestore(remoteRestore, restoreRemoteDiagnostics);
return { proxyListRestored, proxyOnlyDisabled };
}
public updateSettings(partial: Partial<AppSettings>): AppSettings {
@@ -720,6 +761,9 @@ export class AppController {
const applied = applyAccountCommand(this.settings, command);
let checkedStatus: DebridAccountStatus | null = null;
const redactions = collectAccountStatusRedactionValues(applied.settings, command);
if (command.action !== "delete" && ["megadebrid-api", "megadebrid-web", "debridlink-api", "realdebrid-api", "deepbrid-api"].includes(command.kind)) {
this.assertProxyOnlyAccountSetup();
}
if (command.action !== "delete" && applied.response.accountId && (command.kind === "megadebrid-api" || command.kind === "megadebrid-web")) {
const mode = command.kind === "megadebrid-web" ? "web" : "api";
const account = getMegaDebridAccountsForMode(applied.settings, mode).find((entry) => entry.id === applied.response.accountId);
@@ -743,6 +787,7 @@ export class AppController {
checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions);
}
if (checkedStatus && !checkedStatus.valid) {
this.throwProxyOnlyAccountFailure(checkedStatus.message || "");
throw new Error(checkedStatus.message || "Zugangsdaten ungültig");
}
this.updateSettings(applied.settings);
@@ -766,6 +811,7 @@ export class AppController {
}
public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise<DebridAccountStatus> {
this.assertProxyOnlyAccountSetup();
const redactions = collectAccountStatusRedactionValues(this.settings, input);
if (input.kind === "deepbrid-api") {
const key = input.secret?.trim() || this.settings.deepbridApiKey.trim();
@@ -774,6 +820,7 @@ export class AppController {
if (!input.secret && input.accountId === "svc-deepbrid" && this.settings.deepbridApiKey.trim()) {
this.manager.applyDebridAccountStatuses([status]);
}
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
return status;
}
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
@@ -802,6 +849,7 @@ export class AppController {
if (!input.secret && getRealDebridAccounts(this.settings).some((entry) => entry.id === status.accountId)) {
this.manager.applyDebridAccountStatuses([status]);
}
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
return status;
}
if (input.kind === "megadebrid-api" || input.kind === "megadebrid-web") {
@@ -814,6 +862,7 @@ export class AppController {
if (!input.secret && getMegaDebridAccountsForMode(this.settings, mode).some((entry) => entry.id === status.accountId)) {
this.manager.applyDebridAccountStatuses([status]);
}
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
return status;
}
const key = input.secret?.trim()
@@ -824,6 +873,7 @@ export class AppController {
if (!input.secret && parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).some((entry) => entry.id === status.accountId)) {
this.manager.applyDebridAccountStatuses([status]);
}
if (!status.valid) this.throwProxyOnlyAccountFailure(status.message || "");
return status;
}
@@ -965,6 +1015,7 @@ export class AppController {
}
public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise<void> {
this.assertProxyOnlyAccountSetup();
const accountId = String(request.accountId || "").trim();
if (!isRealDebridWebAccountId(accountId)) {
throw new Error("Account-Payload ist ungültig");
@@ -1060,8 +1111,9 @@ export class AppController {
}
}
public async openAllDebridLoginWindow(): Promise<void> {
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
public async openAllDebridLoginWindow(): Promise<void> {
this.assertProxyOnlyAccountSetup();
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
await this.allDebridWebFallback.openLoginWindow();
}
@@ -1090,6 +1142,7 @@ export class AppController {
}
public async checkDebridAccounts(scope: AccountCheckScope = "active"): Promise<DebridAccountStatus[]> {
this.assertProxyOnlyAccountSetup();
const checkedStatuses = sanitizeDebridAccountStatuses(
await checkAllDebridAccounts(
this.settings,
@@ -1100,6 +1153,12 @@ export class AppController {
collectAccountStatusRedactionValues(this.settings)
);
const statuses = retainConfiguredRealDebridStatuses(this.settings, checkedStatuses);
const proxyFailure = statuses.find((status) => !status.valid && resolveProxyOnlyAccountErrorCode(
this.settings,
getNetworkProxyState(),
status.message || ""
));
if (proxyFailure) this.throwProxyOnlyAccountFailure(proxyFailure.message || "");
this.manager.applyDebridAccountStatuses(statuses);
this.audit("INFO", "Debrid-Accounts geprueft", {
total: statuses.length,
@@ -1357,20 +1416,28 @@ export class AppController {
}
public async exportOnlineBackup(): Promise<{ key: string }> {
const created = createOnlineBackup({ ...this.settings }, APP_VERSION);
const proxyListContent = captureOnlineProxyList(this.settings);
const created = createOnlineBackup({ ...this.settings }, APP_VERSION, undefined, proxyListContent);
await uploadOnlineBackup(created.record, ONLINE_BACKUP_API_URL);
this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only" });
this.audit("INFO", "Online-Sicherung erstellt", { kind: "settings-only", proxyListIncluded: proxyListContent !== undefined });
return { key: created.key };
}
public async importOnlineBackup(key: string): Promise<{ restored: boolean; relaunch: false; message: string }> {
const payload = await downloadOnlineBackup(key, ONLINE_BACKUP_API_URL);
await this.applySettingsOnlyBackup(payload.settings);
const result = await this.applySettingsOnlyBackup(payload.settings, undefined, false, payload.proxyList?.content ?? null);
this.audit("INFO", "Online-Sicherung importiert", {
kind: "settings-only",
proxyListRestored: result.proxyListRestored,
proxyOnlyDisabled: result.proxyOnlyDisabled,
accountSummary: buildAccountSummary(this.settings)
});
return { restored: true, relaunch: false, message: "Einstellungen aus Online-Sicherung wiederhergestellt" };
const message = result.proxyListRestored
? "Einstellungen und Proxy-Liste aus Online-Sicherung wiederhergestellt"
: result.proxyOnlyDisabled
? "Einstellungen wiederhergestellt; Proxy-only wurde deaktiviert, weil die Online-Sicherung keine Proxy-Liste enthält"
: "Einstellungen aus Online-Sicherung wiederhergestellt";
return { restored: true, relaunch: false, message };
}
public async exportSupportBundle(): Promise<{ buffer: Buffer; defaultFileName: string }> {
+33 -5
View File
@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import zlib from "node:zlib";
import type { AppSettings } from "../shared/types";
import { MAX_ONLINE_PROXY_LIST_BYTES, validateOnlineProxyListContent } from "./online-proxy-list";
const KEY_PREFIX = "MDD2-";
const KEY_BODY_LENGTH = 70;
@@ -12,7 +13,8 @@ const AUTH_TAG_LENGTH = 16;
const BLOB_VERSION = 1;
const MAX_BLOB_BYTES = 256 * 1024;
const MAX_RESPONSE_BYTES = 512 * 1024;
const MAX_PLAINTEXT_BYTES = 512 * 1024;
const MAX_SETTINGS_PLAINTEXT_BYTES = 512 * 1024;
const MAX_PLAINTEXT_BYTES = 9 * 1024 * 1024;
const REQUEST_TIMEOUT_MS = 12_000;
const KEY_CONTEXT = Buffer.from("MDD2-ONLINE-KEY-V1", "utf8");
const AAD_CONTEXT = Buffer.from("MDD-ONLINE-BACKUP-V1", "utf8");
@@ -23,6 +25,10 @@ export interface OnlineSettingsPayload {
appVersion: string;
exportedAt: string;
settings: AppSettings;
proxyList?: {
version: 1;
content: string;
};
}
export interface OnlineBackupRecord {
@@ -81,6 +87,13 @@ function validatePayload(value: unknown): OnlineSettingsPayload {
) {
throw new Error("Online-Sicherung enthält keine gültigen Einstellungen");
}
if (record.proxyList !== undefined) {
const proxyList = record.proxyList as Record<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;
}
@@ -152,22 +165,35 @@ export function parseOnlineBackupKey(key: string): ParsedOnlineBackupKey {
return { id: idBytes.toString("base64url"), idBytes: Buffer.from(idBytes), masterKey: Buffer.from(masterKey) };
}
export function createOnlineBackup(settings: AppSettings, appVersion: string, exportedAt = new Date().toISOString()): CreatedOnlineBackup {
export function createOnlineBackup(
settings: AppSettings,
appVersion: string,
exportedAt = new Date().toISOString(),
proxyListContent?: string
): CreatedOnlineBackup {
if (proxyListContent !== undefined) validateOnlineProxyListContent(proxyListContent);
const idBytes = crypto.randomBytes(RECORD_ID_LENGTH);
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
const key = encodeKey(idBytes, masterKey);
const encryptionKey = deriveSecret(masterKey, idBytes, "ENCRYPTION");
const nonce = crypto.randomBytes(NONCE_LENGTH);
const settingsSnapshot = JSON.parse(JSON.stringify(settings)) as AppSettings;
if (Buffer.byteLength(JSON.stringify(settingsSnapshot), "utf8") > MAX_SETTINGS_PLAINTEXT_BYTES) {
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
}
const payload: OnlineSettingsPayload = {
version: 1,
kind: "settings-only",
appVersion,
exportedAt,
settings: JSON.parse(JSON.stringify(settings)) as AppSettings
settings: settingsSnapshot,
...(proxyListContent === undefined ? {} : { proxyList: { version: 1 as const, content: proxyListContent } })
};
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
if (plaintext.length > MAX_PLAINTEXT_BYTES) {
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
throw new Error(proxyListContent === undefined
? "Einstellungen sind für eine Online-Sicherung zu groß"
: "Einstellungen und Proxy-Liste sind für eine Online-Sicherung zu groß");
}
const compressed = zlib.gzipSync(plaintext, { level: 9 });
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH });
@@ -175,7 +201,9 @@ export function createOnlineBackup(settings: AppSettings, appVersion: string, ex
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]);
if (blobBytes.length > MAX_BLOB_BYTES) {
throw new Error("Einstellungen sind für eine Online-Sicherung zu groß");
throw new Error(proxyListContent === undefined
? "Einstellungen sind für eine Online-Sicherung zu groß"
: "Einstellungen und Proxy-Liste sind für eine Online-Sicherung zu groß");
}
const parsed = parseOnlineBackupKey(key);
const deleteVerifier = crypto.createHash("sha256").update(deriveDeleteSecret(parsed)).digest("base64url");
+67
View File
@@ -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;
}
}
+33
View File
@@ -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}`);
}
+44 -31
View File
@@ -40,8 +40,8 @@ import {
getProviderUsageDayKey
} from "../shared/provider-daily-limits";
import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./package-order";
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, shouldClearDownloadSelection } from "./selection";
import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui";
import { pruneSelection, releaseAccountSelectionFocus, resolveEscapeSelectionScope, resolveSelectAllSelectionScope, shouldClearDownloadSelection } from "./selection";
import { buildConfiguredProviderOrder, createAccountToggleQueue, enqueueAccountToggleIntent, filterAccountDialogOptions, formatAccountOperationError, getAccountDialogSelectableOptions, getAvailableAccountOptions, mergeAccountToggleSettings, pruneAccountRowSelections, resolveAccountStatusState, resolveAccountToggleIntentEnabled, resolveAccountUsername, resolveVisibleAccountKind, sortAccountServices, updateAccountRowSelection, type AccountToggleTarget } from "./account-ui";
import { buildAccountDeleteCommand, buildAccountReplaceCommand, buildAccountSecretRequest, createAccountEditState, validateAccountEdit } from "./account-edit";
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
@@ -1920,6 +1920,11 @@ export function App(): ReactElement {
const [settingsSubTab, setSettingsSubTab] = useState<SettingsSection>("allgemein");
const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules" | "runtime">("overview");
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 [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
@@ -3119,8 +3124,8 @@ export function App(): ReactElement {
await persistDraftSettings();
await window.rd.openRealDebridLogin();
showToast("Real-Debrid Login-Fenster geöffnet", 2200);
}, (error) => {
showToast(`Real-Debrid Login fehlgeschlagen: ${String(error)}`, 2800);
}, (error) => {
showToast(formatAccountOperationError("Real-Debrid Login fehlgeschlagen", error), 3200);
});
};
@@ -3129,8 +3134,8 @@ export function App(): ReactElement {
await persistDraftSettings();
await window.rd.openAllDebridLogin();
showToast("AllDebrid Login-Fenster geöffnet", 2200);
}, (error) => {
showToast(`AllDebrid Login fehlgeschlagen: ${String(error)}`, 2800);
}, (error) => {
showToast(formatAccountOperationError("AllDebrid Login fehlgeschlagen", error), 3200);
});
};
@@ -3231,8 +3236,8 @@ export function App(): ReactElement {
const label = scope === "active" ? "Aktive Accounts" : "Alle Accounts";
showToast(`${label}: ${valid}/${statuses.length} Login gültig, ${premium} mit Premium.`, 3600);
}
} catch (error) {
showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3600);
} catch (error) {
showToast(formatAccountOperationError("Account-Check fehlgeschlagen", error), 3600);
} finally {
setAccountCheckBusy(false);
}
@@ -3312,7 +3317,7 @@ export function App(): ReactElement {
}
});
}, (error) => {
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
showToast(formatAccountOperationError("Account konnte nicht gespeichert werden", error), 3600);
});
};
@@ -3357,8 +3362,8 @@ export function App(): ReactElement {
}
void checkAccounts("active");
});
}, (error) => {
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
}, (error) => {
showToast(formatAccountOperationError("Account konnte nicht gespeichert werden", error), 3600);
});
};
@@ -3427,7 +3432,7 @@ export function App(): ReactElement {
pendingAccountTogglesRef.current = settledPending;
setPendingAccountToggles(settledPending);
if (result.status === "failed") {
showToast(`${subject}: Umschalten fehlgeschlagen: ${String(result.error)}`, 3200);
showToast(formatAccountOperationError(`${subject}: Umschalten fehlgeschlagen`, result.error), 3600);
return;
}
showToast(`${subject} ${requestedEnabled ? "aktiviert" : "deaktiviert"}`, 2200);
@@ -3442,7 +3447,7 @@ export function App(): ReactElement {
await performQuickAction(async () => {
await runAccountQuickAction(meta.action, row.accountId);
}, (error) => {
showToast(`${row.entry.serviceLabel}: Aktion fehlgeschlagen: ${String(error)}`, 3200);
showToast(formatAccountOperationError(`${row.entry.serviceLabel}: Aktion fehlgeschlagen`, error), 3600);
});
};
@@ -3518,7 +3523,7 @@ export function App(): ReactElement {
void performQuickAction(async () => {
const status = await window.rd.checkAccountCredentials({ kind, accountId });
showToast(status.valid ? "Account erfolgreich geprüft" : status.message || "Zugangsdaten ungültig", 2600);
}, (error) => showToast(`Prüfung fehlgeschlagen: ${String(error)}`, 3200));
}, (error) => showToast(formatAccountOperationError("Prüfung fehlgeschlagen", error), 3600));
return;
}
if (row.checkable) {
@@ -5233,24 +5238,29 @@ export function App(): ReactElement {
setOpenMenu(null);
void onImportDlcRef.current();
return;
}
if (!e.shiftKey && e.key.toLowerCase() === "a") {
if (inInput) return;
if (tabRef.current === "downloads") {
e.preventDefault();
// Select exactly the VISIBLE rows (packages + their items), honouring
// the active search / collapse / hide-extracted filters — selecting
// the unfiltered package map would let a later delete hit hidden ones.
}
if (!e.shiftKey && e.key.toLowerCase() === "a") {
const inputType = target.tagName === "INPUT" ? (target as HTMLInputElement).type : "";
const selectionScope = resolveSelectAllSelectionScope(
tabRef.current,
settingsSubTabRef.current,
accountManagementTabRef.current,
target.tagName,
inputType
);
if (!selectionScope) return;
e.preventDefault();
if (selectionScope === "downloads") {
setSelectedIds(new Set(visibleOrderIdsRef.current));
} else if (tabRef.current === "collector") {
e.preventDefault();
} else if (selectionScope === "collector") {
setSelectedCollectorLinkIds((current) => setCollectorVisibleSelection(current, collectorVisibleIdsRef.current, true));
} else if (tabRef.current === "history") {
e.preventDefault();
} else if (selectionScope === "history") {
setSelectedHistoryIds((current) => selectHistoryPageFromShortcut(current, historyVisibleIdsRef.current));
} else {
setSelectedAccountRowKeys(new Set(visibleAccountRowKeysRef.current));
}
return;
}
return;
}
}
};
window.addEventListener("keydown", handler);
@@ -5768,6 +5778,9 @@ export function App(): ReactElement {
const visibleAccountRows = useMemo(() => accountStatusSort === "none"
? projectedAccountRows
: sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, projectedAccountRows]);
visibleAccountRowKeysRef.current = visibleAccountRows
.map((row) => accountRowBindings.get(row.id)?.rowKey)
.filter((rowKey): rowKey is string => Boolean(rowKey));
const accountRuntimeModel = useMemo<AccountWorkspaceViewModel["runtime"]>(() => {
const runtimeEntries = snapshot.accountRuntime || [];
const runtimeByAccountId = new Map(runtimeEntries.map((entry) => [`${entry.provider}:${entry.accountId}`, entry]));
@@ -6311,7 +6324,7 @@ export function App(): ReactElement {
} else {
showToast("Für diesen Dienst ist keine direkte Statusprüfung verfügbar.", 2800);
}
}, (error) => showToast(`Prüfung fehlgeschlagen: ${String(error)}`, 3200));
}, (error) => showToast(formatAccountOperationError("Prüfung fehlgeschlagen", error), 3600));
};
const accountEditDialogView = accountEditDialog && accountEditOption ? (
<AccountEditDialog
@@ -6807,8 +6820,8 @@ export function App(): ReactElement {
>
<p>
{onlineBackupDialog.mode === "export"
? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf."
: "Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt."}
? "Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf."
: "Füge den vollständigen MDD2-Schlüssel ein. Einstellungen und eine enthaltene Proxy-Liste werden durch die gespeicherte Version ersetzt."}
</p>
{onlineBackupDialog.mode === "export" && onlineBackupDialog.busy && <div className="online-backup-status">Online-Sicherung wird verschlüsselt und gespeichert </div>}
{onlineBackupDialog.mode === "export" && onlineBackupDialog.key && (
+15
View File
@@ -1,5 +1,20 @@
import type { DebridProvider, RendererSettings, RendererSettingsUpdate } from "../shared/types";
const proxyOnlyAccountMessages = {
proxy_list_missing: "Proxy-only ist aktiviert, aber es ist keine Proxy-Liste hinterlegt. Hinterlege sie unter Einstellungen → Geschwindigkeit.",
proxy_list_unreadable: "Proxy-only ist aktiviert, aber die hinterlegte Proxy-Liste kann nicht gelesen werden. Prüfe die Datei unter Einstellungen → Geschwindigkeit.",
proxy_list_empty: "Proxy-only ist aktiviert, aber die hinterlegte Proxy-Liste ist leer oder enthält keine gültigen HTTP-Proxys.",
proxy_index_unavailable: "Proxy-only ist aktiviert, aber der feste API-Proxy ist in der Liste nicht verfügbar. Prüfe den Listeneintrag unter Einstellungen → Geschwindigkeit.",
proxy_unreachable: "Proxy-only ist aktiviert, aber der feste API-Proxy ist nicht erreichbar oder lehnt die Verbindung ab. Prüfe den Proxy unter Einstellungen → Geschwindigkeit."
} as const;
export function formatAccountOperationError(prefix: string, error: unknown): string {
const raw = String(error);
const match = raw.match(/proxy_only_account:(proxy_list_missing|proxy_list_unreadable|proxy_list_empty|proxy_index_unavailable|proxy_unreachable)/);
const detail = match ? proxyOnlyAccountMessages[match[1] as keyof typeof proxyOnlyAccountMessages] : raw;
return `${prefix}: ${detail}`;
}
export type AccountToggleTarget =
| { type: "provider"; provider: DebridProvider }
| { type: "realdebrid"; accountId: string }
+9 -2
View File
@@ -176,8 +176,10 @@ const pairs = [
["Text mit Links analysieren", "Analyze text containing links"], ["Online-Schlüssel exportieren", "Export online key"], ["Online-Schlüssel importieren", "Import online key"], ["Logs öffnen", "Open logs"],
["Support-Bundle exportieren", "Export support bundle"], ["Support-Trace deaktivieren", "Disable support trace"], ["Support-Trace aktivieren", "Enable support trace"], ["Letzte Fehler anzeigen", "Show recent errors"],
["Einträge:", "Entries:"], ["Ausgewählt:", "Selected:"], ["Online-Schlüssel", "Online key"],
["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials. Keep it as secure as a password."],
["Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. The current settings will be replaced by the saved version."],
["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten und hinterlegter Proxy-Liste wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials and the configured proxy list. Keep it as secure as a password."],
["Füge den vollständigen MDD2-Schlüssel ein. Einstellungen und eine enthaltene Proxy-Liste werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. Settings and any included proxy list will be replaced by the stored version."],
["Einstellungen und Proxy-Liste aus Online-Sicherung wiederhergestellt", "Settings and proxy list restored from online backup"],
["Einstellungen wiederhergestellt; Proxy-only wurde deaktiviert, weil die Online-Sicherung keine Proxy-Liste enthält", "Settings restored; Proxy-only was disabled because the online backup contains no proxy list"],
["Online-Sicherung wird verschlüsselt und gespeichert …", "Online backup is being encrypted and saved …"], ["Online-Sicherungsschlüssel", "Online backup key"], ["Online-Sicherungsschlüssel eingeben", "Enter online backup key"], ["Wird geladen …", "Loading …"],
["Ermöglicht einer vertrauenswürdigen Support-Stelle den geschützten Lesezugriff auf Status, Logs und Fehler. Der Verbindungscode enthält das Zugriffstoken und ist wie ein Passwort zu behandeln.", "Allows a trusted support contact protected read access to status, logs and errors. The connection code contains the access token and must be treated like a password."],
["Oeffentliche Adresse (fuer den Verbindungscode)", "Public address (for the connection code)"], ["Allowlist - erlaubte IPs/CIDR (eine pro Zeile)", "Allowlist - permitted IPs/CIDRs (one per line)"],
@@ -195,6 +197,11 @@ const pairs = [
["DDownload Login", "DDownload login"], ["Debrid-Link API", "Debrid-Link API"], ["LinkSnappy Web-Login", "LinkSnappy web login"],
["Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.", "Daily limit reached. New links will switch to the next hoster."], ["Mega-Debrid: Bitte Login und Passwort eintragen.", "Mega-Debrid: Enter a login and password."],
["Dieser Mega-Debrid-Account ist bereits vorhanden.", "This Mega-Debrid account already exists."], ["Debrid-Link: Bitte genau einen API-Key eintragen.", "Debrid-Link: Enter exactly one API key."],
["Proxy-only ist aktiviert, aber es ist keine Proxy-Liste hinterlegt. Hinterlege sie unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but no proxy list is configured. Add one under Settings → Speed."],
["Proxy-only ist aktiviert, aber die hinterlegte Proxy-Liste kann nicht gelesen werden. Prüfe die Datei unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but the configured proxy list cannot be read. Check the file under Settings → Speed."],
["Proxy-only ist aktiviert, aber die hinterlegte Proxy-Liste ist leer oder enthält keine gültigen HTTP-Proxys.", "Proxy-only is enabled, but the configured proxy list is empty or contains no valid HTTP proxies."],
["Proxy-only ist aktiviert, aber der feste API-Proxy ist in der Liste nicht verfügbar. Prüfe den Listeneintrag unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but the fixed API proxy is not available in the list. Check the list entry under Settings → Speed."],
["Proxy-only ist aktiviert, aber der feste API-Proxy ist nicht erreichbar oder lehnt die Verbindung ab. Prüfe den Proxy unter Einstellungen → Geschwindigkeit.", "Proxy-only is enabled, but the fixed API proxy is unreachable or refuses the connection. Check the proxy under Settings → Speed."],
["Die Prüfung hat nicht genau den neuen Account bestätigt.", "The check did not confirm exactly the new account."], ["Nur lokal gebunden", "Bound locally only"], ["(nur lokal)", "(local only)"], ["Nur lokal", "Local only"],
["Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante.", "Binds only to 127.0.0.1. Remote access only through a tunnel (such as Tailscale/SSH) - the safest option."],
["Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen.", "Binds to 0.0.0.0. Reachable on the network and requires an allowlist. Use only on trusted networks or VPNs."],
+19
View File
@@ -13,6 +13,25 @@ export function shouldClearDownloadSelectionOnEscape(tagName: string, inputType
return ["checkbox", "radio", "button"].includes(inputType.toLowerCase());
}
export function resolveSelectAllSelectionScope(
view: string,
settingsSection: string,
accountPanel: string,
tagName: string,
inputType = ""
): "downloads" | "collector" | "history" | "accounts" | null {
const normalizedTag = tagName.toUpperCase();
if (view === "settings" && settingsSection === "accounts" && accountPanel === "overview") {
if (normalizedTag === "TEXTAREA") return null;
if (normalizedTag === "INPUT" && !["checkbox", "radio", "button"].includes(inputType.toLowerCase())) return null;
return "accounts";
}
if (normalizedTag === "INPUT" || normalizedTag === "TEXTAREA") {
return null;
}
return view === "downloads" || view === "collector" || view === "history" ? view : null;
}
export function resolveEscapeSelectionScope(
view: string,
settingsSection: string,