From 26d62a933711afb7f3f8677e0cd9870ee3954d7b Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 11 Aug 2026 23:22:44 +0200 Subject: [PATCH] Harden renderer account IPC boundary Move secret-bearing account and settings state behind main-process boundaries for Task 1B. Renderer snapshots now expose RendererSettings plus safe account metadata instead of full AppSettings, with provider tokens, passwords, API keys, archive passwords, and notification URLs excluded from renderer-bound state. Add write-only account create, replace, update-secret, and delete IPC commands, validate renderer settings updates against the safe shape, and keep account command results limited to safe settings, safe accounts, and stable account IDs. Preserve existing account behavior while removing renderer secret access: blank replace secrets retain stored main-process secrets, Mega-Debrid API/Web pools stay mode-specific, Debrid-Link key metadata migrates by stable key ID, and delete/enable operations target stable account or provider identities. Remove obsolete renderer-side account status helpers from the old settings snapshot flow. Add focused coverage for all supported renderer account kinds, secret-free UiSnapshot serialization, preload account command forwarding, malformed payload error sanitization, Mega-Debrid preferApi preservation, Debrid-Link key metadata migration, account edit safety, settings UI, debug server settings payloads, link export, and visual fixtures. --- src/main/account-commands.ts | 466 +++++++++ src/main/app-controller.ts | 93 +- src/main/download-manager.ts | 37 +- src/main/main.ts | 63 +- src/main/renderer-settings.ts | 83 ++ src/main/renderer-state.ts | 264 +++++ src/preload/preload.ts | 31 +- src/renderer/App.tsx | 909 +++++------------- src/renderer/account-edit.ts | 584 ++--------- src/renderer/views/settings/settings-model.ts | 14 +- src/shared/ipc.ts | 14 +- src/shared/preload-api.ts | 31 +- src/shared/types.ts | 178 +++- tests/account-commands.test.ts | 350 +++++++ tests/account-dialog-prefer-api.test.ts | 99 +- tests/account-edit.test.ts | 385 ++------ tests/account-preload.test.ts | 64 ++ tests/debug-server.test.ts | 27 +- tests/link-export.test.ts | 5 +- tests/renderer-state.test.ts | 131 +++ tests/settings-view.test.tsx | 31 +- tests/visual-fixtures.test.ts | 27 +- tests/visual/fixtures.ts | 18 +- tests/visual/mock-electron-api.ts | 36 +- 24 files changed, 2240 insertions(+), 1700 deletions(-) create mode 100644 src/main/account-commands.ts create mode 100644 src/main/renderer-settings.ts create mode 100644 src/main/renderer-state.ts create mode 100644 tests/account-commands.test.ts create mode 100644 tests/account-preload.test.ts create mode 100644 tests/renderer-state.test.ts diff --git a/src/main/account-commands.ts b/src/main/account-commands.ts new file mode 100644 index 0000000..68e8140 --- /dev/null +++ b/src/main/account-commands.ts @@ -0,0 +1,466 @@ +import { getDebridLinkApiKeyId, parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { + getMegaDebridAccountId, + getMegaDebridAccountsForMode, + getMegaDebridCredentialsForMode, + getMegaDebridDisabledAccountIdsForMode, + mergeMegaDebridCredentialPools, + parseMegaDebridAccounts, + serializeMegaDebridAccounts, + type MegaDebridAccountMode +} from "../shared/mega-debrid-accounts"; +import type { AccountCommand, AccountCredentialCheckInput, AppSettings, DebridProvider, RendererAccountKind } from "../shared/types"; + +export interface AppliedAccountCommand { + settings: AppSettings; + response: { accountId: string | null }; +} + +const ACCOUNT_KINDS = new Set([ + "realdebrid-api", + "realdebrid-web", + "megadebrid-api", + "megadebrid-web", + "bestdebrid-api", + "bestdebrid-web", + "alldebrid-api", + "alldebrid-web", + "ddownload-login", + "onefichier-api", + "debridlink-api", + "linksnappy-login" +]); + +const ACTION_FIELDS: Record> = { + create: new Set(["action", "kind", "identity", "secret", "dailyLimitBytes"]), + replace: new Set(["action", "kind", "accountId", "identity", "secret", "dailyLimitBytes"]), + "update-secret": new Set(["action", "kind", "accountId", "secret"]), + delete: new Set(["action", "kind", "accountId"]) +}; + +function invalid(): never { + throw new Error("Account-Payload ist ungültig"); +} + +function optionalString(value: unknown, maxLength: number): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "string" || value.length > maxLength) { + invalid(); + } + return value; +} + +function requiredString(value: unknown, maxLength: number): string { + const result = optionalString(value, maxLength); + if (result === undefined || !result.trim()) { + invalid(); + } + return result; +} + +function optionalLimit(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + invalid(); + } + return value; +} + +export function validateAccountCommand(value: unknown): AccountCommand { + if (!value || typeof value !== "object" || Array.isArray(value)) { + invalid(); + } + const raw = value as Record; + const action = raw.action; + if (action !== "create" && action !== "replace" && action !== "update-secret" && action !== "delete") { + invalid(); + } + if (Object.keys(raw).some((key) => !ACTION_FIELDS[action].has(key))) { + invalid(); + } + if (typeof raw.kind !== "string" || !ACCOUNT_KINDS.has(raw.kind as RendererAccountKind)) { + invalid(); + } + const kind = raw.kind as RendererAccountKind; + if (action === "create") { + return { + action, + kind, + identity: optionalString(raw.identity, 512), + secret: optionalString(raw.secret, 100_000), + dailyLimitBytes: optionalLimit(raw.dailyLimitBytes) + }; + } + const accountId = requiredString(raw.accountId, 256).trim(); + if (action === "replace") { + return { + action, + kind, + accountId, + identity: optionalString(raw.identity, 512), + secret: optionalString(raw.secret, 100_000), + dailyLimitBytes: optionalLimit(raw.dailyLimitBytes) + }; + } + if (action === "update-secret") { + return { + action, + kind, + accountId, + secret: requiredString(raw.secret, 100_000) + }; + } + return { action, kind, accountId }; +} + +export function validateAccountCredentialCheckInput(value: unknown): AccountCredentialCheckInput { + if (!value || typeof value !== "object" || Array.isArray(value)) invalid(); + const raw = value as Record; + if (Object.keys(raw).some((key) => !new Set(["kind", "accountId", "identity", "secret"]).has(key))) invalid(); + if (raw.kind !== "megadebrid-api" && raw.kind !== "megadebrid-web" && raw.kind !== "debridlink-api") invalid(); + return { + kind: raw.kind, + accountId: optionalString(raw.accountId, 256), + identity: optionalString(raw.identity, 512), + secret: optionalString(raw.secret, 100_000) + }; +} + +function withoutKeys(record: Record, ...keys: string[]): Record { + const removed = new Set(keys); + return Object.fromEntries(Object.entries(record).filter(([key]) => !removed.has(key))); +} + +function migrateIdList(values: readonly string[], oldId: string, newId: string): string[] { + const retained = values.filter((value) => value !== oldId && value !== newId); + if (values.includes(oldId)) { + retained.push(newId); + } + return retained; +} + +function setLimit(record: Record, id: string, value: number | undefined): Record { + const result = { ...record }; + if (value === undefined) { + return result; + } + if (value > 0) { + result[id] = value; + } else { + delete result[id]; + } + return result; +} + +function migrateLimit(record: Record, oldId: string, newId: string, value: number | undefined): Record { + const result = withoutKeys(record, oldId, newId); + const resolved = value === undefined ? record[oldId] : value; + if (resolved && resolved > 0) { + result[newId] = resolved; + } + return result; +} + +function validateIdentity(identity: string): string { + const trimmed = identity.trim(); + if (!trimmed || /[:\r\n]/.test(trimmed)) { + invalid(); + } + return trimmed; +} + +function validateSecret(secret: string): string { + if (!secret.trim() || /[\r\n]/.test(secret)) { + invalid(); + } + return secret; +} + +function megaMode(kind: RendererAccountKind): MegaDebridAccountMode { + return kind === "megadebrid-web" ? "web" : "api"; +} + +function writeMegaPools(settings: AppSettings, apiCredentials: string, webCredentials: string): AppSettings { + const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials); + const first = parseMegaDebridAccounts(mergedCredentials)[0]; + return { + ...settings, + megaCredentials: mergedCredentials, + megaLogin: first?.login || "", + megaPassword: first?.password || "", + megaDebridApiCredentials: apiCredentials, + megaDebridWebCredentials: webCredentials, + megaDebridApiEnabled: settings.megaDebridApiEnabled && Boolean(apiCredentials), + megaDebridWebEnabled: settings.megaDebridWebEnabled && Boolean(webCredentials) + }; +} + +function createMega(settings: AppSettings, command: Extract): AppliedAccountCommand { + const mode = megaMode(command.kind); + const identity = validateIdentity(command.identity || ""); + const secret = validateSecret(command.secret || ""); + const accounts = getMegaDebridAccountsForMode(settings, mode); + if (accounts.some((account) => account.login.toLowerCase() === identity.toLowerCase())) { + invalid(); + } + const selectedCredentials = serializeMegaDebridAccounts([...accounts, { login: identity, password: secret }]); + const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api"); + const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web"); + const accountId = getMegaDebridAccountId(identity); + const next = writeMegaPools(settings, apiCredentials, webCredentials); + if (mode === "api") { + next.megaDebridApiEnabled = true; + } else { + next.megaDebridWebEnabled = true; + } + next.megaDebridAccountDailyLimitBytes = setLimit(settings.megaDebridAccountDailyLimitBytes, accountId, command.dailyLimitBytes); + return { settings: next, response: { accountId } }; +} + +function replaceMega(settings: AppSettings, command: Extract): AppliedAccountCommand { + const mode = megaMode(command.kind); + const accounts = getMegaDebridAccountsForMode(settings, mode); + const index = accounts.findIndex((account) => account.id === command.accountId); + if (index < 0) { + invalid(); + } + const current = accounts[index]; + const identity = command.identity === undefined ? current.login : validateIdentity(command.identity); + const secret = command.secret?.trim() ? validateSecret(command.secret) : current.password; + if (accounts.some((account, accountIndex) => accountIndex !== index && account.login.toLowerCase() === identity.toLowerCase())) { + invalid(); + } + const accountId = getMegaDebridAccountId(identity); + const selectedCredentials = serializeMegaDebridAccounts(accounts.map((account, accountIndex) => accountIndex === index + ? { login: identity, password: secret } + : { login: account.login, password: account.password })); + const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api"); + const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web"); + const next = writeMegaPools(settings, apiCredentials, webCredentials); + const selectedDisabled = migrateIdList(getMegaDebridDisabledAccountIdsForMode(settings, mode), command.accountId, accountId); + if (mode === "api") { + next.megaDebridApiDisabledAccountIds = selectedDisabled; + } else { + next.megaDebridWebDisabledAccountIds = selectedDisabled; + } + next.megaDebridDisabledAccountIds = [...new Set([...next.megaDebridApiDisabledAccountIds, ...next.megaDebridWebDisabledAccountIds])]; + next.megaDebridAccountDailyLimitBytes = migrateLimit(settings.megaDebridAccountDailyLimitBytes, command.accountId, accountId, command.dailyLimitBytes); + if (command.accountId !== accountId) { + next.megaDebridAccountDailyUsageBytes = withoutKeys(settings.megaDebridAccountDailyUsageBytes, command.accountId, accountId); + next.megaDebridAccountTotalUsageBytes = withoutKeys(settings.megaDebridAccountTotalUsageBytes, command.accountId, accountId); + next.debridAccountStatuses = withoutKeys(settings.debridAccountStatuses, command.accountId, accountId); + } + return { settings: next, response: { accountId } }; +} + +function deleteMega(settings: AppSettings, command: Extract): AppliedAccountCommand { + const mode = megaMode(command.kind); + const selected = getMegaDebridAccountsForMode(settings, mode); + if (!selected.some((account) => account.id === command.accountId)) { + invalid(); + } + const selectedCredentials = serializeMegaDebridAccounts(selected.filter((account) => account.id !== command.accountId)); + const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api"); + const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web"); + const accountStillUsed = parseMegaDebridAccounts(mode === "api" ? webCredentials : apiCredentials).some((account) => account.id === command.accountId); + const next = writeMegaPools(settings, apiCredentials, webCredentials); + next.megaDebridApiDisabledAccountIds = mode === "api" + ? settings.megaDebridApiDisabledAccountIds.filter((id) => id !== command.accountId) + : [...settings.megaDebridApiDisabledAccountIds]; + next.megaDebridWebDisabledAccountIds = mode === "web" + ? settings.megaDebridWebDisabledAccountIds.filter((id) => id !== command.accountId) + : [...settings.megaDebridWebDisabledAccountIds]; + next.megaDebridDisabledAccountIds = [...new Set([...next.megaDebridApiDisabledAccountIds, ...next.megaDebridWebDisabledAccountIds])]; + if (!accountStillUsed) { + next.megaDebridAccountDailyLimitBytes = withoutKeys(settings.megaDebridAccountDailyLimitBytes, command.accountId); + next.megaDebridAccountDailyUsageBytes = withoutKeys(settings.megaDebridAccountDailyUsageBytes, command.accountId); + next.megaDebridAccountTotalUsageBytes = withoutKeys(settings.megaDebridAccountTotalUsageBytes, command.accountId); + next.debridAccountStatuses = withoutKeys(settings.debridAccountStatuses, command.accountId); + } + const remaining = getMegaDebridAccountsForMode(next, mode)[0]?.id || null; + return { settings: next, response: { accountId: remaining } }; +} + +function createDebridLink(settings: AppSettings, command: Extract): AppliedAccountCommand { + const secret = validateSecret(command.secret || ""); + if (/[,\r\n]/.test(secret)) { + invalid(); + } + const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys); + if (keys.some((key) => key.token === secret)) { + invalid(); + } + const accountId = getDebridLinkApiKeyId(secret); + return { + settings: { + ...settings, + debridLinkApiKeys: [...keys.map((key) => key.token), secret].join("\n"), + debridLinkApiKeyDailyLimitBytes: setLimit(settings.debridLinkApiKeyDailyLimitBytes, accountId, command.dailyLimitBytes) + }, + response: { accountId } + }; +} + +function replaceDebridLink(settings: AppSettings, command: Extract): AppliedAccountCommand { + const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys); + const index = keys.findIndex((key) => key.id === command.accountId); + if (index < 0) { + invalid(); + } + const secret = command.secret?.trim() ? validateSecret(command.secret) : keys[index].token; + if (/[,\r\n]/.test(secret) || keys.some((key, keyIndex) => keyIndex !== index && key.token === secret)) { + invalid(); + } + const accountId = getDebridLinkApiKeyId(secret); + const tokens = keys.map((key, keyIndex) => keyIndex === index ? secret : key.token); + const idChanged = accountId !== command.accountId; + return { + settings: { + ...settings, + debridLinkApiKeys: tokens.join("\n"), + debridLinkDisabledKeyIds: idChanged ? migrateIdList(settings.debridLinkDisabledKeyIds, command.accountId, accountId) : [...settings.debridLinkDisabledKeyIds], + debridLinkApiKeyDailyLimitBytes: migrateLimit(settings.debridLinkApiKeyDailyLimitBytes, command.accountId, accountId, command.dailyLimitBytes), + debridLinkApiKeyDailyUsageBytes: idChanged ? withoutKeys(settings.debridLinkApiKeyDailyUsageBytes, command.accountId, accountId) : { ...settings.debridLinkApiKeyDailyUsageBytes }, + debridLinkApiKeyTotalUsageBytes: idChanged ? withoutKeys(settings.debridLinkApiKeyTotalUsageBytes, command.accountId, accountId) : { ...settings.debridLinkApiKeyTotalUsageBytes }, + debridAccountStatuses: idChanged ? withoutKeys(settings.debridAccountStatuses, command.accountId, accountId) : { ...settings.debridAccountStatuses } + }, + response: { accountId } + }; +} + +function deleteDebridLink(settings: AppSettings, command: Extract): AppliedAccountCommand { + const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys); + if (!keys.some((key) => key.id === command.accountId)) { + invalid(); + } + const remaining = keys.filter((key) => key.id !== command.accountId); + return { + settings: { + ...settings, + debridLinkApiKeys: remaining.map((key) => key.token).join("\n"), + debridLinkDisabledKeyIds: settings.debridLinkDisabledKeyIds.filter((id) => id !== command.accountId), + debridLinkApiKeyDailyLimitBytes: withoutKeys(settings.debridLinkApiKeyDailyLimitBytes, command.accountId), + debridLinkApiKeyDailyUsageBytes: withoutKeys(settings.debridLinkApiKeyDailyUsageBytes, command.accountId), + debridLinkApiKeyTotalUsageBytes: withoutKeys(settings.debridLinkApiKeyTotalUsageBytes, command.accountId), + debridAccountStatuses: withoutKeys(settings.debridAccountStatuses, command.accountId) + }, + response: { accountId: remaining[0]?.id || null } + }; +} + +function singleProvider(kind: RendererAccountKind): DebridProvider { + if (kind.startsWith("realdebrid")) return "realdebrid"; + if (kind.startsWith("bestdebrid")) return "bestdebrid"; + if (kind.startsWith("alldebrid")) return "alldebrid"; + if (kind === "ddownload-login") return "ddownload"; + if (kind === "onefichier-api") return "onefichier"; + if (kind === "linksnappy-login") return "linksnappy"; + invalid(); +} + +function singleConfigured(settings: AppSettings, kind: RendererAccountKind): boolean { + if (kind === "realdebrid-api") return Boolean(settings.token.trim()); + if (kind === "realdebrid-web") return settings.realDebridUseWebLogin; + if (kind === "bestdebrid-api") return Boolean(settings.bestToken.trim()); + if (kind === "bestdebrid-web") return settings.bestDebridUseWebLogin; + if (kind === "alldebrid-api") return Boolean(settings.allDebridToken.trim()); + if (kind === "alldebrid-web") return settings.allDebridUseWebLogin; + if (kind === "ddownload-login") return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword); + if (kind === "onefichier-api") return Boolean(settings.oneFichierApiKey.trim()); + if (kind === "linksnappy-login") return Boolean(settings.linkSnappyLogin.trim() && settings.linkSnappyPassword); + return false; +} + +function setSingle(settings: AppSettings, kind: RendererAccountKind, identity: string | undefined, secret: string | undefined): AppSettings { + if (kind === "realdebrid-api") return { ...settings, token: validateSecret(secret || ""), realDebridUseWebLogin: false }; + if (kind === "realdebrid-web") return { ...settings, token: "", realDebridUseWebLogin: true }; + if (kind === "bestdebrid-api") return { ...settings, bestToken: validateSecret(secret || ""), bestDebridUseWebLogin: false }; + if (kind === "bestdebrid-web") return { ...settings, bestToken: "", bestDebridUseWebLogin: true }; + if (kind === "alldebrid-api") return { ...settings, allDebridToken: validateSecret(secret || ""), allDebridUseWebLogin: false }; + if (kind === "alldebrid-web") return { ...settings, allDebridToken: "", allDebridUseWebLogin: true }; + if (kind === "ddownload-login") return { ...settings, ddownloadLogin: validateIdentity(identity || ""), ddownloadPassword: validateSecret(secret || "") }; + if (kind === "onefichier-api") return { ...settings, oneFichierApiKey: validateSecret(secret || "") }; + if (kind === "linksnappy-login") return { ...settings, linkSnappyLogin: validateIdentity(identity || ""), linkSnappyPassword: validateSecret(secret || "") }; + invalid(); +} + +function replaceSingle(settings: AppSettings, command: Extract): AppliedAccountCommand { + if (!singleConfigured(settings, command.kind) || command.accountId !== `svc-${singleProvider(command.kind)}`) { + invalid(); + } + let identity = command.identity; + let secret = command.secret?.trim() ? command.secret : undefined; + if (command.kind === "realdebrid-api") secret ||= settings.token; + if (command.kind === "bestdebrid-api") secret ||= settings.bestToken; + if (command.kind === "alldebrid-api") secret ||= settings.allDebridToken; + if (command.kind === "ddownload-login") { + identity = identity?.trim() ? identity : settings.ddownloadLogin; + secret ||= settings.ddownloadPassword; + } + if (command.kind === "onefichier-api") secret ||= settings.oneFichierApiKey; + if (command.kind === "linksnappy-login") { + identity = identity?.trim() ? identity : settings.linkSnappyLogin; + secret ||= settings.linkSnappyPassword; + } + const provider = singleProvider(command.kind); + const next = setSingle(settings, command.kind, identity, secret); + next.providerDailyLimitBytes = setLimit(settings.providerDailyLimitBytes as Record, provider, command.dailyLimitBytes); + return { settings: next, response: { accountId: command.accountId } }; +} + +function deleteSingle(settings: AppSettings, command: Extract): AppliedAccountCommand { + const provider = singleProvider(command.kind); + if (!singleConfigured(settings, command.kind) || command.accountId !== `svc-${provider}`) { + invalid(); + } + let next = { ...settings }; + if (provider === "realdebrid") next = { ...next, token: "", realDebridUseWebLogin: false }; + if (provider === "bestdebrid") next = { ...next, bestToken: "", bestDebridUseWebLogin: false }; + if (provider === "alldebrid") next = { ...next, allDebridToken: "", allDebridUseWebLogin: false }; + if (provider === "ddownload") next = { ...next, ddownloadLogin: "", ddownloadPassword: "" }; + if (provider === "onefichier") next = { ...next, oneFichierApiKey: "" }; + if (provider === "linksnappy") next = { ...next, linkSnappyLogin: "", linkSnappyPassword: "" }; + next.providerDailyLimitBytes = withoutKeys(settings.providerDailyLimitBytes as Record, provider); + next.providerDailyUsageBytes = withoutKeys(settings.providerDailyUsageBytes as Record, provider); + next.providerTotalUsageBytes = withoutKeys(settings.providerTotalUsageBytes as Record, provider); + return { settings: next, response: { accountId: null } }; +} + +function createSingle(settings: AppSettings, command: Extract): AppliedAccountCommand { + if (singleConfigured(settings, command.kind)) { + invalid(); + } + const provider = singleProvider(command.kind); + const next = setSingle(settings, command.kind, command.identity, command.secret); + next.providerDailyLimitBytes = setLimit(settings.providerDailyLimitBytes as Record, provider, command.dailyLimitBytes); + return { settings: next, response: { accountId: `svc-${provider}` } }; +} + +export function applyAccountCommand(settings: AppSettings, command: AccountCommand): AppliedAccountCommand { + if (command.action === "update-secret") { + const replace: Extract = { + action: "replace", + kind: command.kind, + accountId: command.accountId, + secret: command.secret + }; + return applyAccountCommand(settings, replace); + } + if (command.kind === "megadebrid-api" || command.kind === "megadebrid-web") { + if (command.action === "create") return createMega(settings, command); + if (command.action === "replace") return replaceMega(settings, command); + return deleteMega(settings, command); + } + if (command.kind === "debridlink-api") { + if (command.action === "create") return createDebridLink(settings, command); + if (command.action === "replace") return replaceDebridLink(settings, command); + return deleteDebridLink(settings, command); + } + if (command.action === "create") return createSingle(settings, command); + if (command.action === "replace") return replaceSingle(settings, command); + return deleteSingle(settings, command); +} diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 95e0ea8..71f07fc 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -5,7 +5,10 @@ import { app } from "electron"; import { AddLinksPayload, AllDebridHostInfo, - AppSettings, + AppSettings, + AccountCommand, + AccountCommandResult, + AccountCredentialCheckInput, DebridAccountStatus, DebridProvider, DuplicatePolicy, @@ -27,8 +30,12 @@ import { importDlcContainers } from "./container"; import { APP_VERSION, ONLINE_BACKUP_API_URL } from "./constants"; import { DownloadManager } from "./download-manager"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; -import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check"; +import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount } from "./account-check"; import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { applyAccountCommand } from "./account-commands"; +import { createRendererState } from "./renderer-state"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; import { AllDebridWebFallback } from "./all-debrid-web"; @@ -59,7 +66,6 @@ import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceE import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup"; import { overlayLiveUsageCounters } from "./settings-live-overlay"; -import { canPersistExpectedAccountStatus } from "./account-status-persistence"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -433,7 +439,7 @@ export class AppController { } } - public updateSettings(partial: Partial): AppSettings { + public updateSettings(partial: Partial): AppSettings { const sanitizedPatch = sanitizeSettingsPatch(partial); const previousSettings = this.settings; const nextSettings = normalizeSettings({ @@ -472,8 +478,52 @@ export class AppController { logger.warn(`BestDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); }); } - return this.settings; - } + return this.settings; + } + + public async executeAccountCommand(command: AccountCommand): Promise { + const applied = applyAccountCommand(this.settings, command); + let checkedStatus: DebridAccountStatus | null = null; + 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); + if (!account) throw new Error("Account-Payload ist ungültig"); + checkedStatus = await checkMegaDebridAccount(account); + } + if (command.action !== "delete" && applied.response.accountId && command.kind === "debridlink-api") { + const key = parseDebridLinkApiKeys(applied.settings.debridLinkApiKeys).find((entry) => entry.id === applied.response.accountId); + if (!key) throw new Error("Account-Payload ist ungültig"); + checkedStatus = await checkDebridLinkKey(key); + } + if (checkedStatus && !checkedStatus.valid) { + const submittedSecret = "secret" in command ? command.secret : undefined; + const safeMessage = submittedSecret ? checkedStatus.message.split(submittedSecret).join("[geschützt]") : checkedStatus.message; + throw new Error(safeMessage || "Zugangsdaten ungültig"); + } + this.updateSettings(applied.settings); + if (checkedStatus) this.manager.applyDebridAccountStatuses([checkedStatus]); + const state = createRendererState(this.settings); + this.audit("INFO", "Account aktualisiert", { action: command.action, kind: command.kind, accountId: applied.response.accountId }); + return { ...applied.response, ...state }; + } + + public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise { + if (input.kind === "megadebrid-api" || input.kind === "megadebrid-web") { + const mode = input.kind === "megadebrid-web" ? "web" : "api"; + const account = input.identity?.trim() && input.secret + ? parseMegaDebridAccounts(`${input.identity.trim()}:${input.secret}`)[0] + : getMegaDebridAccountsForMode(this.settings, mode).find((entry) => entry.id === input.accountId); + if (!account) throw new Error("Account-Payload ist ungültig"); + const status = await checkMegaDebridAccount(account); + return { ...status, message: input.secret ? status.message.split(input.secret).join("[geschützt]") : status.message }; + } + const key = input.secret?.trim() + ? parseDebridLinkApiKeys(input.secret)[0] + : parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).find((entry) => entry.id === input.accountId); + if (!key) throw new Error("Account-Payload ist ungültig"); + const status = await checkDebridLinkKey(key); + return { ...status, message: input.secret ? status.message.split(input.secret).join("[geschützt]") : status.message }; + } public resetProviderDailyUsage(provider: DebridProvider): AppSettings { const liveSettings = this.manager.getSettings(); @@ -535,31 +585,16 @@ export class AppController { return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host); } -public async checkDebridAccounts(settingsOverride?: AppSettings, persistValidOverride = false, expectedAccountId?: string): Promise { - const statuses = await checkAllDebridAccounts(settingsOverride ? normalizeSettings(settingsOverride) : this.settings); - if (!settingsOverride || (persistValidOverride && canPersistExpectedAccountStatus(statuses, expectedAccountId))) { - this.manager.applyDebridAccountStatuses(statuses); - } - if (!settingsOverride) { - this.audit("INFO", "Debrid-Accounts geprueft", { - total: statuses.length, - valid: statuses.filter((s) => s.valid).length, - premium: statuses.filter((s) => s.isPremium).length - }); - } +public async checkDebridAccounts(): Promise { + const statuses = await checkAllDebridAccounts(this.settings); + this.manager.applyDebridAccountStatuses(statuses); + this.audit("INFO", "Debrid-Accounts geprueft", { + total: statuses.length, + valid: statuses.filter((s) => s.valid).length, + premium: statuses.filter((s) => s.isPremium).length + }); return statuses; } - - public async checkSingleMegaDebridAccount(login: string, password: string): Promise { - const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0]; - if (!entry) { - return null; - } - const status = await checkMegaDebridAccount(entry); - this.manager.applyDebridAccountStatuses([status]); - this.audit("INFO", "Mega-Debrid-Account einzeln geprueft", { valid: status.valid, premium: status.isPremium }); - return status; - } public async checkUpdates(): Promise { const result = await checkGitHubUpdate(this.settings.updateRepo); if (!result.error) { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 28d9fd5..0582dea 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -71,6 +71,7 @@ import { logDesktopRename, verifyRename, verifyRenameAsync, type RenameVerificat import { StoragePaths, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "./storage"; import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, looksLikeOpaqueFilename, nowMs, sanitizeFilename, sleep } from "./utils"; import { mergeKnownTotalBytes } from "./download-size"; +import { createRendererState } from "./renderer-state"; type ActiveTask = { itemId: string; @@ -407,20 +408,7 @@ function cloneSession(session: SessionState): SessionState { }; } -function cloneSettings(settings: AppSettings): AppSettings { - return { - ...settings, - bandwidthSchedules: (settings.bandwidthSchedules || []).map((entry) => ({ ...entry })), - providerDailyLimitBytes: { ...(settings.providerDailyLimitBytes || {}) }, - providerDailyUsageBytes: { ...(settings.providerDailyUsageBytes || {}) }, - providerTotalUsageBytes: { ...(settings.providerTotalUsageBytes || {}) }, - debridLinkApiKeyDailyLimitBytes: { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }, - debridLinkApiKeyDailyUsageBytes: { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }, - debridLinkApiKeyTotalUsageBytes: { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) } - }; -} - -type ParsedContentRange = { +type ParsedContentRange = { start: number; end: number; total: number | null; @@ -1782,7 +1770,7 @@ export class DownloadManager extends EventEmitter { private statsCacheAt = 0; - private settingsSnapshotCache: AppSettings | null = null; + private settingsSnapshotCache: ReturnType | null = null; private settingsSnapshotCacheAt = 0; private invalidateSettingsSnapshotCache(): void { this.settingsSnapshotCache = null; @@ -2490,21 +2478,22 @@ export class DownloadManager extends EventEmitter { const reconnectMs = Math.max(0, this.session.reconnectUntil - now); const snapshotSession = cloneSession(this.session); - let snapshotSettings: AppSettings; - if (this.settingsSnapshotCache && now - this.settingsSnapshotCacheAt < 400) { - snapshotSettings = this.settingsSnapshotCache; - } else { - snapshotSettings = cloneSettings(this.settings); - this.settingsSnapshotCache = snapshotSettings; - this.settingsSnapshotCacheAt = now; - } + let rendererState: ReturnType; + if (this.settingsSnapshotCache && now - this.settingsSnapshotCacheAt < 400) { + rendererState = this.settingsSnapshotCache; + } else { + rendererState = createRendererState(this.settings); + this.settingsSnapshotCache = rendererState; + this.settingsSnapshotCacheAt = now; + } const snapshotSummary = this.summary ? { ...this.summary } : null; return { rotationEvents: getRecentRotationEvents(40), - settings: snapshotSettings, + settings: rendererState.settings, + accounts: rendererState.accounts, session: snapshotSession, summary: snapshotSummary, stats: this.getStats(now), diff --git a/src/main/main.ts b/src/main/main.ts index 190482d..2654223 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, safeStorage, shell, Tray } from "electron"; -import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types"; +import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, RendererSettingsUpdate, UpdateInstallProgress } from "../shared/types"; import { AppController } from "./app-controller"; import { IPC_CHANNELS } from "../shared/ipc"; import { getLogFilePath, logger } from "./logger"; @@ -15,6 +15,9 @@ import { DEV_SERVER_URL } from "./dev-server-url"; import { resolveAppIconPath } from "./app-icon"; import { configureCredentialProtector } from "./credential-protection"; import { isMdd2Backup } from "./backup-crypto"; +import { validateAccountCommand, validateAccountCredentialCheckInput } from "./account-commands"; +import { createRendererSettings } from "./renderer-state"; +import { validateRendererSettingsUpdate } from "./renderer-settings"; function validateString(value: unknown, name: string): string { if (typeof value !== "string") { @@ -358,28 +361,52 @@ function registerIpcHandlers(): void { return false; } }); - ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial) => { - const validated = validatePlainObject(partial ?? {}, "partial"); - const result = controller.updateSettings(validated as Partial); - updateClipboardWatcher(); - updateTray(); - armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true }); - return result; - }); + ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => { + const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings()); + const result = controller.updateSettings(validated as Partial); + updateClipboardWatcher(); + updateTray(); + armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true }); + return createRendererSettings(result); + }); ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => { const validatedProvider = validateString(provider, "provider") as DebridProvider; if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) { throw new Error("provider ist ungültig"); } - return controller.resetProviderDailyUsage(validatedProvider); + return createRendererSettings(controller.resetProviderDailyUsage(validatedProvider)); }); ipcMain.handle(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => { const validatedKeyId = validateString(keyId, "keyId").trim(); if (!validatedKeyId) { throw new Error("keyId ist ungültig"); } - return controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId); - }); + return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId)); + }); + + ipcMain.handle(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { + const command = validateAccountCommand(rawCommand); + if (command.action !== "create") throw new Error("Account-Payload ist ungültig"); + return controller.executeAccountCommand(command); + }); + + ipcMain.handle(IPC_CHANNELS.REPLACE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { + const command = validateAccountCommand(rawCommand); + if (command.action !== "replace") throw new Error("Account-Payload ist ungültig"); + return controller.executeAccountCommand(command); + }); + + ipcMain.handle(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { + const command = validateAccountCommand(rawCommand); + if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig"); + return controller.executeAccountCommand(command); + }); + + ipcMain.handle(IPC_CHANNELS.DELETE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { + const command = validateAccountCommand(rawCommand); + if (command.action !== "delete") throw new Error("Account-Payload ist ungültig"); + return controller.executeAccountCommand(command); + }); ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { validatePlainObject(payload ?? {}, "payload"); validateString(payload?.rawText, "rawText"); @@ -761,13 +788,13 @@ function registerIpcHandlers(): void { return controller.getDebridLinkHostLimits(); }); - ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async (_event, settings?: AppSettings, persistValidOverride = false, expectedAccountId?: string) => { - return controller.checkDebridAccounts(settings, persistValidOverride === true, expectedAccountId); + ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => { + return controller.checkDebridAccounts(); + }); + + ipcMain.handle(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, async (_event, rawInput: unknown) => { + return controller.checkAccountCredentials(validateAccountCredentialCheckInput(rawInput)); }); - - ipcMain.handle(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, async (_event, login: string, password: string) => { - return controller.checkSingleMegaDebridAccount(String(login || ""), String(password || "")); - }); ipcMain.handle(IPC_CHANNELS.SELECT_BACKUP_IMPORT, async () => { pendingBackupImport = null; diff --git a/src/main/renderer-settings.ts b/src/main/renderer-settings.ts new file mode 100644 index 0000000..4aa517a --- /dev/null +++ b/src/main/renderer-settings.ts @@ -0,0 +1,83 @@ +import type { AppSettings, RendererSettingsUpdate } from "../shared/types"; +import { createRendererSettings } from "./renderer-state"; + +const DERIVED_KEYS = new Set(["archivePasswordListConfigured", "notifyUrlConfigured", "configuredProviders"]); +const WRITE_ONLY_KEYS = new Set(["archivePasswordList", "notifyUrl"]); +const MAX_SETTINGS_PAYLOAD_BYTES = 1_000_000; + +function invalid(): never { + throw new Error("Settings-Payload ist ungültig"); +} + +function validateJsonValue(value: unknown, depth = 0): void { + if (depth > 8) { + invalid(); + } + if (value === null || typeof value === "string" || typeof value === "boolean") { + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) invalid(); + return; + } + if (Array.isArray(value)) { + if (value.length > 10_000) invalid(); + value.forEach((entry) => validateJsonValue(entry, depth + 1)); + return; + } + if (!value || typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) { + invalid(); + } + const entries = Object.entries(value as Record); + if (entries.length > 10_000) invalid(); + entries.forEach(([, entry]) => validateJsonValue(entry, depth + 1)); +} + +function validateTopLevelType(value: unknown, expected: unknown): void { + if (Array.isArray(expected)) { + if (!Array.isArray(value)) invalid(); + return; + } + if (expected && typeof expected === "object") { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) invalid(); + return; + } + if (typeof value !== typeof expected) { + invalid(); + } +} + +export function validateRendererSettingsUpdate(value: unknown, current: AppSettings): RendererSettingsUpdate { + if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) { + invalid(); + } + let serialized = ""; + try { + serialized = JSON.stringify(value); + } catch { + invalid(); + } + if (Buffer.byteLength(serialized, "utf8") > MAX_SETTINGS_PAYLOAD_BYTES) { + invalid(); + } + const safe = createRendererSettings(current) as unknown as Record; + const input = value as Record; + const output: Record = {}; + for (const [key, entry] of Object.entries(input)) { + if (DERIVED_KEYS.has(key)) { + continue; + } + if (WRITE_ONLY_KEYS.has(key)) { + if (typeof entry !== "string" || entry.length > 100_000) invalid(); + output[key] = entry; + continue; + } + if (!(key in safe)) { + invalid(); + } + validateTopLevelType(entry, safe[key]); + validateJsonValue(entry); + output[key] = entry; + } + return output as RendererSettingsUpdate; +} diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts new file mode 100644 index 0000000..a574046 --- /dev/null +++ b/src/main/renderer-state.ts @@ -0,0 +1,264 @@ +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types"; + +function maskValue(value: string, keepStart = 3, keepEnd = 3): string { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + if (trimmed.length <= keepStart + keepEnd) { + return "*".repeat(trimmed.length); + } + return `${trimmed.slice(0, keepStart)}${"*".repeat(Math.max(4, trimmed.length - keepStart - keepEnd))}${trimmed.slice(-keepEnd)}`; +} + +function getSecrets(settings: AppSettings): string[] { + const megaSecrets = [settings.megaCredentials, settings.megaDebridApiCredentials, settings.megaDebridWebCredentials] + .flatMap((credentials) => parseMegaDebridAccounts(credentials, settings.megaPassword)) + .map((account) => account.password); + const debridLinkSecrets = parseDebridLinkApiKeys(settings.debridLinkApiKeys).map((key) => key.token); + return [...new Set([ + settings.token, + settings.megaPassword, + settings.megaCredentials, + settings.megaDebridApiCredentials, + settings.megaDebridWebCredentials, + ...megaSecrets, + settings.bestToken, + settings.allDebridToken, + settings.ddownloadPassword, + settings.oneFichierApiKey, + settings.debridLinkApiKeys, + ...debridLinkSecrets, + settings.linkSnappyPassword, + settings.archivePasswordList, + settings.notifyUrl + ].map((value) => value.trim()).filter(Boolean))].sort((left, right) => right.length - left.length); +} + +function redact(value: string, secrets: readonly string[]): string { + return secrets.reduce((result, secret) => result.split(secret).join("[geschützt]"), value); +} + +function safeStatus(status: DebridAccountStatus | undefined, secrets: readonly string[]): DebridAccountStatus | null { + if (!status) { + return null; + } + return { + ...status, + label: redact(status.label, secrets), + maskedLogin: redact(status.maskedLogin, secrets), + email: status.email ? redact(status.email, secrets) : undefined, + message: redact(status.message, secrets) + }; +} + +function providerEnabled(settings: AppSettings, provider: DebridProvider): boolean { + return !settings.disabledProviders.includes(provider); +} + +function singleAccount( + settings: AppSettings, + kind: RendererAccountKind, + provider: DebridProvider, + identity: string, + maskedIdentity: string, + hasSecret: boolean +): RendererAccount { + return { + accountId: `svc-${provider}`, + kind, + provider, + identity, + maskedIdentity, + hasSecret, + enabled: providerEnabled(settings, provider), + dailyLimitBytes: settings.providerDailyLimitBytes[provider] || 0, + dailyUsageBytes: settings.providerDailyUsageBytes[provider] || 0, + totalUsageBytes: settings.providerTotalUsageBytes[provider] || 0, + status: null + }; +} + +export function createRendererAccounts(settings: AppSettings): RendererAccount[] { + const accounts: RendererAccount[] = []; + const secrets = getSecrets(settings); + if (settings.realDebridUseWebLogin || settings.token.trim()) { + accounts.push(singleAccount( + settings, + settings.realDebridUseWebLogin ? "realdebrid-web" : "realdebrid-api", + "realdebrid", + "", + settings.realDebridUseWebLogin ? "Browser-Login" : maskValue(settings.token), + true + )); + } + for (const mode of ["api", "web"] as const) { + const kind: RendererAccountKind = mode === "api" ? "megadebrid-api" : "megadebrid-web"; + const provider: DebridProvider = kind; + const modeEnabled = mode === "api" ? settings.megaDebridApiEnabled : settings.megaDebridWebEnabled; + const disabledIds = new Set(getMegaDebridDisabledAccountIdsForMode(settings, mode)); + for (const account of getMegaDebridAccountsForMode(settings, mode)) { + accounts.push({ + accountId: account.id, + kind, + provider, + identity: account.login, + maskedIdentity: account.maskedLogin, + hasSecret: Boolean(account.password), + enabled: modeEnabled && providerEnabled(settings, provider) && !disabledIds.has(account.id), + dailyLimitBytes: settings.megaDebridAccountDailyLimitBytes[account.id] || 0, + dailyUsageBytes: settings.megaDebridAccountDailyUsageBytes[account.id] || 0, + totalUsageBytes: settings.megaDebridAccountTotalUsageBytes[account.id] || 0, + status: safeStatus(settings.debridAccountStatuses[account.id], secrets) + }); + } + } + if (settings.bestDebridUseWebLogin || settings.bestToken.trim()) { + accounts.push(singleAccount( + settings, + settings.bestDebridUseWebLogin ? "bestdebrid-web" : "bestdebrid-api", + "bestdebrid", + "", + settings.bestDebridUseWebLogin ? "Cookie-Import" : maskValue(settings.bestToken), + true + )); + } + if (settings.allDebridUseWebLogin || settings.allDebridToken.trim()) { + accounts.push(singleAccount( + settings, + settings.allDebridUseWebLogin ? "alldebrid-web" : "alldebrid-api", + "alldebrid", + "", + settings.allDebridUseWebLogin ? "Browser-Login" : maskValue(settings.allDebridToken), + true + )); + } + if (settings.ddownloadLogin.trim() && settings.ddownloadPassword) { + accounts.push(singleAccount(settings, "ddownload-login", "ddownload", settings.ddownloadLogin.trim(), maskValue(settings.ddownloadLogin, 2, 4), true)); + } + if (settings.oneFichierApiKey.trim()) { + accounts.push(singleAccount(settings, "onefichier-api", "onefichier", "", maskValue(settings.oneFichierApiKey), true)); + } + for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys)) { + accounts.push({ + accountId: key.id, + kind: "debridlink-api", + provider: "debridlink", + identity: "", + maskedIdentity: key.masked, + hasSecret: true, + enabled: providerEnabled(settings, "debridlink") && !settings.debridLinkDisabledKeyIds.includes(key.id), + dailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes[key.id] || 0, + dailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes[key.id] || 0, + totalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes[key.id] || 0, + status: safeStatus(settings.debridAccountStatuses[key.id], secrets) + }); + } + if (settings.linkSnappyLogin.trim() && settings.linkSnappyPassword) { + accounts.push(singleAccount(settings, "linksnappy-login", "linksnappy", settings.linkSnappyLogin.trim(), maskValue(settings.linkSnappyLogin, 2, 4), true)); + } + return accounts; +} + +export function createRendererSettings(settings: AppSettings): RendererSettings { + const configuredProviders = [...new Set(createRendererAccounts(settings).map((account) => account.provider))]; + return { + language: settings.language, + realDebridUseWebLogin: settings.realDebridUseWebLogin, + megaDebridApiEnabled: settings.megaDebridApiEnabled, + megaDebridWebEnabled: settings.megaDebridWebEnabled, + megaDebridPreferApi: settings.megaDebridPreferApi, + bestDebridUseWebLogin: settings.bestDebridUseWebLogin, + allDebridUseWebLogin: settings.allDebridUseWebLogin, + debridLinkDisabledKeyIds: [...settings.debridLinkDisabledKeyIds], + rememberToken: settings.rememberToken, + configuredProviders, + providerOrder: [...settings.providerOrder], + providerPrimary: settings.providerPrimary, + providerSecondary: settings.providerSecondary, + providerTertiary: settings.providerTertiary, + autoProviderFallback: settings.autoProviderFallback, + outputDir: settings.outputDir, + packageName: settings.packageName, + autoExtract: settings.autoExtract, + autoRename4sf4sj: settings.autoRename4sf4sj, + keepGermanAudioOnly: settings.keepGermanAudioOnly, + germanAudioMode: settings.germanAudioMode, + extractDir: settings.extractDir, + collectMkvToLibrary: settings.collectMkvToLibrary, + mkvLibraryDir: settings.mkvLibraryDir, + createExtractSubfolder: settings.createExtractSubfolder, + hybridExtract: settings.hybridExtract, + cleanupMode: settings.cleanupMode, + extractConflictMode: settings.extractConflictMode, + removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract, + removeSamplesAfterExtract: settings.removeSamplesAfterExtract, + enableIntegrityCheck: settings.enableIntegrityCheck, + autoResumeOnStart: settings.autoResumeOnStart, + autoReconnect: settings.autoReconnect, + reconnectWaitSeconds: settings.reconnectWaitSeconds, + completedCleanupPolicy: settings.completedCleanupPolicy, + maxParallel: settings.maxParallel, + maxParallelExtract: settings.maxParallelExtract, + retryLimit: settings.retryLimit, + speedLimitEnabled: settings.speedLimitEnabled, + speedLimitKbps: settings.speedLimitKbps, + speedLimitMode: settings.speedLimitMode, + updateRepo: settings.updateRepo, + autoUpdateCheck: settings.autoUpdateCheck, + clipboardWatch: settings.clipboardWatch, + minimizeToTray: settings.minimizeToTray, + theme: settings.theme, + collapseNewPackages: settings.collapseNewPackages, + historyRetentionMode: settings.historyRetentionMode, + historyMaxEntries: settings.historyMaxEntries, + historyMaxAgeDays: settings.historyMaxAgeDays, + accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys, + autoSortPackagesByProgress: settings.autoSortPackagesByProgress, + autoSkipExtracted: settings.autoSkipExtracted, + hideExtractedItems: settings.hideExtractedItems, + confirmDeleteSelection: settings.confirmDeleteSelection, + backupIncludeDownloads: settings.backupIncludeDownloads, + backupIncludeRemoteDiagnostics: settings.backupIncludeRemoteDiagnostics, + archivePasswordListConfigured: Boolean(settings.archivePasswordList.trim()), + notifyUrlConfigured: Boolean(settings.notifyUrl.trim()), + notifyMention: settings.notifyMention, + notifyOnPackageCompleted: settings.notifyOnPackageCompleted, + notifyOnPackageFailed: settings.notifyOnPackageFailed, + notifyOnRunFinished: settings.notifyOnRunFinished, + totalDownloadedAllTime: settings.totalDownloadedAllTime, + totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, + totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs, + bandwidthSchedules: settings.bandwidthSchedules.map((entry) => ({ ...entry })), + columnOrder: [...settings.columnOrder], + columnOrderVersion: settings.columnOrderVersion, + extractCpuPriority: settings.extractCpuPriority, + autoExtractWhenStopped: settings.autoExtractWhenStopped, + disabledProviders: [...settings.disabledProviders], + hosterRouting: { ...settings.hosterRouting }, + providerDailyLimitBytes: { ...settings.providerDailyLimitBytes }, + providerDailyUsageBytes: { ...settings.providerDailyUsageBytes }, + providerTotalUsageBytes: { ...settings.providerTotalUsageBytes }, + debridLinkApiKeyDailyLimitBytes: { ...settings.debridLinkApiKeyDailyLimitBytes }, + debridLinkApiKeyDailyUsageBytes: { ...settings.debridLinkApiKeyDailyUsageBytes }, + debridLinkApiKeyTotalUsageBytes: { ...settings.debridLinkApiKeyTotalUsageBytes }, + megaDebridDisabledAccountIds: [...settings.megaDebridDisabledAccountIds], + megaDebridApiDisabledAccountIds: [...settings.megaDebridApiDisabledAccountIds], + megaDebridWebDisabledAccountIds: [...settings.megaDebridWebDisabledAccountIds], + megaDebridAccountDailyLimitBytes: { ...settings.megaDebridAccountDailyLimitBytes }, + megaDebridAccountDailyUsageBytes: { ...settings.megaDebridAccountDailyUsageBytes }, + megaDebridAccountTotalUsageBytes: { ...settings.megaDebridAccountTotalUsageBytes }, + debridAccountStatuses: Object.fromEntries(Object.entries(settings.debridAccountStatuses).map(([id, status]) => [id, safeStatus(status, getSecrets(settings))])), + providerDailyUsageDay: settings.providerDailyUsageDay, + scheduledStartEpochMs: settings.scheduledStartEpochMs + } as RendererSettings; +} + +export function createRendererState(settings: AppSettings): { settings: RendererSettings; accounts: RendererAccount[] } { + return { + settings: createRendererSettings(settings), + accounts: createRendererAccounts(settings) + }; +} diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 546f1b2..0369141 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -1,8 +1,13 @@ import { contextBridge, ipcRenderer } from "electron"; import { - AddLinksPayload, + AddLinksPayload, + AccountCommandResult, + AccountCredentialCheckInput, + AccountCreateCommand, + AccountDeleteCommand, + AccountReplaceCommand, + AccountUpdateSecretCommand, AllDebridHostInfo, - AppSettings, DebridAccountStatus, DebridLinkHostLimitInfo, DebridProvider, @@ -11,7 +16,9 @@ import { HistoryEntry, HistoryRevealResult, PackagePriority, - RemoteDiagnosticsInfo, + RemoteDiagnosticsInfo, + RendererSettings, + RendererSettingsUpdate, RendererErrorReport, SessionStats, StartConflictEntry, @@ -29,9 +36,13 @@ const api: ElectronApi = { checkUpdates: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES), installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE), openExternal: (url: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url), - updateSettings: (settings: Partial): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), - resetProviderDailyUsage: (provider: DebridProvider): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider), - resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), + updateSettings: (settings: RendererSettingsUpdate): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), + resetProviderDailyUsage: (provider: DebridProvider): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider), + resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), + createAccount: (command: AccountCreateCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.CREATE_ACCOUNT, command), + replaceAccount: (command: AccountReplaceCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_ACCOUNT, command), + updateAccountSecret: (command: AccountUpdateSecretCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, command), + deleteAccount: (command: AccountDeleteCommand): Promise => ipcRenderer.invoke(IPC_CHANNELS.DELETE_ACCOUNT, command), addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> => ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => @@ -89,10 +100,10 @@ const api: ElectronApi = { openAllDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), importBestDebridCookies: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), getAllDebridHostInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), - getDebridLinkHostLimits: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), - checkDebridAccounts: (settings?: AppSettings, persistValidOverride = false, expectedAccountId?: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, settings, persistValidOverride, expectedAccountId), - checkMegaDebridAccount: (login: string, password: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password), - retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), + getDebridLinkHostLimits: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), + checkDebridAccounts: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS), + checkAccountCredentials: (input: AccountCredentialCheckInput): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, input), + retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), resetPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9f5b763..f52a2da 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,11 +1,10 @@ import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { getMegaDebridAccountId, getMegaDebridAccountsForMode, getMegaDebridCredentialsForMode, getMegaDebridDisabledAccountIdsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, serializeMegaDebridAccounts, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts"; -import type { - AllDebridHostInfo, - AppSettings, - AppTheme, +import type { + AccountCreateCommand, + AllDebridHostInfo, + AppTheme, BandwidthScheduleEntry, DebugSetupCheckResult, DebridFallbackProvider, @@ -16,7 +15,10 @@ import type { DuplicatePolicy, HistoryEntry, PackageEntry, - RemoteDiagnosticsInfo, + RemoteDiagnosticsInfo, + RendererAccount, + RendererSettings, + RendererSettingsUpdate, StartConflictEntry, UiSnapshot, UpdateCheckResult, @@ -40,7 +42,7 @@ import { preservePackageOrderForDisplay, sortPackageOrderByName } from "./packag import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection"; import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui"; import type { AccountModeFilter } from "./account-ui"; -import { applyAccountEdit, buildAccountEditCheckSettings, createAccountEditState, getAccountEditExpectedStatusId, removeAccountTarget, validateAccountEdit, validateAccountEditStatuses } from "./account-edit"; +import { buildAccountDeleteCommand, buildAccountReplaceCommand, createAccountEditState, validateAccountEdit } from "./account-edit"; import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit"; import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons"; import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state"; @@ -306,7 +308,6 @@ interface AccountTableRow { dailyRemainingBytes: number; totalUsedBytes: number; toggleKind: "mega" | "dl" | "single"; - megaLogin?: string; dlKey?: DebridLinkAccountKeyEntry; editTarget: AccountEditTarget; } @@ -319,23 +320,36 @@ interface AccountContextMenuState { type SettingsThemeChoice = AppTheme | "system"; +interface RendererSettingsDraft extends RendererSettings { + archivePasswordList: string; + notifyUrl: string; +} + +function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft { + return { + ...settings, + archivePasswordList: current?.archivePasswordList || "", + notifyUrl: current?.notifyUrl || "" + }; +} + function settingsValueEqual(left: unknown, right: unknown): boolean { return Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right); } export function mergeConcurrentSpecificSettings( - base: AppSettings, - requested: AppSettings, - persisted: AppSettings, - current: AppSettings -): AppSettings { + base: RendererSettingsDraft, + requested: RendererSettingsDraft, + persisted: RendererSettings, + current: RendererSettingsDraft +): RendererSettingsDraft { const merged = { ...current } as Record; - for (const key of Object.keys(requested) as Array) { - if (!settingsValueEqual(base[key], requested[key]) && settingsValueEqual(base[key], current[key])) { - merged[key] = persisted[key]; + for (const key of Object.keys(requested) as Array) { + if (!settingsValueEqual(base[key], requested[key]) && settingsValueEqual(base[key], current[key]) && key in persisted) { + merged[key] = persisted[key as keyof RendererSettings]; } } - return merged as unknown as AppSettings; + return merged as unknown as RendererSettingsDraft; } export function resolveSettingsThemeChoice(choice: SettingsThemeChoice, prefersLight: boolean): AppTheme { @@ -545,16 +559,7 @@ function getAccountServiceProvider(service: AccountService): DebridProvider { return service as DebridProvider; } -function formatAccountDailyLimitInput(limitBytes: number): string { - if (limitBytes <= 0) { - return ""; - } - const gib = limitBytes / ACCOUNT_LIMIT_BYTES_PER_GIB; - const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2; - return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1"); -} - -function parseAccountDailyLimitInputBytes(value: string): number | null { +function parseAccountDailyLimitInputBytes(value: string): number | null { const normalized = value.trim().replace(",", "."); if (!normalized) { return null; @@ -566,15 +571,6 @@ function parseAccountDailyLimitInputBytes(value: string): number | null { return Math.floor(parsed * ACCOUNT_LIMIT_BYTES_PER_GIB); } -function buildDebridLinkKeyLimitInputs(rawKeys: string, values?: Record, settings?: AppSettings): Record { - const next: Record = {}; - for (const key of parseDebridLinkApiKeys(rawKeys)) { - next[key.id] = values?.[key.id] - ?? formatAccountDailyLimitInput(settings?.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0); - } - return next; -} - function getAccountPickerFunctionLabel(option: AccountOption): string { switch (option.kind) { case "realdebrid-api": @@ -621,54 +617,25 @@ function getAccountCredentialLabel(kind: AccountKind): string { } } -function hasMegaDebridCredentials(settings: AppSettings, mode?: MegaDebridAccountMode): boolean { - if (mode) { - return getMegaDebridAccountsForMode(settings, mode).length > 0; - } - return getMegaDebridAccountsForMode(settings, "api").length > 0 || getMegaDebridAccountsForMode(settings, "web").length > 0; -} - -function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvider[] { - const list: DebridProvider[] = []; - if (settings.token.trim() || settings.realDebridUseWebLogin) { - list.push("realdebrid"); - } - if (hasMegaDebridCredentials(settings, "api") && settings.megaDebridApiEnabled) { - list.push("megadebrid-api"); - } - if (hasMegaDebridCredentials(settings, "web") && settings.megaDebridWebEnabled) { - list.push("megadebrid-web"); - } - if (settings.bestDebridUseWebLogin || settings.bestToken.trim()) { - list.push("bestdebrid"); - } - if (settings.allDebridUseWebLogin || settings.allDebridToken.trim()) { - list.push("alldebrid"); - } - if ((settings.debridLinkApiKeys || "").trim()) { - list.push("debridlink"); - } - if ((settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim()) { - list.push("linksnappy"); - } - return list; +function getConfiguredProvidersFromSettings(settings: RendererSettings): DebridProvider[] { + return [...settings.configuredProviders]; } -function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[] { +function getActiveProvidersFromSettings(settings: RendererSettings): DebridProvider[] { const disabled = new Set(settings.disabledProviders || []); return getConfiguredProvidersFromSettings(settings).filter((provider) => !disabled.has(provider)); } const DIRECT_HOSTERS: ReadonlySet = new Set(["onefichier", "ddownload"]); -function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] { +function normalizeProviderOrderForSettings(settings: RendererSettings): DebridProvider[] { const configured = getConfiguredProvidersFromSettings(settings).filter((provider) => !DIRECT_HOSTERS.has(provider)); return buildConfiguredProviderOrder(settings.providerOrder || [], configured); } function normalizeProviderSelectionForSettings( - settings: AppSettings -): Pick { + settings: RendererSettings +): Pick { const providerOrder = normalizeProviderOrderForSettings(settings); return { providerOrder, @@ -678,174 +645,70 @@ function normalizeProviderSelectionForSettings( }; } -function getConfiguredAccountKind(settings: AppSettings, service: AccountService): AccountKind | null { - switch (service) { - case "realdebrid": - if (settings.realDebridUseWebLogin) return "realdebrid-web"; - return settings.token.trim() ? "realdebrid-api" : null; - case "megadebrid-api": - return hasMegaDebridCredentials(settings, "api") && settings.megaDebridApiEnabled ? "megadebrid-api" : null; - case "megadebrid-web": - return hasMegaDebridCredentials(settings, "web") && settings.megaDebridWebEnabled ? "megadebrid-web" : null; - case "bestdebrid": - if (settings.bestDebridUseWebLogin) return "bestdebrid-web"; - return settings.bestToken.trim() ? "bestdebrid-api" : null; - case "alldebrid": - if (settings.allDebridUseWebLogin) return "alldebrid-web"; - return settings.allDebridToken.trim() ? "alldebrid-api" : null; - case "ddownload": - return settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim() ? "ddownload-login" : null; - case "onefichier": - return settings.oneFichierApiKey.trim() ? "onefichier-api" : null; - case "debridlink": - return (settings.debridLinkApiKeys || "").trim() ? "debridlink-api" : null; - case "linksnappy": - return (settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim() ? "linksnappy-login" : null; +function getConfiguredAccountKind(settings: RendererSettings, service: AccountService): AccountKind | null { + const configured = new Set(settings.configuredProviders); + switch (service) { + case "realdebrid": + if (settings.realDebridUseWebLogin) return "realdebrid-web"; + return configured.has("realdebrid") ? "realdebrid-api" : null; + case "megadebrid-api": + return configured.has("megadebrid-api") ? "megadebrid-api" : null; + case "megadebrid-web": + return configured.has("megadebrid-web") ? "megadebrid-web" : null; + case "bestdebrid": + if (settings.bestDebridUseWebLogin) return "bestdebrid-web"; + return configured.has("bestdebrid") ? "bestdebrid-api" : null; + case "alldebrid": + if (settings.allDebridUseWebLogin) return "alldebrid-web"; + return configured.has("alldebrid") ? "alldebrid-api" : null; + case "ddownload": + return configured.has("ddownload") ? "ddownload-login" : null; + case "onefichier": + return configured.has("onefichier") ? "onefichier-api" : null; + case "debridlink": + return configured.has("debridlink") ? "debridlink-api" : null; + case "linksnappy": + return configured.has("linksnappy") ? "linksnappy-login" : null; default: return null; } } -function maskValue(value: string, keepStart = 2, keepEnd = 2): string { - const trimmed = value.trim(); - if (!trimmed) return "Nicht hinterlegt"; - if (trimmed.length <= keepStart + keepEnd) { - return "*".repeat(trimmed.length); - } - return `${trimmed.slice(0, keepStart)}${"*".repeat(Math.max(4, trimmed.length - keepStart - keepEnd))}${trimmed.slice(-keepEnd)}`; -} - -function summarizeAccount(kind: AccountKind, settings: AppSettings): string { - switch (kind) { - case "realdebrid-api": - return maskValue(settings.token, 3, 3); - case "realdebrid-web": - return "Browser-Login"; - case "megadebrid-api": - case "megadebrid-web": { - const mode = kind === "megadebrid-web" ? "web" : "api"; - const megaAccounts = getMegaDebridAccountsForMode(settings, mode); - if (megaAccounts.length > 1) return `${megaAccounts.length} Accounts`; - if (megaAccounts.length === 1) return megaAccounts[0].maskedLogin; - return settings.megaLogin.trim() ? maskValue(settings.megaLogin.trim(), 2, 6) : "Nicht hinterlegt"; - } - case "bestdebrid-api": - return maskValue(settings.bestToken, 3, 3); - case "bestdebrid-web": - return "Cookie-Import"; - case "alldebrid-api": - return maskValue(settings.allDebridToken, 3, 3); - case "alldebrid-web": - return "Browser-Login"; - case "ddownload-login": - return settings.ddownloadLogin.trim() ? maskValue(settings.ddownloadLogin.trim(), 2, 6) : "Login + Passwort"; - case "onefichier-api": - return maskValue(settings.oneFichierApiKey, 3, 3); - case "debridlink-api": { - const keys = (settings.debridLinkApiKeys || "").split(/[\n,]+/).filter((k: string) => k.trim()); - if (keys.length > 1) return `${keys.length} API-Keys`; - return keys.length === 1 ? maskValue(keys[0].trim(), 3, 3) : "Nicht hinterlegt"; - } - case "linksnappy-login": - return (settings.linkSnappyLogin || "").trim() ? maskValue((settings.linkSnappyLogin || "").trim(), 2, 4) : "Login + Passwort"; - default: - return "Konfiguriert"; - } -} - -function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string[] { - if (kind === "debridlink-api" && settings.accountListShowDetailedDebridLinkKeys) { - const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); - if (keys.length > 1) { - return keys.map((entry) => `${entry.label}: ${entry.masked}`); - } - } - if (kind === "megadebrid-api" || kind === "megadebrid-web") { - const accounts = getMegaDebridAccountsForMode(settings, kind === "megadebrid-web" ? "web" : "api"); - if (accounts.length > 1) { - return accounts.map((entry) => `${entry.label}: ${entry.maskedLogin}`); - } - } - return [summarizeAccount(kind, settings)]; +function accountsOfKind(kind: AccountKind, accounts: readonly RendererAccount[]): RendererAccount[] { + return accounts.filter((account) => account.kind === kind); } -function getStoredAccountUsername(kind: AccountKind, settings: AppSettings): string { - switch (kind) { - case "ddownload-login": - return settings.ddownloadLogin; - case "linksnappy-login": - return settings.linkSnappyLogin; - case "realdebrid-web": - case "alldebrid-web": - return "Browser-Login"; - case "bestdebrid-web": - return "Cookies.txt"; - default: - return "API-Account"; +function summarizeAccount(kind: AccountKind, accounts: readonly RendererAccount[]): string { + const matching = accountsOfKind(kind, accounts); + if (matching.length > 1) return kind === "debridlink-api" ? `${matching.length} API-Keys` : `${matching.length} Accounts`; + return matching[0]?.maskedIdentity || "Konfiguriert"; +} + +function summarizeAccountLines(kind: AccountKind, accounts: readonly RendererAccount[], detailed: boolean): string[] { + const matching = accountsOfKind(kind, accounts); + if (matching.length > 1 && (kind !== "debridlink-api" || detailed)) { + return matching.map((entry, index) => `${kind === "debridlink-api" ? "Key" : "Account"} ${index + 1}: ${entry.maskedIdentity}`); } + return [summarizeAccount(kind, accounts)]; +} + +function getStoredAccountUsername(kind: AccountKind, accounts: readonly RendererAccount[]): string { + return accountsOfKind(kind, accounts)[0]?.identity || ""; } -export function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState { +export function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, _settings: RendererSettings): AccountDialogState { const baseMega: Pick = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] }; - if (!kind) { - return { - mode, - kind: null, - service: null, - token: "", - login: "", - password: "", - dailyLimitGb: "", - keyDailyLimitGbById: {}, - ...baseMega - }; - } - const service = findAccountOption(kind).service; - const provider = getAccountServiceProvider(service); - const dailyLimitGb = formatAccountDailyLimitInput(getProviderDailyLimitBytes(settings, provider)); - switch (kind) { - case "realdebrid-api": - return { mode, kind, service, token: settings.token, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "realdebrid-web": - return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "megadebrid-api": - case "megadebrid-web": { - const megaMode = kind === "megadebrid-web" ? "web" : "api"; - const parsed = getMegaDebridAccountsForMode(settings, megaMode); - const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password })); - const loadedIds = new Set(parsed.map((a) => a.id)); - const megaDisabledIds = getMegaDebridDisabledAccountIdsForMode(settings, megaMode).filter((id) => loadedIds.has(id)); - return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds }; - } - case "bestdebrid-api": - return { mode, kind, service, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "bestdebrid-web": - return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "alldebrid-api": - return { mode, kind, service, token: settings.allDebridToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "alldebrid-web": - return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "ddownload-login": - return { mode, kind, service, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword, dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "onefichier-api": - return { mode, kind, service, token: settings.oneFichierApiKey, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "debridlink-api": - return { - mode, - kind, - service, - token: mode === "create" ? "" : settings.debridLinkApiKeys || "", - login: "", - password: "", - dailyLimitGb, - keyDailyLimitGbById: mode === "create" ? {} : buildDebridLinkKeyLimitInputs(settings.debridLinkApiKeys || "", undefined, settings), - ...baseMega - }; - case "linksnappy-login": - return { mode, kind, service, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - default: - return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - } + return { + mode, + kind, + service: kind ? findAccountOption(kind).service : null, + token: "", + login: "", + password: "", + dailyLimitGb: "", + keyDailyLimitGbById: {}, + ...baseMega + }; } export function buildAccountAddFields(dialog: AccountDialogState | null): AccountDialogField[] { @@ -875,170 +738,14 @@ export function buildAccountAddFields(dialog: AccountDialogState | null): Accoun ]; } -function applyMegaDebridDialogToSettings(settings: AppSettings, dialog: AccountDialogState, mode: MegaDebridAccountMode, providerDailyLimitBytes: AppSettings["providerDailyLimitBytes"]): AppSettings { - const selectedCredentials = serializeMegaDebridAccounts(dialog.megaAccounts); - const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api"); - const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web"); - const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials); - const first = parseMegaDebridAccounts(mergedCredentials)[0]; - const selectedIds = new Set(parseMegaDebridAccounts(selectedCredentials).map((account) => account.id)); - const selectedDisabledIds = (dialog.megaDisabledIds || []).filter((id) => selectedIds.has(id)); - const apiDisabledIds = mode === "api" ? selectedDisabledIds : getMegaDebridDisabledAccountIdsForMode(settings, "api"); - const webDisabledIds = mode === "web" ? selectedDisabledIds : getMegaDebridDisabledAccountIdsForMode(settings, "web"); - return { - ...settings, - megaCredentials: mergedCredentials, - megaLogin: first?.login || "", - megaPassword: first?.password || "", - megaDebridApiCredentials: apiCredentials, - megaDebridWebCredentials: webCredentials, - megaDebridApiEnabled: mode === "api" ? selectedIds.size > 0 : settings.megaDebridApiEnabled && parseMegaDebridAccounts(apiCredentials).length > 0, - megaDebridWebEnabled: mode === "web" ? selectedIds.size > 0 : settings.megaDebridWebEnabled && parseMegaDebridAccounts(webCredentials).length > 0, - megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], - megaDebridApiDisabledAccountIds: apiDisabledIds, - megaDebridWebDisabledAccountIds: webDisabledIds, - providerDailyLimitBytes - }; -} - -function clearMegaDebridModeFromSettings(settings: AppSettings, mode: MegaDebridAccountMode, providerDailyLimitBytes: AppSettings["providerDailyLimitBytes"], providerDailyUsageBytes: AppSettings["providerDailyUsageBytes"]): AppSettings { - const apiCredentials = mode === "api" ? "" : getMegaDebridCredentialsForMode(settings, "api"); - const webCredentials = mode === "web" ? "" : getMegaDebridCredentialsForMode(settings, "web"); - const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials); - const first = parseMegaDebridAccounts(mergedCredentials)[0]; - const apiDisabledIds = mode === "api" ? [] : getMegaDebridDisabledAccountIdsForMode(settings, "api"); - const webDisabledIds = mode === "web" ? [] : getMegaDebridDisabledAccountIdsForMode(settings, "web"); - return { - ...settings, - megaCredentials: mergedCredentials, - megaLogin: first?.login || "", - megaPassword: first?.password || "", - megaDebridApiCredentials: apiCredentials, - megaDebridWebCredentials: webCredentials, - megaDebridApiEnabled: mode === "api" ? false : settings.megaDebridApiEnabled && parseMegaDebridAccounts(apiCredentials).length > 0, - megaDebridWebEnabled: mode === "web" ? false : settings.megaDebridWebEnabled && parseMegaDebridAccounts(webCredentials).length > 0, - megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], - megaDebridApiDisabledAccountIds: apiDisabledIds, - megaDebridWebDisabledAccountIds: webDisabledIds, - providerDailyLimitBytes, - providerDailyUsageBytes - }; -} - -export function applyAccountDialogToSettings(settings: AppSettings, dialog: AccountDialogState): AppSettings { - if (!dialog.kind) { - return settings; - } - const token = dialog.token.trim(); - const login = dialog.login.trim(); - const password = dialog.password; - const provider = getAccountServiceProvider(findAccountOption(dialog.kind).service); - const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; - const nextDebridLinkApiKeyDailyLimitBytes = dialog.kind === "debridlink-api" - ? Object.fromEntries( - parseDebridLinkApiKeys(dialog.token).flatMap((entry) => { - const limitBytes = parseAccountDailyLimitInputBytes(dialog.keyDailyLimitGbById?.[entry.id] || ""); - return limitBytes && limitBytes > 0 ? [[entry.id, limitBytes]] : []; - }) - ) as Record - : { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }; - const dailyLimitBytes = parseAccountDailyLimitInputBytes(dialog.dailyLimitGb); - if (dailyLimitBytes && dailyLimitBytes > 0) { - nextProviderDailyLimitBytes[provider] = dailyLimitBytes; - } else { - delete nextProviderDailyLimitBytes[provider]; - } - switch (dialog.kind) { - case "realdebrid-api": - return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "realdebrid-web": - return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "megadebrid-api": { - return applyMegaDebridDialogToSettings(settings, dialog, "api", nextProviderDailyLimitBytes); - } - case "megadebrid-web": { - return applyMegaDebridDialogToSettings(settings, dialog, "web", nextProviderDailyLimitBytes); - } - case "bestdebrid-api": - return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "bestdebrid-web": - return { ...settings, bestToken: "", bestDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "alldebrid-api": - return { ...settings, allDebridToken: token, allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "alldebrid-web": - return { ...settings, allDebridToken: "", allDebridUseWebLogin: true, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "ddownload-login": - return { ...settings, ddownloadLogin: login, ddownloadPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "onefichier-api": - return { ...settings, oneFichierApiKey: token, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - case "debridlink-api": - return { - ...settings, - debridLinkApiKeys: token, - providerDailyLimitBytes: nextProviderDailyLimitBytes, - debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes - }; - case "linksnappy-login": - return { ...settings, linkSnappyLogin: login, linkSnappyPassword: password, providerDailyLimitBytes: nextProviderDailyLimitBytes }; - default: - return settings; - } -} - -function clearAccountServiceFromSettings(settings: AppSettings, service: AccountService): AppSettings { - const provider = getAccountServiceProvider(service); - const nextProviderDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; - const nextProviderDailyUsageBytes = { ...(settings.providerDailyUsageBytes || {}) }; - const nextDebridLinkApiKeyDailyLimitBytes = { ...(settings.debridLinkApiKeyDailyLimitBytes || {}) }; - const nextDebridLinkApiKeyDailyUsageBytes = { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }; - delete nextProviderDailyLimitBytes[provider]; - delete nextProviderDailyUsageBytes[provider]; - if (service === "debridlink") { - for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys || "")) { - delete nextDebridLinkApiKeyDailyLimitBytes[key.id]; - delete nextDebridLinkApiKeyDailyUsageBytes[key.id]; - } - } - switch (service) { - case "realdebrid": - return { ...settings, token: "", realDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "megadebrid-api": - return clearMegaDebridModeFromSettings(settings, "api", nextProviderDailyLimitBytes, nextProviderDailyUsageBytes); - case "megadebrid-web": - return clearMegaDebridModeFromSettings(settings, "web", nextProviderDailyLimitBytes, nextProviderDailyUsageBytes); - case "bestdebrid": - return { ...settings, bestToken: "", bestDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "alldebrid": - return { ...settings, allDebridToken: "", allDebridUseWebLogin: false, providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "ddownload": - return { ...settings, ddownloadLogin: "", ddownloadPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "onefichier": - return { ...settings, oneFichierApiKey: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - case "debridlink": - return { - ...settings, - debridLinkApiKeys: "", - providerDailyLimitBytes: nextProviderDailyLimitBytes, - providerDailyUsageBytes: nextProviderDailyUsageBytes, - debridLinkApiKeyDailyLimitBytes: nextDebridLinkApiKeyDailyLimitBytes, - debridLinkApiKeyDailyUsageBytes: nextDebridLinkApiKeyDailyUsageBytes - }; - case "linksnappy": - return { ...settings, linkSnappyLogin: "", linkSnappyPassword: "", providerDailyLimitBytes: nextProviderDailyLimitBytes, providerDailyUsageBytes: nextProviderDailyUsageBytes }; - default: - return settings; - } -} - -function validateAccountDialog(dialog: AccountDialogState): string | null { +function validateAccountDialog(dialog: AccountDialogState): string | null { if (!dialog.kind) { return "Bitte zuerst einen Account-Typ auswählen."; } - const option = findAccountOption(dialog.kind); - if (dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") { - if (dialog.megaAccounts.length === 0) { - return `${option.title}: Mindestens einen Account hinzufügen.`; - } + const option = findAccountOption(dialog.kind); + if (dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web") { + if (!dialog.megaNewLogin.trim()) return `${option.title}: Bitte Login oder E-Mail eintragen.`; + if (!dialog.megaNewPassword) return `${option.title}: Bitte Passwort eintragen.`; } else if (option.needsToken && !dialog.token.trim()) { return `${option.title}: Bitte Zugangstoken eintragen.`; } @@ -1071,8 +778,20 @@ function validateAccountDialog(dialog: AccountDialogState): string | null { } } } - return null; -} + return null; +} + +export function buildAccountCreateCommand(dialog: AccountDialogState): AccountCreateCommand | null { + if (!dialog.kind) return null; + const option = findAccountOption(dialog.kind); + return { + action: "create", + kind: dialog.kind, + identity: dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web" ? dialog.megaNewLogin.trim() : dialog.login.trim(), + secret: dialog.kind === "megadebrid-api" || dialog.kind === "megadebrid-web" ? dialog.megaNewPassword : option.needsToken ? dialog.token : dialog.password, + dailyLimitBytes: parseAccountDailyLimitInputBytes(dialog.dailyLimitGb) || 0 + }; +} const emptyStats = (): DownloadStats => ({ totalDownloaded: 0, @@ -1090,10 +809,10 @@ const emptyStats = (): DownloadStats => ({ const emptySnapshot = (): UiSnapshot => ({ settings: { - language: "en", token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiCredentials: "", megaDebridWebCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "", - debridLinkDisabledKeyIds: [], - archivePasswordList: "", - rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", + language: "en", realDebridUseWebLogin: false, megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestDebridUseWebLogin: false, allDebridUseWebLogin: false, + debridLinkDisabledKeyIds: [], + archivePasswordListConfigured: false, notifyUrlConfigured: false, + rememberToken: true, configuredProviders: [], providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "", autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true, collectMkvToLibrary: false, mkvLibraryDir: "", @@ -1103,7 +822,7 @@ const emptySnapshot = (): UiSnapshot => ({ maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, - notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, + notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, accountListShowDetailedDebridLinkKeys: false, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"], @@ -1126,7 +845,8 @@ const emptySnapshot = (): UiSnapshot => ({ debridAccountStatuses: {}, providerDailyUsageDay: getProviderUsageDayKey(), scheduledStartEpochMs: 0 - }, + }, + accounts: [], session: { version: 2, packageOrder: [], packages: {}, items: {}, runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0, reconnectReason: "", @@ -1140,7 +860,7 @@ const cleanupLabels: Record = { never: "Nie", immediate: "Sofort", on_start: "Beim App-Start", package_done: "Sobald Paket fertig ist" }; -const historyRetentionLabels: Record = { +const historyRetentionLabels: Record = { never: "Nie", session: "Nur aktuelle Session", permanent: "Dauerhaft" @@ -1185,7 +905,7 @@ const KNOWN_HOSTERS: { id: string; label: string }[] = [ { id: "isra", label: "Isra.cloud" } ]; -function providerLabelWithMode(provider: DebridProvider, settings: AppSettings): string { +function providerLabelWithMode(provider: DebridProvider, settings: RendererSettings): string { const base = providerLabels[provider]; if (provider === "megadebrid" || provider === "megadebrid-api" || provider === "megadebrid-web") { return base; @@ -1792,7 +1512,7 @@ export function App(): ReactElement { const [availableUpdate, setAvailableUpdate] = useState(null); const [updateDialogOpen, setUpdateDialogOpen] = useState(false); const [updateInstallProgress, setUpdateInstallProgress] = useState(null); - const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); + const [settingsDraft, setSettingsDraft] = useState(() => createSettingsDraft(emptySnapshot().settings)); const [settingsThemeChoice, setSettingsThemeChoice] = useState(emptySnapshot().settings.theme); const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); @@ -1803,7 +1523,8 @@ export function App(): ReactElement { const [scheduleCountdown, setScheduleCountdown] = useState(""); const [runtimeNow, setRuntimeNow] = useState(() => Date.now()); const updateCheckGenerationRef = useRef(0); - const settingsDirtyRef = useRef(false); + const settingsDirtyRef = useRef(false); + const writeOnlySettingsDirtyRef = useRef(new Set<"archivePasswordList" | "notifyUrl">()); const settingsDraftRevisionRef = useRef(0); useEffect(() => { @@ -1847,11 +1568,10 @@ export function App(): ReactElement { const [downloadProviderFilter, setDownloadProviderFilter] = useState("all"); const [downloadsSortColumn, setDownloadsSortColumn] = useState("name"); const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); - const [showAllPackages, setShowAllPackages] = useState(false); - const [actionBusy, setActionBusy] = useState(false); - const [accountCheckBusy, setAccountCheckBusy] = useState(false); - const [megaCheckingIds, setMegaCheckingIds] = useState>(() => new Set()); - const actionBusyRef = useRef(false); + const [showAllPackages, setShowAllPackages] = useState(false); + const [actionBusy, setActionBusy] = useState(false); + const [accountCheckBusy, setAccountCheckBusy] = useState(false); + const actionBusyRef = useRef(false); const actionUnlockTimerRef = useRef | null>(null); const mountedRef = useRef(true); const [supportTraceEnabled, setSupportTraceEnabled] = useState(false); @@ -2108,7 +1828,7 @@ export function App(): ReactElement { setDebridLinkHostLimitsError(""); setDebridLinkHostLimits({}); try { - const apiKeys = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || ""); + const apiKeys = snapshot.accounts.filter((account) => account.kind === "debridlink-api"); if (apiKeys.length === 0) { throw new Error("Debrid-Link ist nicht konfiguriert"); } @@ -2133,7 +1853,7 @@ export function App(): ReactElement { setDebridLinkHostLimitsLoading(false); } } - }, [settingsDraft.debridLinkApiKeys, showToast]); + }, [snapshot.accounts, showToast]); useEffect(() => { if (keyStatsPopup !== "debridlink") { @@ -2178,7 +1898,8 @@ export function App(): ReactElement { if (state.settings.columnOrder?.length > 0) { setColumnOrder(state.settings.columnOrder); } - setSettingsDraft(state.settings); + setSettingsDraft(createSettingsDraft(state.settings)); + writeOnlySettingsDirtyRef.current.clear(); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; setSettingsDirty(false); @@ -2234,7 +1955,7 @@ export function App(): ReactElement { setColumnOrder(next.settings.columnOrder); } if (!settingsDirtyRef.current) { - setSettingsDraft(next.settings); + setSettingsDraft(createSettingsDraft(next.settings)); } latestStateRef.current = null; } @@ -2368,9 +2089,8 @@ export function App(): ReactElement { renderLimit: AUTO_RENDER_PACKAGE_LIMIT }), [collapsedPackages, deferredDownloadSearch, downloadDisplayMode, downloadFilter, downloadProviderFilter, selectedIds, showAllPackages, snapshot.session.items, snapshot.session.packages, snapshot.session.running, snapshot.settings.hideExtractedItems, visiblePackages]); - const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim()); - const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin - || snapshot.settings.allDebridToken !== settingsDraft.allDebridToken; + const hasSavedAllDebridAccount = snapshot.accounts.some((account) => account.provider === "alldebrid"); + const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin; useEffect(() => { if (!snapshot.session.running) { @@ -2388,7 +2108,7 @@ export function App(): ReactElement { return; } void loadAllDebridHostInfo(true); - }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); + }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); const allPackagesCollapsed = useMemo(() => ( packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id]) @@ -2396,15 +2116,7 @@ export function App(): ReactElement { const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]); - const hasDdownloadAccount = useMemo(() => - Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()), - [settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]); - - const hasOneFichierAccount = useMemo(() => - Boolean((settingsDraft.oneFichierApiKey || "").trim()), - [settingsDraft.oneFichierApiKey]); - - const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0); + const totalConfiguredAccounts = snapshot.accounts.length; const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]); @@ -2460,7 +2172,7 @@ export function App(): ReactElement { setProviderDropTarget(null); }, []); - const normalizedSettingsDraft: AppSettings = useMemo(() => ({ + const normalizedSettingsDraft: RendererSettingsDraft = useMemo(() => ({ ...settingsDraft, ...normalizeProviderSelectionForSettings(settingsDraft) }), [settingsDraft]); @@ -2473,14 +2185,14 @@ export function App(): ReactElement { continue; } const option = findAccountOption(kind); - let statusLabel = "Aktiviert"; - let note = ""; + let statusLabel = "Aktiviert"; + let note = ""; if (kind === "megadebrid-api") { - const megaAccountCount = getMegaDebridAccountsForMode(settingsDraft, "api").length; + const megaAccountCount = accountsOfKind(kind, snapshot.accounts).length; statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; note = "Nur API aktiv. Kein Web-Fallback."; } else if (kind === "megadebrid-web") { - const megaAccountCount = getMegaDebridAccountsForMode(settingsDraft, "web").length; + const megaAccountCount = accountsOfKind(kind, snapshot.accounts).length; statusLabel = megaAccountCount > 1 ? `${megaAccountCount} Accounts` : "Aktiviert"; note = "Nur Web aktiv. Kein API-Fallback."; } else if (kind === "realdebrid-web") { @@ -2500,9 +2212,9 @@ export function App(): ReactElement { if (allDebridSettingsDirty && hasSavedAllDebridAccount) { note = "Status basiert auf den zuletzt gespeicherten AllDebrid-Daten."; } - } - if (kind === "debridlink-api") { - const keyCount = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").length; + } + if (kind === "debridlink-api") { + const keyCount = accountsOfKind(kind, snapshot.accounts).length; statusLabel = keyCount > 1 ? `${keyCount} API-Keys` : "Aktiviert"; } const provider = getAccountServiceProvider(service); @@ -2515,26 +2227,26 @@ export function App(): ReactElement { providerDailyUsageDay: snapshot.settings.providerDailyUsageDay }, provider); let dailyLimitReached = dailyLimitBytes > 0 && dailyUsedBytes >= dailyLimitBytes; - const isDisabled = (settingsDraft.disabledProviders || []).includes(provider); - const debridLinkKeys = kind === "debridlink-api" - ? parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").map((key) => { - const keyDailyUsedBytes = getDebridLinkApiKeyDailyUsageBytes(snapshot.settings, key.id); - const keyDailyLimitBytes = getDebridLinkApiKeyDailyLimitBytes(settingsDraft, key.id); - const keyDailyRemainingBytes = getDebridLinkApiKeyDailyRemainingBytes({ + const isDisabled = (settingsDraft.disabledProviders || []).includes(provider); + const debridLinkKeys = kind === "debridlink-api" + ? accountsOfKind(kind, snapshot.accounts).map((account, index) => { + const keyDailyUsedBytes = account.dailyUsageBytes; + const keyDailyLimitBytes = account.dailyLimitBytes; + const keyDailyRemainingBytes = getDebridLinkApiKeyDailyRemainingBytes({ debridLinkApiKeyDailyLimitBytes: settingsDraft.debridLinkApiKeyDailyLimitBytes, debridLinkApiKeyDailyUsageBytes: snapshot.settings.debridLinkApiKeyDailyUsageBytes, providerDailyLimitBytes: settingsDraft.providerDailyLimitBytes, providerDailyUsageBytes: snapshot.settings.providerDailyUsageBytes, providerDailyUsageDay: snapshot.settings.providerDailyUsageDay - }, key.id); - return { - id: key.id, - label: key.label, - token: key.token, - masked: key.masked, - disabled: (settingsDraft.debridLinkDisabledKeyIds || []).includes(key.id), - dailyUsedBytes: keyDailyUsedBytes, - totalUsedBytes: getDebridLinkApiKeyTotalUsageBytes(snapshot.settings, key.id), + }, account.accountId); + return { + id: account.accountId, + label: `Key ${index + 1}`, + token: "", + masked: account.maskedIdentity, + disabled: !account.enabled, + dailyUsedBytes: keyDailyUsedBytes, + totalUsedBytes: account.totalUsageBytes, dailyLimitBytes: keyDailyLimitBytes, dailyRemainingBytes: keyDailyRemainingBytes, dailyLimitReached: keyDailyLimitBytes > 0 && keyDailyUsedBytes >= keyDailyLimitBytes @@ -2571,8 +2283,8 @@ export function App(): ReactElement { serviceLabel: option.serviceLabel, modeLabel: option.modeLabel, statusLabel: isDisabled ? "Deaktiviert" : statusLabel, - summary: summarizeAccount(kind, settingsDraft), - summaryLines: summarizeAccountLines(kind, settingsDraft), + summary: summarizeAccount(kind, snapshot.accounts), + summaryLines: summarizeAccountLines(kind, snapshot.accounts, settingsDraft.accountListShowDetailedDebridLinkKeys), note, disabled: isDisabled, dailyUsedBytes, @@ -2584,7 +2296,7 @@ export function App(): ReactElement { }); } return entries; - }, [settingsDraft, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]); + }, [settingsDraft, snapshot.accounts, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]); const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]); @@ -2592,34 +2304,31 @@ export function App(): ReactElement { const rows: AccountTableRow[] = []; for (const entry of configuredAccounts) { if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") { - const mode = entry.kind === "megadebrid-web" ? "web" : "api"; - const accounts = getMegaDebridAccountsForMode(settingsDraft, mode); - const disabledAccountIds = getMegaDebridDisabledAccountIdsForMode(settingsDraft, mode); - for (const acc of accounts) { - const used = getMegaDebridAccountDailyUsageBytes(snapshot.settings, acc.id); - const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id); - rows.push({ - rowKey: `mega-${entry.kind}-${acc.id}`, + const accounts = accountsOfKind(entry.kind, snapshot.accounts); + for (const acc of accounts) { + const used = acc.dailyUsageBytes; + const limit = acc.dailyLimitBytes; + rows.push({ + rowKey: `mega-${entry.kind}-${acc.accountId}`, entry, hosterLabel: entry.serviceLabel, modeLabel: entry.modeLabel, - username: acc.login, + username: acc.identity, credentialLabel: "••••••", - accountId: acc.id, - checkable: true, - disabled: disabledAccountIds.includes(acc.id), + accountId: acc.accountId, + checkable: true, + disabled: !acc.enabled, dailyUsedBytes: used, - dailyLimitBytes: limit, - dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0, - totalUsedBytes: getMegaDebridAccountTotalUsageBytes(snapshot.settings, acc.id), + dailyLimitBytes: limit, + dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0, + totalUsedBytes: acc.totalUsageBytes, toggleKind: "mega", - megaLogin: acc.login, editTarget: { type: "mega", - rowKey: `mega-${entry.kind}-${acc.id}`, + rowKey: `mega-${entry.kind}-${acc.accountId}`, kind: entry.kind, service: entry.service as "megadebrid-api" | "megadebrid-web", - accountId: acc.id + accountId: acc.accountId } }); } @@ -2656,7 +2365,7 @@ export function App(): ReactElement { entry, hosterLabel: entry.serviceLabel, modeLabel: entry.modeLabel, - username: getStoredAccountUsername(entry.kind, settingsDraft), + username: getStoredAccountUsername(entry.kind, snapshot.accounts), credentialLabel: getAccountCredentialLabel(entry.kind), accountId: null, checkable: false, @@ -2677,7 +2386,7 @@ export function App(): ReactElement { } } return rows; - }, [configuredAccounts, settingsDraft, snapshot.settings]); + }, [configuredAccounts, snapshot.accounts]); const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none"); const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none")); @@ -2865,8 +2574,9 @@ export function App(): ReactElement { }); }; - const applyPersistedSettings = (result: AppSettings): void => { - setSettingsDraft(result); + const applyPersistedSettings = (result: RendererSettings): void => { + setSettingsDraft(createSettingsDraft(result)); + writeOnlySettingsDirtyRef.current.clear(); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; setSettingsDirty(false); @@ -2875,7 +2585,7 @@ export function App(): ReactElement { applyTheme(result.theme); }; - const syncLiveProviderUsageSettings = (result: AppSettings): void => { + const syncLiveProviderUsageSettings = (result: RendererSettings): void => { setSnapshot((prev) => ({ ...prev, settings: result })); if (!settingsDirtyRef.current) { applyPersistedSettings(result); @@ -2894,14 +2604,17 @@ export function App(): ReactElement { })); }; - const persistSpecificSettings = async (nextDraft: AppSettings): Promise => { + const persistSpecificSettings = async (nextDraft: RendererSettingsDraft): Promise => { const revisionAtStart = settingsDraftRevisionRef.current; const draftAtStart = settingsDraft; const normalizedDraft = { ...nextDraft, ...normalizeProviderSelectionForSettings(nextDraft) }; - const result = await window.rd.updateSettings(normalizedDraft); + const update: RendererSettingsUpdate = { ...normalizedDraft }; + if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList; + if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl; + const result = await window.rd.updateSettings(update); if (settingsDraftRevisionRef.current === revisionAtStart) { applyPersistedSettings(result); } else { @@ -2954,24 +2667,6 @@ export function App(): ReactElement { } }, [showToast]); - const runMegaAccountCheck = useCallback(async (login: string, password: string): Promise => { - const trimmedLogin = login.trim(); - const trimmedPassword = password.trim(); - if (!trimmedLogin || !trimmedPassword) return; - const accId = getMegaDebridAccountId(trimmedLogin); - setMegaCheckingIds((prev) => { const next = new Set(prev); next.add(accId); return next; }); - try { - const status = await window.rd.checkMegaDebridAccount(trimmedLogin, trimmedPassword); - if (status) { - showToast(status.valid ? `Account geprüft — ${status.message}` : `Account ungültig — ${status.message}`, 3200); - } - } catch (error) { - showToast(`Account-Check fehlgeschlagen: ${String(error)}`, 3200); - } finally { - setMegaCheckingIds((prev) => { const next = new Set(prev); next.delete(accId); return next; }); - } - }, [showToast]); - const openCreateAccountDialog = (): void => { setAccountDialogSearch(""); setAccountDialogModeFilter("all"); @@ -2980,7 +2675,7 @@ export function App(): ReactElement { const openEditAccountDialog = (row: AccountTableRow): void => { try { - setAccountEditDialog(createAccountEditState(row.editTarget, settingsDraft)); + setAccountEditDialog(createAccountEditState(row.editTarget, snapshot.accounts)); } catch (error) { showToast(String(error), 3200); } @@ -3023,22 +2718,15 @@ export function App(): ReactElement { return; } const editSnapshot = accountEditDialog; - const validationError = validateAccountEdit(editSnapshot, settingsDraft); + const validationError = validateAccountEdit(editSnapshot, snapshot.accounts); if (validationError) { showToast(validationError, 2800); return; } await performQuickAction(async () => { - const nextDraft = applyAccountEdit(settingsDraft, editSnapshot); - if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink") { - const statuses = await window.rd.checkDebridAccounts(buildAccountEditCheckSettings(nextDraft, editSnapshot), true, getAccountEditExpectedStatusId(editSnapshot) || undefined); - const statusError = validateAccountEditStatuses(editSnapshot, statuses); - if (statusError) { - showToast(`Prüfung fehlgeschlagen: ${statusError}`, 4200); - return; - } - } - await persistSpecificSettings(nextDraft); + const result = await window.rd.replaceAccount(buildAccountReplaceCommand(editSnapshot)); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); closeAccountEditDialog(); if (quickAction) { await runAccountQuickAction(quickAction); @@ -3054,51 +2742,10 @@ export function App(): ReactElement { if (!accountDialog) { return; } - let dialogSnapshot = accountDialog; - let newIdentityId: string | null = null; - let newMegaCredentials: { login: string; password: string } | null = null; - let newDebridLinkToken = ""; - if (dialogSnapshot.mode === "create" && (dialogSnapshot.kind === "megadebrid-api" || dialogSnapshot.kind === "megadebrid-web")) { - const login = dialogSnapshot.megaNewLogin.trim(); - const password = dialogSnapshot.megaNewPassword; - if (!login || !password) { - showToast("Mega-Debrid: Bitte Login und Passwort eintragen.", 2800); - return; - } - if (dialogSnapshot.megaAccounts.some((account) => account.login.trim().toLowerCase() === login.toLowerCase())) { - showToast("Dieser Mega-Debrid-Account ist bereits vorhanden.", 2800); - return; - } - newIdentityId = getMegaDebridAccountId(login); - newMegaCredentials = { login, password }; - dialogSnapshot = { - ...dialogSnapshot, - megaAccounts: [...dialogSnapshot.megaAccounts, newMegaCredentials], - megaNewLogin: "", - megaNewPassword: "" - }; - } - if (dialogSnapshot.mode === "create" && dialogSnapshot.kind === "debridlink-api") { - const newKeys = parseDebridLinkApiKeys(dialogSnapshot.token); - if (newKeys.length !== 1) { + const dialogSnapshot = accountDialog; + if (dialogSnapshot.kind === "debridlink-api" && parseDebridLinkApiKeys(dialogSnapshot.token).length !== 1) { showToast("Debrid-Link: Bitte genau einen API-Key eintragen.", 2800); return; - } - const existingKeys = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || ""); - if (existingKeys.some((key) => key.id === newKeys[0].id)) { - showToast("Dieser Debrid-Link-Key ist bereits vorhanden.", 2800); - return; - } - newIdentityId = newKeys[0].id; - newDebridLinkToken = newKeys[0].token; - dialogSnapshot = { - ...dialogSnapshot, - token: [...existingKeys.map((key) => key.token), newKeys[0].token].join("\n"), - keyDailyLimitGbById: { - ...buildDebridLinkKeyLimitInputs(settingsDraft.debridLinkApiKeys || "", undefined, settingsDraft), - [newKeys[0].id]: accountDialog.dailyLimitGb - } - }; } const validationError = validateAccountDialog(dialogSnapshot); if (validationError) { @@ -3107,42 +2754,11 @@ export function App(): ReactElement { } const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null; await performQuickAction(async () => { - const nextDraft = applyAccountDialogToSettings(settingsDraft, dialogSnapshot); - const targetedOption = selectedOption && newIdentityId ? { - service: selectedOption.service === "megadebrid-web" ? "megadebrid-api" : selectedOption.service - } as Pick : null; - const targetedCheck = targetedOption && newIdentityId ? buildTargetedAccountCheck(targetedOption, newIdentityId) : null; - if (targetedCheck) { - const checkSettings = targetedCheck.service === "megadebrid-api" && newMegaCredentials - ? { - ...nextDraft, - megaCredentials: serializeMegaDebridAccounts([newMegaCredentials]), - megaLogin: newMegaCredentials.login, - megaPassword: newMegaCredentials.password, - megaDebridApiCredentials: selectedOption?.service === "megadebrid-web" ? "" : serializeMegaDebridAccounts([newMegaCredentials]), - megaDebridWebCredentials: selectedOption?.service === "megadebrid-web" ? serializeMegaDebridAccounts([newMegaCredentials]) : "", - megaDebridApiEnabled: selectedOption?.service !== "megadebrid-web", - megaDebridWebEnabled: selectedOption?.service === "megadebrid-web", - debridLinkApiKeys: "" - } - : { - ...nextDraft, - megaCredentials: "", - megaLogin: "", - megaPassword: "", - megaDebridApiCredentials: "", - megaDebridWebCredentials: "", - megaDebridApiEnabled: false, - megaDebridWebEnabled: false, - debridLinkApiKeys: newDebridLinkToken - }; - const statuses = await window.rd.checkDebridAccounts(checkSettings, true, targetedCheck.expectedStatusId); - const status = statuses.length === 1 && statuses[0].accountId === targetedCheck.expectedStatusId ? statuses[0] : null; - if (!status || !status.valid) { - throw new Error(status?.message || "Die Prüfung hat nicht genau den neuen Account bestätigt."); - } - } - await persistSpecificSettings(nextDraft); + const command = buildAccountCreateCommand(dialogSnapshot); + if (!command) throw new Error("Account-Payload ist ungültig"); + const result = await window.rd.createAccount(command); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); closeAccountDialog(); if (quickAction) { await runAccountQuickAction(quickAction); @@ -3155,28 +2771,6 @@ export function App(): ReactElement { }); }; - const onRemoveAccount = async (entry: ConfiguredAccountEntry): Promise => { - const confirmed = await askConfirmPrompt({ - title: `${entry.serviceLabel} entfernen`, - message: `Soll ${entry.serviceLabel} wirklich aus der Accountliste entfernt werden?`, - confirmLabel: "Entfernen", - danger: true - }); - if (!confirmed) { - return; - } - await performQuickAction(async () => { - const nextDraft = clearAccountServiceFromSettings(settingsDraft, entry.service); - await persistSpecificSettings(nextDraft); - if (entry.service === "alldebrid") { - setAllDebridHostInfo(null); - } - showToast(`${entry.serviceLabel} entfernt`, 2200); - }, (error) => { - showToast(`Account konnte nicht entfernt werden: ${String(error)}`, 3200); - }); - }; - const onResetAccountDailyUsage = async (entry: ConfiguredAccountEntry): Promise => { await performQuickAction(async () => { const result = await window.rd.resetProviderDailyUsage(getAccountServiceProvider(entry.service)); @@ -3203,7 +2797,7 @@ export function App(): ReactElement { const nextDisabledIds = key.disabled ? currentDisabledIds.filter((existingId) => existingId !== key.id) : [...currentDisabledIds, key.id]; - const nextDraft: AppSettings = { + const nextDraft: RendererSettingsDraft = { ...settingsDraft, debridLinkDisabledKeyIds: nextDisabledIds }; @@ -3231,14 +2825,13 @@ export function App(): ReactElement { }); }; - const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", login: string, currentlyDisabled: boolean): Promise => { - const accId = getMegaDebridAccountId(login.trim()); + const onToggleMegaAccountEnabled = async (kind: "megadebrid-api" | "megadebrid-web", accountId: string, currentlyDisabled: boolean): Promise => { await performQuickAction(async () => { const mode = kind === "megadebrid-web" ? "web" : "api"; - const current = getMegaDebridDisabledAccountIdsForMode(settingsDraft, mode); - const next = currentlyDisabled ? current.filter((id) => id !== accId) : [...current, accId]; - const apiDisabledIds = mode === "api" ? next : getMegaDebridDisabledAccountIdsForMode(settingsDraft, "api"); - const webDisabledIds = mode === "web" ? next : getMegaDebridDisabledAccountIdsForMode(settingsDraft, "web"); + const current = mode === "api" ? settingsDraft.megaDebridApiDisabledAccountIds : settingsDraft.megaDebridWebDisabledAccountIds; + const next = currentlyDisabled ? current.filter((id) => id !== accountId) : [...current, accountId]; + const apiDisabledIds = mode === "api" ? next : settingsDraft.megaDebridApiDisabledAccountIds; + const webDisabledIds = mode === "web" ? next : settingsDraft.megaDebridWebDisabledAccountIds; await persistSpecificSettings({ ...settingsDraft, megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], @@ -3251,12 +2844,13 @@ export function App(): ReactElement { }); }; - const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise => { + const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise => { const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true }); - if (!confirmed) return; - await performQuickAction(async () => { - const remaining = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").filter((k) => k.id !== key.id); - await persistSpecificSettings({ ...settingsDraft, debridLinkApiKeys: remaining.map((k) => k.token).join("\n") }); + if (!confirmed) return; + await performQuickAction(async () => { + const result = await window.rd.deleteAccount({ action: "delete", kind: "debridlink-api", accountId: key.id }); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); showToast("Key entfernt", 2000); }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); }; @@ -3268,7 +2862,7 @@ export function App(): ReactElement { const nextDisabledProviders = current.includes(provider) ? current.filter((existing) => existing !== provider) : [...current, provider]; - const nextDraft: AppSettings = { + const nextDraft: RendererSettingsDraft = { ...settingsDraft, disabledProviders: nextDisabledProviders }; @@ -3286,8 +2880,8 @@ export function App(): ReactElement { const toggleAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); - if (row.toggleKind === "mega" && row.megaLogin) { - void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.megaLogin, row.disabled); + if (row.toggleKind === "mega" && row.accountId) { + void onToggleMegaAccountEnabled(row.entry.kind as "megadebrid-api" | "megadebrid-web", row.accountId, row.disabled); } else if (row.toggleKind === "dl" && row.dlKey) { void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey); } else { @@ -3305,7 +2899,7 @@ export function App(): ReactElement { accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string), enabled ); - const nextDraft: AppSettings = { + const nextDraft: RendererSettingsDraft = { ...settingsDraft, ...nextEnabledState, megaDebridApiDisabledAccountIds: enabled ? [] : accountRows.filter((row) => row.entry.kind === "megadebrid-api" && row.accountId).map((row) => row.accountId as string), @@ -3332,7 +2926,9 @@ export function App(): ReactElement { return; } await performQuickAction(async () => { - await persistSpecificSettings(removeAccountTarget(settingsDraft, row.editTarget)); + const result = await window.rd.deleteAccount(buildAccountDeleteCommand(row.editTarget)); + setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); + applyPersistedSettings(result.settings); if (row.entry.service === "alldebrid") { setAllDebridHostInfo(null); } @@ -3345,15 +2941,7 @@ export function App(): ReactElement { const checkAccountTableRow = (row: AccountTableRow): void => { setAccountContextMenu(null); - if (row.toggleKind === "mega" && row.megaLogin) { - const account = getMegaDebridAccountsForMode(settingsDraft, row.entry.kind === "megadebrid-web" ? "web" : "api") - .find((entry) => entry.id === row.accountId); - if (account) { - void runMegaAccountCheck(account.login, account.password); - } - return; - } - if (row.toggleKind === "dl") { + if (row.toggleKind === "mega" || row.toggleKind === "dl") { void checkAllAccounts(); return; } @@ -3374,9 +2962,12 @@ export function App(): ReactElement { }); }; - const persistDraftSettings = async (): Promise => { - const revisionAtStart = settingsDraftRevisionRef.current; - const result = await window.rd.updateSettings(normalizedSettingsDraft); + const persistDraftSettings = async (): Promise => { + const revisionAtStart = settingsDraftRevisionRef.current; + const update: RendererSettingsUpdate = { ...normalizedSettingsDraft }; + if (!writeOnlySettingsDirtyRef.current.has("archivePasswordList")) delete update.archivePasswordList; + if (!writeOnlySettingsDirtyRef.current.has("notifyUrl")) delete update.notifyUrl; + const result = await window.rd.updateSettings(update); if (settingsDraftRevisionRef.current === revisionAtStart) { applyPersistedSettings(result); } @@ -3839,7 +3430,7 @@ export function App(): ReactElement { input.click(); }; - const setBool = (key: keyof AppSettings, value: boolean): void => { + const setBool = (key: keyof RendererSettingsDraft, value: boolean): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; settingsDirtyRef.current = true; @@ -3847,7 +3438,8 @@ export function App(): ReactElement { setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; - const setText = (key: keyof AppSettings, value: string): void => { + const setText = (key: keyof RendererSettingsDraft, value: string): void => { + if (key === "archivePasswordList" || key === "notifyUrl") writeOnlySettingsDirtyRef.current.add(key); settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; settingsDirtyRef.current = true; @@ -3855,7 +3447,7 @@ export function App(): ReactElement { setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; - const setNum = (key: keyof AppSettings, value: number): void => { + const setNum = (key: keyof RendererSettingsDraft, value: number): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; settingsDirtyRef.current = true; @@ -5230,18 +4822,15 @@ export function App(): ReactElement { : null; const accountSources = useMemo(() => accountRows.map((row) => { const checkedStatus = row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId] : undefined; - const checking = Boolean(row.accountId && megaCheckingIds.has(row.accountId)); const state: AccountRowSource["status"]["state"] = row.disabled ? "disabled" - : checking - ? "checking" - : !checkedStatus - ? "unchecked" - : checkedStatus && !checkedStatus.valid - ? "invalid" - : checkedStatus && !checkedStatus.isPremium - ? "free" - : "premium"; + : !checkedStatus + ? "unchecked" + : checkedStatus && !checkedStatus.valid + ? "invalid" + : checkedStatus && !checkedStatus.isPremium + ? "free" + : "premium"; return { identityId: row.accountId || row.rowKey, service: row.entry.service, @@ -5261,7 +4850,7 @@ export function App(): ReactElement { credentialKind: row.credentialLabel.includes("API") ? "api-key" : row.credentialLabel.includes("•") ? "password" : "protected", canCheck: row.checkable }; - }), [accountRows, megaCheckingIds, snapshot.settings.debridAccountStatuses]); + }), [accountRows, snapshot.settings.debridAccountStatuses]); const selectedAccountViewId = useMemo(() => { const selectedRow = selectedAccountRowKey ? accountRows.find((row) => row.rowKey === selectedAccountRowKey) : null; return selectedRow ? accountRowViewId(selectedRow) : null; @@ -5419,10 +5008,10 @@ export function App(): ReactElement { return; } if (typeof value === "boolean") { - setBool(fieldId as keyof AppSettings, value); + setBool(fieldId as keyof RendererSettingsDraft, value); return; } - const numericLimits: Partial> = { + const numericLimits: Partial> = { maxParallel: [1, 50, 1], retryLimit: [0, 99, 0], historyMaxEntries: [50, 100000, 500], @@ -5430,12 +5019,12 @@ export function App(): ReactElement { maxParallelExtract: [1, 8, 2], reconnectWaitSeconds: [10, 600, 45] }; - const bounds = numericLimits[fieldId as keyof AppSettings]; + const bounds = numericLimits[fieldId as keyof RendererSettingsDraft]; if (bounds) { const parsed = Number(value); - setNum(fieldId as keyof AppSettings, Math.max(bounds[0], Math.min(bounds[1], Number.isFinite(parsed) ? parsed : bounds[2]))); + setNum(fieldId as keyof RendererSettingsDraft, Math.max(bounds[0], Math.min(bounds[1], Number.isFinite(parsed) ? parsed : bounds[2]))); } else { - setText(fieldId as keyof AppSettings, String(value)); + setText(fieldId as keyof RendererSettingsDraft, String(value)); } }, onCommit: (fieldId, value) => { @@ -5563,7 +5152,7 @@ export function App(): ReactElement { ] : []; const checkAccountEditDialog = (): void => { if (!accountEditDialog) return; - const validationError = validateAccountEdit(accountEditDialog, settingsDraft); + const validationError = validateAccountEdit(accountEditDialog, snapshot.accounts); if (validationError) { showToast(validationError, 2800); return; @@ -5571,10 +5160,14 @@ export function App(): ReactElement { const editSnapshot = accountEditDialog; void performQuickAction(async () => { if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink") { - const checkSettings = buildAccountEditCheckSettings(applyAccountEdit(settingsDraft, editSnapshot), editSnapshot); - const statuses = await window.rd.checkDebridAccounts(checkSettings, true, getAccountEditExpectedStatusId(editSnapshot) || undefined); - const statusError = validateAccountEditStatuses(editSnapshot, statuses); - if (statusError) throw new Error(statusError); + const secret = editSnapshot.target.type === "mega" ? editSnapshot.password : editSnapshot.token; + const status = await window.rd.checkAccountCredentials({ + kind: editSnapshot.target.kind, + accountId: editSnapshot.target.type === "mega" ? editSnapshot.target.accountId : editSnapshot.target.keyId, + identity: secret ? editSnapshot.login : undefined, + secret: secret || undefined + }); + if (!status.valid) throw new Error(status.message || "Zugangsdaten ungültig"); showToast("Account erfolgreich geprüft", 2200); return; } diff --git a/src/renderer/account-edit.ts b/src/renderer/account-edit.ts index 6e61548..fbdb30d 100644 --- a/src/renderer/account-edit.ts +++ b/src/renderer/account-edit.ts @@ -1,505 +1,99 @@ -import { parseDebridLinkApiKeys, getDebridLinkApiKeyId } from "../shared/debrid-link-keys"; -import { getMegaDebridAccountId, getMegaDebridAccountsForMode, getMegaDebridCredentialsForMode, getMegaDebridDisabledAccountIdsForMode, mergeMegaDebridCredentialPools, parseMegaDebridAccounts, serializeMegaDebridAccounts, type MegaDebridAccountMode } from "../shared/mega-debrid-accounts"; -import type { AppSettings, DebridAccountStatus, DebridProvider } from "../shared/types"; - -export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy"; - -export type AccountKind = - | "realdebrid-api" - | "realdebrid-web" - | "megadebrid-api" - | "megadebrid-web" - | "bestdebrid-api" - | "bestdebrid-web" - | "alldebrid-api" - | "alldebrid-web" - | "ddownload-login" - | "onefichier-api" - | "debridlink-api" - | "linksnappy-login"; - -export type SingleAccountKind = Exclude; - -export type AccountEditTarget = - | { - type: "single"; - rowKey: string; - kind: SingleAccountKind; - service: AccountService; - provider: DebridProvider; - } - | { - type: "mega"; - rowKey: string; - kind: "megadebrid-api" | "megadebrid-web"; - service: "megadebrid-api" | "megadebrid-web"; - accountId: string; - } - | { - type: "debridlink"; - rowKey: string; - kind: "debridlink-api"; - service: "debridlink"; - keyId: string; - }; - -export interface AccountEditState { - target: AccountEditTarget; - login: string; - password: string; - token: string; - dailyLimitGb: string; - originalDailyLimitBytes: number; -} - +import type { AccountDeleteCommand, AccountReplaceCommand, DebridProvider, RendererAccount, RendererAccountKind } from "../shared/types"; + +export type AccountService = "realdebrid" | "megadebrid-api" | "megadebrid-web" | "bestdebrid" | "alldebrid" | "ddownload" | "onefichier" | "debridlink" | "linksnappy"; +export type AccountKind = RendererAccountKind; +export type SingleAccountKind = Exclude; + +export type AccountEditTarget = + | { type: "single"; rowKey: string; kind: SingleAccountKind; service: AccountService; provider: DebridProvider } + | { type: "mega"; rowKey: string; kind: "megadebrid-api" | "megadebrid-web"; service: "megadebrid-api" | "megadebrid-web"; accountId: string } + | { type: "debridlink"; rowKey: string; kind: "debridlink-api"; service: "debridlink"; keyId: string }; + +export interface AccountEditState { + target: AccountEditTarget; + login: string; + password: string; + token: string; + dailyLimitGb: string; + originalDailyLimitBytes: number; +} + const BYTES_PER_GIB = 1024 * 1024 * 1024; -function getMegaTargetMode(target: Extract): MegaDebridAccountMode { - return target.kind === "megadebrid-web" ? "web" : "api"; +function formatDailyLimit(limitBytes: number): string { + if (!Number.isFinite(limitBytes) || limitBytes <= 0) return ""; + const gib = limitBytes / BYTES_PER_GIB; + const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2; + return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1"); } - -function formatDailyLimit(limitBytes: number): string { - if (!Number.isFinite(limitBytes) || limitBytes <= 0) { - return ""; - } - const gib = limitBytes / BYTES_PER_GIB; - const precision = gib >= 100 ? 0 : gib >= 10 ? 1 : 2; - return gib.toFixed(precision).replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1"); -} - -function parseDailyLimit(value: string): number | null { - const normalized = value.trim().replace(",", "."); - if (!normalized) { - return null; - } - const parsed = Number(normalized); - if (!Number.isFinite(parsed) || parsed < 0) { - return null; - } - return Math.floor(parsed * BYTES_PER_GIB); -} - -function withoutRecordKeys(record: Record, ...keys: string[]): Record { - const blocked = new Set(keys); - return Object.fromEntries(Object.entries(record || {}).filter(([key]) => !blocked.has(key))); -} - -function resolveDailyLimit(value: string, originalBytes: number): number | null { - return value === formatDailyLimit(originalBytes) ? (originalBytes > 0 ? originalBytes : null) : parseDailyLimit(value); -} - -function updateTargetLimit(record: Record, oldId: string, newId: string, value: string, originalBytes: number): Record { - const next = withoutRecordKeys(record || {}, oldId, newId); - const limit = resolveDailyLimit(value, originalBytes); - if (limit && limit > 0) { - next[newId] = limit; - } - return next; -} - -function migrateDisabledId(ids: readonly string[], oldId: string, newId: string): string[] { - const wasDisabled = ids.includes(oldId); - const next = ids.filter((id) => id !== oldId && id !== newId); - if (wasDisabled) { - next.push(newId); - } - return next; -} - -function updateProviderLimit(settings: AppSettings, provider: DebridProvider, value: string, originalBytes: number): AppSettings["providerDailyLimitBytes"] { - const next = { ...(settings.providerDailyLimitBytes || {}) }; - const limit = resolveDailyLimit(value, originalBytes); - if (limit && limit > 0) { - next[provider] = limit; - } else { - delete next[provider]; - } - return next; -} - -function createSingleEditState(target: Extract, settings: AppSettings): AccountEditState { - const originalDailyLimitBytes = settings.providerDailyLimitBytes?.[target.provider] || 0; - const base = { - target, - login: "", - password: "", - token: "", - dailyLimitGb: formatDailyLimit(originalDailyLimitBytes), - originalDailyLimitBytes - }; - switch (target.kind) { - case "realdebrid-api": - return { ...base, token: settings.token }; - case "bestdebrid-api": - return { ...base, token: settings.bestToken }; - case "alldebrid-api": - return { ...base, token: settings.allDebridToken }; - case "onefichier-api": - return { ...base, token: settings.oneFichierApiKey }; - case "ddownload-login": - return { ...base, login: settings.ddownloadLogin, password: settings.ddownloadPassword }; - case "linksnappy-login": - return { ...base, login: settings.linkSnappyLogin, password: settings.linkSnappyPassword }; - default: - return base; - } -} - -export function createAccountEditState(target: AccountEditTarget, settings: AppSettings): AccountEditState { - if (target.type === "single") { - return createSingleEditState(target, settings); - } - if (target.type === "mega") { - const account = getMegaDebridAccountsForMode(settings, getMegaTargetMode(target)) - .find((entry) => entry.id === target.accountId); - if (!account) { - throw new Error("Der ausgewählte Mega-Debrid-Account wurde nicht gefunden."); - } - return { - target, - login: account.login, - password: account.password, - token: "", - dailyLimitGb: formatDailyLimit(settings.megaDebridAccountDailyLimitBytes?.[account.id] || 0), - originalDailyLimitBytes: settings.megaDebridAccountDailyLimitBytes?.[account.id] || 0 - }; - } - const key = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "").find((entry) => entry.id === target.keyId); - if (!key) { - throw new Error("Der ausgewählte Debrid-Link-Key wurde nicht gefunden."); - } - return { - target, - login: "", - password: "", - token: key.token, - dailyLimitGb: formatDailyLimit(settings.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0), - originalDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes?.[key.id] || 0 - }; -} - -function validateDailyLimit(value: string): string | null { - const normalized = value.trim().replace(",", "."); - if (!normalized) { - return null; - } - const parsed = Number(normalized); - return Number.isFinite(parsed) && parsed >= 0 ? null : "Das Tageslimit muss eine positive Zahl oder 0 sein."; -} - -export function validateAccountEdit(state: AccountEditState, settings: AppSettings): string | null { - const limitError = validateDailyLimit(state.dailyLimitGb); - if (limitError) { - return limitError; - } - if (state.target.type === "mega") { - const target = state.target; - const login = state.login.trim(); - if (!login || !state.password.trim()) { - return "Login und Passwort werden benötigt."; - } - if (/[:\r\n]/.test(login)) { - return "Der Login darf keinen Doppelpunkt oder Zeilenumbruch enthalten."; - } - if (/[\r\n]/.test(state.password)) { - return "Das Passwort darf keinen Zeilenumbruch enthalten."; - } - const accounts = getMegaDebridAccountsForMode(settings, getMegaTargetMode(target)); - if (!accounts.some((entry) => entry.id === target.accountId)) { - return "Der ausgewählte Mega-Debrid-Account wurde nicht gefunden."; - } - if (accounts.some((entry) => entry.id !== target.accountId && entry.login.toLowerCase() === login.toLowerCase())) { - return "Dieser Mega-Debrid-Login ist bereits vorhanden."; - } - return null; - } - if (state.target.type === "debridlink") { - const target = state.target; - const token = state.token.trim(); - if (!token) { - return "Der API-Key wird benötigt."; - } - if (/[,\r\n]/.test(token)) { - return "Beim Bearbeiten ist genau ein API-Key erlaubt."; - } - const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); - if (!keys.some((entry) => entry.id === target.keyId)) { - return "Der ausgewählte Debrid-Link-Key wurde nicht gefunden."; - } - if (keys.some((entry) => entry.id !== target.keyId && entry.token === token)) { - return "Dieser Debrid-Link-Key ist bereits vorhanden."; - } - return null; - } - if (["realdebrid-api", "bestdebrid-api", "alldebrid-api", "onefichier-api"].includes(state.target.kind) && !state.token.trim()) { - return "Der Zugangstoken wird benötigt."; - } - if (["ddownload-login", "linksnappy-login"].includes(state.target.kind)) { - if (!state.login.trim() || !state.password.trim()) { - return "Login und Passwort werden benötigt."; - } - } - return null; -} - -function applySingleEdit(settings: AppSettings, state: AccountEditState & { target: Extract }): AppSettings { - const providerDailyLimitBytes = updateProviderLimit(settings, state.target.provider, state.dailyLimitGb, state.originalDailyLimitBytes); - const token = state.token.trim(); - const login = state.login.trim(); - switch (state.target.kind) { - case "realdebrid-api": - return { ...settings, token, realDebridUseWebLogin: false, providerDailyLimitBytes }; - case "realdebrid-web": - return { ...settings, token: "", realDebridUseWebLogin: true, providerDailyLimitBytes }; - case "bestdebrid-api": - return { ...settings, bestToken: token, bestDebridUseWebLogin: false, providerDailyLimitBytes }; - case "bestdebrid-web": - return { ...settings, bestToken: "", bestDebridUseWebLogin: true, providerDailyLimitBytes }; - case "alldebrid-api": - return { ...settings, allDebridToken: token, allDebridUseWebLogin: false, providerDailyLimitBytes }; - case "alldebrid-web": - return { ...settings, allDebridToken: "", allDebridUseWebLogin: true, providerDailyLimitBytes }; - case "ddownload-login": - return { ...settings, ddownloadLogin: login, ddownloadPassword: state.password, providerDailyLimitBytes }; - case "onefichier-api": - return { ...settings, oneFichierApiKey: token, providerDailyLimitBytes }; - case "linksnappy-login": - return { ...settings, linkSnappyLogin: login, linkSnappyPassword: state.password, providerDailyLimitBytes }; - } -} - -function applyMegaEdit(settings: AppSettings, state: AccountEditState & { target: Extract }): AppSettings { - const mode = getMegaTargetMode(state.target); - const otherMode: MegaDebridAccountMode = mode === "api" ? "web" : "api"; - const accounts = getMegaDebridAccountsForMode(settings, mode); - const index = accounts.findIndex((entry) => entry.id === state.target.accountId); - if (index < 0) { - throw new Error("Der ausgewählte Mega-Debrid-Account wurde nicht gefunden."); - } - const oldId = state.target.accountId; - const login = state.login.trim(); - const newId = getMegaDebridAccountId(login); - const nextAccounts = accounts.map((entry, accountIndex) => accountIndex === index - ? { login, password: state.password } - : { login: entry.login, password: entry.password }); - const idChanged = oldId !== newId; - const selectedCredentials = serializeMegaDebridAccounts(nextAccounts); - const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api"); - const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web"); - const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials); - const first = parseMegaDebridAccounts(mergedCredentials)[0]; - const oldIdStillUsed = idChanged && getMegaDebridAccountsForMode(settings, otherMode).some((entry) => entry.id === oldId); - const selectedDisabledIds = idChanged - ? migrateDisabledId(getMegaDebridDisabledAccountIdsForMode(settings, mode), oldId, newId) - : getMegaDebridDisabledAccountIdsForMode(settings, mode); - const otherDisabledIds = getMegaDebridDisabledAccountIdsForMode(settings, otherMode); - const apiDisabledIds = mode === "api" ? selectedDisabledIds : otherDisabledIds; - const webDisabledIds = mode === "web" ? selectedDisabledIds : otherDisabledIds; - const nextDailyLimits = updateTargetLimit(settings.megaDebridAccountDailyLimitBytes || {}, oldId, newId, state.dailyLimitGb, state.originalDailyLimitBytes); - if (oldIdStillUsed && settings.megaDebridAccountDailyLimitBytes?.[oldId]) { - nextDailyLimits[oldId] = settings.megaDebridAccountDailyLimitBytes[oldId]; + +function parseDailyLimit(value: string, originalBytes: number): number { + if (value === formatDailyLimit(originalBytes)) return originalBytes; + const normalized = value.trim().replace(",", "."); + if (!normalized) return 0; + return Math.floor(Number(normalized) * BYTES_PER_GIB); +} + +function targetAccountId(target: AccountEditTarget): string { + if (target.type === "mega") return target.accountId; + if (target.type === "debridlink") return target.keyId; + return `svc-${target.provider}`; +} + +export function createAccountEditState(target: AccountEditTarget, accounts: readonly RendererAccount[]): AccountEditState { + const account = accounts.find((entry) => entry.accountId === targetAccountId(target) && entry.kind === target.kind); + if (!account) { + throw new Error("Der ausgewählte Account wurde nicht gefunden."); } - const retainOldMetadata = (record: Record): Record => idChanged - ? oldIdStillUsed ? withoutRecordKeys(record, newId) : withoutRecordKeys(record, oldId, newId) - : { ...record }; return { - ...settings, - megaCredentials: mergedCredentials, - megaLogin: first?.login || "", - megaPassword: first?.password || "", - megaDebridApiCredentials: apiCredentials, - megaDebridWebCredentials: webCredentials, - megaDebridApiEnabled: settings.megaDebridApiEnabled && parseMegaDebridAccounts(apiCredentials).length > 0, - megaDebridWebEnabled: settings.megaDebridWebEnabled && parseMegaDebridAccounts(webCredentials).length > 0, - megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], - megaDebridApiDisabledAccountIds: apiDisabledIds, - megaDebridWebDisabledAccountIds: webDisabledIds, - megaDebridAccountDailyLimitBytes: nextDailyLimits, - megaDebridAccountDailyUsageBytes: retainOldMetadata(settings.megaDebridAccountDailyUsageBytes || {}), - megaDebridAccountTotalUsageBytes: retainOldMetadata(settings.megaDebridAccountTotalUsageBytes || {}), - debridAccountStatuses: retainOldMetadata(settings.debridAccountStatuses || {}) + target, + login: account.identity, + password: "", + token: "", + dailyLimitGb: formatDailyLimit(account.dailyLimitBytes), + originalDailyLimitBytes: account.dailyLimitBytes }; } - -function applyDebridLinkEdit(settings: AppSettings, state: AccountEditState & { target: Extract }): AppSettings { - const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); - const index = keys.findIndex((entry) => entry.id === state.target.keyId); - if (index < 0) { - throw new Error("Der ausgewählte Debrid-Link-Key wurde nicht gefunden."); - } - const oldId = state.target.keyId; - const token = state.token.trim(); - const newId = getDebridLinkApiKeyId(token); - const tokens = keys.map((entry, keyIndex) => keyIndex === index ? token : entry.token); - const idChanged = oldId !== newId; - return { - ...settings, - debridLinkApiKeys: tokens.join("\n"), - debridLinkDisabledKeyIds: idChanged - ? migrateDisabledId(settings.debridLinkDisabledKeyIds || [], oldId, newId) - : [...(settings.debridLinkDisabledKeyIds || [])], - debridLinkApiKeyDailyLimitBytes: updateTargetLimit(settings.debridLinkApiKeyDailyLimitBytes || {}, oldId, newId, state.dailyLimitGb, state.originalDailyLimitBytes), - debridLinkApiKeyDailyUsageBytes: idChanged - ? withoutRecordKeys(settings.debridLinkApiKeyDailyUsageBytes || {}, oldId, newId) - : { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }, - debridLinkApiKeyTotalUsageBytes: idChanged - ? withoutRecordKeys(settings.debridLinkApiKeyTotalUsageBytes || {}, oldId, newId) - : { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) }, - debridAccountStatuses: idChanged - ? withoutRecordKeys(settings.debridAccountStatuses || {}, oldId, newId) - : { ...(settings.debridAccountStatuses || {}) } - }; -} - -export function applyAccountEdit(settings: AppSettings, state: AccountEditState): AppSettings { - if (state.target.type === "single") { - return applySingleEdit(settings, state as AccountEditState & { target: Extract }); - } - if (state.target.type === "mega") { - return applyMegaEdit(settings, state as AccountEditState & { target: Extract }); - } - return applyDebridLinkEdit(settings, state as AccountEditState & { target: Extract }); -} - -function clearSingleAccount(settings: AppSettings, target: Extract): AppSettings { - const providerDailyLimitBytes = { ...(settings.providerDailyLimitBytes || {}) }; - const providerDailyUsageBytes = { ...(settings.providerDailyUsageBytes || {}) }; - const providerTotalUsageBytes = { ...(settings.providerTotalUsageBytes || {}) }; - delete providerDailyLimitBytes[target.provider]; - delete providerDailyUsageBytes[target.provider]; - delete providerTotalUsageBytes[target.provider]; - const base = { ...settings, providerDailyLimitBytes, providerDailyUsageBytes, providerTotalUsageBytes }; - switch (target.service) { - case "realdebrid": - return { ...base, token: "", realDebridUseWebLogin: false }; - case "bestdebrid": - return { ...base, bestToken: "", bestDebridUseWebLogin: false }; - case "alldebrid": - return { ...base, allDebridToken: "", allDebridUseWebLogin: false }; - case "ddownload": - return { ...base, ddownloadLogin: "", ddownloadPassword: "" }; - case "onefichier": - return { ...base, oneFichierApiKey: "" }; - case "linksnappy": - return { ...base, linkSnappyLogin: "", linkSnappyPassword: "" }; - default: - return base; - } -} - -export function removeAccountTarget(settings: AppSettings, target: AccountEditTarget): AppSettings { - if (target.type === "single") { - return clearSingleAccount(settings, target); + +export function validateAccountEdit(state: AccountEditState, accounts: readonly RendererAccount[]): string | null { + const normalizedLimit = state.dailyLimitGb.trim().replace(",", "."); + if (normalizedLimit && (!Number.isFinite(Number(normalizedLimit)) || Number(normalizedLimit) < 0)) { + return "Das Tageslimit muss eine positive Zahl oder 0 sein."; + } + const accountId = targetAccountId(state.target); + if (!accounts.some((entry) => entry.accountId === accountId && entry.kind === state.target.kind)) { + return "Der ausgewählte Account wurde nicht gefunden."; } - if (target.type === "mega") { - const mode = getMegaTargetMode(target); - const selectedAccounts = getMegaDebridAccountsForMode(settings, mode) - .filter((entry) => entry.id !== target.accountId) - .map((entry) => ({ login: entry.login, password: entry.password })); - const selectedCredentials = serializeMegaDebridAccounts(selectedAccounts); - const apiCredentials = mode === "api" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "api"); - const webCredentials = mode === "web" ? selectedCredentials : getMegaDebridCredentialsForMode(settings, "web"); - const mergedCredentials = mergeMegaDebridCredentialPools(apiCredentials, webCredentials); - const first = parseMegaDebridAccounts(mergedCredentials)[0]; - const accountStillUsed = parseMegaDebridAccounts(mode === "api" ? webCredentials : apiCredentials).some((entry) => entry.id === target.accountId); - const apiDisabledIds = (mode === "api" ? getMegaDebridDisabledAccountIdsForMode(settings, "api").filter((id) => id !== target.accountId) : getMegaDebridDisabledAccountIdsForMode(settings, "api")); - const webDisabledIds = (mode === "web" ? getMegaDebridDisabledAccountIdsForMode(settings, "web").filter((id) => id !== target.accountId) : getMegaDebridDisabledAccountIdsForMode(settings, "web")); - const keepOrRemoveMetadata = (record: Record): Record => accountStillUsed ? { ...record } : withoutRecordKeys(record, target.accountId); - return { - ...settings, - megaCredentials: mergedCredentials, - megaLogin: first?.login || "", - megaPassword: first?.password || "", - megaDebridApiCredentials: apiCredentials, - megaDebridWebCredentials: webCredentials, - megaDebridApiEnabled: parseMegaDebridAccounts(apiCredentials).length > 0 && settings.megaDebridApiEnabled, - megaDebridWebEnabled: parseMegaDebridAccounts(webCredentials).length > 0 && settings.megaDebridWebEnabled, - megaDebridDisabledAccountIds: [...new Set([...apiDisabledIds, ...webDisabledIds])], - megaDebridApiDisabledAccountIds: apiDisabledIds, - megaDebridWebDisabledAccountIds: webDisabledIds, - megaDebridAccountDailyLimitBytes: keepOrRemoveMetadata(settings.megaDebridAccountDailyLimitBytes || {}), - megaDebridAccountDailyUsageBytes: keepOrRemoveMetadata(settings.megaDebridAccountDailyUsageBytes || {}), - megaDebridAccountTotalUsageBytes: keepOrRemoveMetadata(settings.megaDebridAccountTotalUsageBytes || {}), - debridAccountStatuses: keepOrRemoveMetadata(settings.debridAccountStatuses || {}) - }; - } - const keys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "").filter((entry) => entry.id !== target.keyId); - return { - ...settings, - debridLinkApiKeys: keys.map((entry) => entry.token).join("\n"), - debridLinkDisabledKeyIds: (settings.debridLinkDisabledKeyIds || []).filter((id) => id !== target.keyId), - debridLinkApiKeyDailyLimitBytes: withoutRecordKeys(settings.debridLinkApiKeyDailyLimitBytes || {}, target.keyId), - debridLinkApiKeyDailyUsageBytes: withoutRecordKeys(settings.debridLinkApiKeyDailyUsageBytes || {}, target.keyId), - debridLinkApiKeyTotalUsageBytes: withoutRecordKeys(settings.debridLinkApiKeyTotalUsageBytes || {}, target.keyId), - debridAccountStatuses: withoutRecordKeys(settings.debridAccountStatuses || {}, target.keyId) - }; -} - -export function buildAccountEditCheckSettings(settings: AppSettings, state: AccountEditState): AppSettings { if (state.target.type === "mega") { const login = state.login.trim(); - const credentials = serializeMegaDebridAccounts([{ login, password: state.password }]); - const mode = getMegaTargetMode(state.target); - return { - ...settings, - megaCredentials: credentials, - megaLogin: login, - megaPassword: state.password, - megaDebridApiCredentials: mode === "api" ? credentials : "", - megaDebridWebCredentials: mode === "web" ? credentials : "", - megaDebridApiEnabled: mode === "api", - megaDebridWebEnabled: mode === "web", - megaDebridApiDisabledAccountIds: [], - megaDebridWebDisabledAccountIds: [], - debridLinkApiKeys: "" - }; - } - if (state.target.type === "debridlink") { - return { - ...settings, - megaCredentials: "", - megaLogin: "", - megaPassword: "", - megaDebridApiCredentials: "", - megaDebridWebCredentials: "", - debridLinkApiKeys: state.token.trim() - }; - } - return { - ...settings, - megaCredentials: "", - megaLogin: "", - megaPassword: "", - megaDebridApiCredentials: "", - megaDebridWebCredentials: "", - debridLinkApiKeys: "" - }; -} - -export function validateAccountEditStatuses(state: AccountEditState, statuses: readonly DebridAccountStatus[]): string | null { - if (state.target.type === "single") { - return null; - } - if (statuses.length === 0) { - return "Die Prüfung hat keinen Account zurückgegeben."; - } - if (statuses.length !== 1) { - return "Die Prüfung hat mehr als den ausgewählten Account zurückgegeben."; - } - const expectedId = getAccountEditExpectedStatusId(state); - const status = statuses[0]; - if (status.accountId !== expectedId) { - return "Die Prüfung hat den falschen Account zurückgegeben."; - } - return status.valid ? null : status.message || "Zugangsdaten ungültig"; -} - -export function getAccountEditExpectedStatusId(state: AccountEditState): string | null { - if (state.target.type === "mega") { - return getMegaDebridAccountId(state.login); - } - if (state.target.type === "debridlink") { - return getDebridLinkApiKeyId(state.token); - } - return null; -} + if (!login) return "Der Login wird benötigt."; + if (/[:\r\n]/.test(login)) return "Der Login darf keinen Doppelpunkt oder Zeilenumbruch enthalten."; + if (/[\r\n]/.test(state.password)) return "Das Passwort darf keinen Zeilenumbruch enthalten."; + if (accounts.some((entry) => entry.kind === state.target.kind && entry.accountId !== accountId && entry.identity.toLowerCase() === login.toLowerCase())) { + return "Dieser Mega-Debrid-Login ist bereits vorhanden."; + } + } + if (state.target.type === "debridlink" && /[,\r\n]/.test(state.token)) { + return "Beim Bearbeiten ist genau ein API-Key erlaubt."; + } + if (state.target.type === "single" && (state.target.kind === "ddownload-login" || state.target.kind === "linksnappy-login")) { + if (!state.login.trim()) return "Der Login wird benötigt."; + if (/[\r\n]/.test(state.password)) return "Das Passwort darf keinen Zeilenumbruch enthalten."; + } + return null; +} + +export function buildAccountReplaceCommand(state: AccountEditState): AccountReplaceCommand { + return { + action: "replace", + kind: state.target.kind, + accountId: targetAccountId(state.target), + identity: state.target.type === "debridlink" ? undefined : state.login.trim(), + secret: state.target.type === "debridlink" ? state.token : state.target.type === "single" && !["ddownload-login", "linksnappy-login"].includes(state.target.kind) ? state.token : state.password, + dailyLimitBytes: parseDailyLimit(state.dailyLimitGb, state.originalDailyLimitBytes) + }; +} + +export function buildAccountDeleteCommand(target: AccountEditTarget): AccountDeleteCommand { + return { action: "delete", kind: target.kind, accountId: targetAccountId(target) }; +} diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index 1d099e8..0042936 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -1,4 +1,4 @@ -import type { AppSettings } from "../../../shared/types"; +import type { RendererSettings } from "../../../shared/types"; import type { AccountService } from "../../account-edit"; import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons"; @@ -49,10 +49,10 @@ export function getSettingsSelectNavigationIndex(currentIndex: number, optionCou } export function resolveHistoryRetentionSelection( - currentMode: AppSettings["historyRetentionMode"], + currentMode: RendererSettings["historyRetentionMode"], currentMaxEntries: number, value: string -): Pick { +): Pick { const preset = /^permanent-(100|250)$/.exec(value); if (preset) { return { @@ -69,7 +69,7 @@ export function resolveHistoryRetentionSelection( }; } return { - historyRetentionMode: value as AppSettings["historyRetentionMode"], + historyRetentionMode: value as RendererSettings["historyRetentionMode"], historyMaxEntries: currentMaxEntries }; } @@ -208,7 +208,7 @@ export interface SettingsFormViewModel { } export interface SettingsFormProjectionInput { - settings: AppSettings; + settings: RendererSettings & { archivePasswordList: string; notifyUrl: string }; section: SettingsSection; speedLimitInput: string; scheduleSpeedInputs: Readonly>; @@ -289,7 +289,7 @@ export function buildSettingsFormViewModel({ id: "extract-passwords", title: "Passwörter", fields: [ - { id: "archivePasswordList", kind: "textarea", label: "Passwortliste für Archive", value: settings.archivePasswordList, placeholder: "Ein Passwort pro Zeile" } + { id: "archivePasswordList", kind: "textarea", label: "Passwortliste für Archive", value: settings.archivePasswordList, placeholder: settings.archivePasswordListConfigured ? "Gespeichert; leer lassen zum Beibehalten" : "Ein Passwort pro Zeile" } ] } ] @@ -528,7 +528,7 @@ export function buildSettingsFormViewModel({ id: "general-notifications", title: "Discord-Benachrichtigungen", fields: [ - { id: "notifyUrl", kind: "text", label: "Webhook-Adresse", value: settings.notifyUrl, placeholder: "https://discord.com/api/webhooks/…", actionLabel: "Testen" }, + { id: "notifyUrl", kind: "text", label: "Webhook-Adresse", value: settings.notifyUrl, placeholder: settings.notifyUrlConfigured ? "Gespeichert; leer lassen zum Beibehalten" : "https://discord.com/api/webhooks/…", actionLabel: "Testen" }, { id: "notifyMention", kind: "text", label: "Discord-Erwähnung (optional)", value: settings.notifyMention }, { id: "notifyOnPackageCompleted", kind: "switch", label: "Melden, wenn ein Paket fertig ist", value: settings.notifyOnPackageCompleted }, { id: "notifyOnPackageFailed", kind: "switch", label: "Melden, wenn ein Paket fehlschlägt", value: settings.notifyOnPackageFailed }, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index fc503ff..2457feb 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -5,7 +5,11 @@ export const IPC_CHANNELS = { INSTALL_UPDATE: "app:install-update", UPDATE_INSTALL_PROGRESS: "app:update-install-progress", OPEN_EXTERNAL: "app:open-external", - UPDATE_SETTINGS: "app:update-settings", + UPDATE_SETTINGS: "app:update-settings", + CREATE_ACCOUNT: "app:create-account", + REPLACE_ACCOUNT: "app:replace-account", + UPDATE_ACCOUNT_SECRET: "app:update-account-secret", + DELETE_ACCOUNT: "app:delete-account", RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage", RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage", ADD_LINKS: "queue:add-links", @@ -64,10 +68,10 @@ export const IPC_CHANNELS = { OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info", - GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits", - CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts", - CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account", - RETRY_EXTRACTION: "queue:retry-extraction", + GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits", + CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts", + CHECK_ACCOUNT_CREDENTIALS: "app:check-account-credentials", + RETRY_EXTRACTION: "queue:retry-extraction", EXTRACT_NOW: "queue:extract-now", RESET_PACKAGE: "queue:reset-package", GET_HISTORY: "history:get", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index d8f2748..5093a22 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -1,7 +1,12 @@ import type { - AddLinksPayload, + AddLinksPayload, + AccountCommandResult, + AccountCredentialCheckInput, + AccountCreateCommand, + AccountDeleteCommand, + AccountReplaceCommand, + AccountUpdateSecretCommand, AllDebridHostInfo, - AppSettings, DebridAccountStatus, DebugSetupCheckResult, DebridLinkHostLimitInfo, @@ -11,7 +16,9 @@ import type { HistoryEntry, HistoryRevealResult, PackagePriority, - RemoteDiagnosticsInfo, + RemoteDiagnosticsInfo, + RendererSettings, + RendererSettingsUpdate, RendererErrorReport, SessionStats, StartConflictEntry, @@ -29,9 +36,13 @@ export interface ElectronApi { checkUpdates: () => Promise; installUpdate: () => Promise; openExternal: (url: string) => Promise; - updateSettings: (settings: Partial) => Promise; - resetProviderDailyUsage: (provider: DebridProvider) => Promise; - resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise; + updateSettings: (settings: RendererSettingsUpdate) => Promise; + resetProviderDailyUsage: (provider: DebridProvider) => Promise; + resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise; + createAccount: (command: AccountCreateCommand) => Promise; + replaceAccount: (command: AccountReplaceCommand) => Promise; + updateAccountSecret: (command: AccountUpdateSecretCommand) => Promise; + deleteAccount: (command: AccountDeleteCommand) => Promise; addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; getStartConflicts: () => Promise; @@ -86,10 +97,10 @@ export interface ElectronApi { openAllDebridLogin: () => Promise; importBestDebridCookies: () => Promise; getAllDebridHostInfo: () => Promise; - getDebridLinkHostLimits: () => Promise; - checkDebridAccounts: (settings?: AppSettings, persistValidOverride?: boolean, expectedAccountId?: string) => Promise; - checkMegaDebridAccount: (login: string, password: string) => Promise; - retryExtraction: (packageId: string) => Promise; + getDebridLinkHostLimits: () => Promise; + checkDebridAccounts: () => Promise; + checkAccountCredentials: (input: AccountCredentialCheckInput) => Promise; + retryExtraction: (packageId: string) => Promise; extractNow: (packageId: string) => Promise; resetPackage: (packageId: string) => Promise; getHistory: () => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index 2b1c355..aba5ea9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -168,8 +168,177 @@ export interface AppSettings { megaDebridAccountTotalUsageBytes: Record; debridAccountStatuses: Record; providerDailyUsageDay: string; - scheduledStartEpochMs: number; -} + scheduledStartEpochMs: number; +} + +export type RendererAccountKind = + | "realdebrid-api" + | "realdebrid-web" + | "megadebrid-api" + | "megadebrid-web" + | "bestdebrid-api" + | "bestdebrid-web" + | "alldebrid-api" + | "alldebrid-web" + | "ddownload-login" + | "onefichier-api" + | "debridlink-api" + | "linksnappy-login"; + +export interface RendererAccount { + accountId: string; + kind: RendererAccountKind; + provider: DebridProvider; + identity: string; + maskedIdentity: string; + hasSecret: boolean; + enabled: boolean; + dailyLimitBytes: number; + dailyUsageBytes: number; + totalUsageBytes: number; + status: DebridAccountStatus | null; +} + +export interface RendererSettings { + language: AppLanguage; + realDebridUseWebLogin: boolean; + megaDebridApiEnabled: boolean; + megaDebridWebEnabled: boolean; + megaDebridPreferApi: boolean; + bestDebridUseWebLogin: boolean; + allDebridUseWebLogin: boolean; + debridLinkDisabledKeyIds: string[]; + rememberToken: boolean; + configuredProviders: DebridProvider[]; + providerOrder: readonly DebridProvider[]; + providerPrimary: DebridProvider; + providerSecondary: DebridFallbackProvider; + providerTertiary: DebridFallbackProvider; + autoProviderFallback: boolean; + outputDir: string; + packageName: string; + autoExtract: boolean; + autoRename4sf4sj: boolean; + keepGermanAudioOnly: boolean; + germanAudioMode: "tag" | "first"; + extractDir: string; + collectMkvToLibrary: boolean; + mkvLibraryDir: string; + createExtractSubfolder: boolean; + hybridExtract: boolean; + cleanupMode: CleanupMode; + extractConflictMode: ConflictMode; + removeLinkFilesAfterExtract: boolean; + removeSamplesAfterExtract: boolean; + enableIntegrityCheck: boolean; + autoResumeOnStart: boolean; + autoReconnect: boolean; + reconnectWaitSeconds: number; + completedCleanupPolicy: FinishedCleanupPolicy; + maxParallel: number; + maxParallelExtract: number; + retryLimit: number; + speedLimitEnabled: boolean; + speedLimitKbps: number; + speedLimitMode: SpeedMode; + updateRepo: string; + autoUpdateCheck: boolean; + clipboardWatch: boolean; + minimizeToTray: boolean; + theme: AppTheme; + collapseNewPackages: boolean; + historyRetentionMode: HistoryRetentionMode; + historyMaxEntries: number; + historyMaxAgeDays: number; + accountListShowDetailedDebridLinkKeys: boolean; + autoSortPackagesByProgress: boolean; + autoSkipExtracted: boolean; + hideExtractedItems: boolean; + confirmDeleteSelection: boolean; + backupIncludeDownloads: boolean; + backupIncludeRemoteDiagnostics: boolean; + archivePasswordListConfigured: boolean; + notifyUrlConfigured: boolean; + notifyMention: string; + notifyOnPackageCompleted: boolean; + notifyOnPackageFailed: boolean; + notifyOnRunFinished: boolean; + totalDownloadedAllTime: number; + totalCompletedFilesAllTime: number; + totalRuntimeAllTimeMs: number; + bandwidthSchedules: BandwidthScheduleEntry[]; + columnOrder: string[]; + columnOrderVersion?: number; + extractCpuPriority: ExtractCpuPriority; + autoExtractWhenStopped: boolean; + disabledProviders: DebridProvider[]; + hosterRouting: Record; + providerDailyLimitBytes: Partial>; + providerDailyUsageBytes: Partial>; + providerTotalUsageBytes: Partial>; + debridLinkApiKeyDailyLimitBytes: Record; + debridLinkApiKeyDailyUsageBytes: Record; + debridLinkApiKeyTotalUsageBytes: Record; + megaDebridDisabledAccountIds: string[]; + megaDebridApiDisabledAccountIds: string[]; + megaDebridWebDisabledAccountIds: string[]; + megaDebridAccountDailyLimitBytes: Record; + megaDebridAccountDailyUsageBytes: Record; + megaDebridAccountTotalUsageBytes: Record; + debridAccountStatuses: Record; + providerDailyUsageDay: string; + scheduledStartEpochMs: number; +} + +export type RendererSettingsUpdate = Partial & { + archivePasswordList?: string; + notifyUrl?: string; +}; + +export interface AccountCreateCommand { + action: "create"; + kind: RendererAccountKind; + identity?: string; + secret?: string; + dailyLimitBytes?: number; +} + +export interface AccountReplaceCommand { + action: "replace"; + kind: RendererAccountKind; + accountId: string; + identity?: string; + secret?: string; + dailyLimitBytes?: number; +} + +export interface AccountUpdateSecretCommand { + action: "update-secret"; + kind: RendererAccountKind; + accountId: string; + secret: string; +} + +export interface AccountDeleteCommand { + action: "delete"; + kind: RendererAccountKind; + accountId: string; +} + +export type AccountCommand = AccountCreateCommand | AccountReplaceCommand | AccountUpdateSecretCommand | AccountDeleteCommand; + +export interface AccountCommandResult { + accountId: string | null; + settings: RendererSettings; + accounts: RendererAccount[]; +} + +export interface AccountCredentialCheckInput { + kind: "megadebrid-api" | "megadebrid-web" | "debridlink-api"; + accountId?: string; + identity?: string; + secret?: string; +} export interface DownloadItem { id: string; @@ -287,8 +456,9 @@ export interface RotationEvent { next?: string; } -export interface UiSnapshot { - settings: AppSettings; +export interface UiSnapshot { + settings: RendererSettings; + accounts: RendererAccount[]; session: SessionState; summary: DownloadSummary | null; stats: DownloadStats; diff --git a/tests/account-commands.test.ts b/tests/account-commands.test.ts new file mode 100644 index 0000000..c68102c --- /dev/null +++ b/tests/account-commands.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from "vitest"; +import { applyAccountCommand, validateAccountCommand } from "../src/main/account-commands"; +import { defaultSettings } from "../src/main/constants"; +import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import type { AppSettings, RendererAccountKind } from "../src/shared/types"; + +const ORIGINAL_SECRET = "fixture-original-secret-4qV8"; +const REPLACEMENT_SECRET = "fixture-replacement-secret-6nC2"; +const GIB = 1024 * 1024 * 1024; + +const SECRET_RETAIN_CASES: Array<{ + kind: RendererAccountKind; + identity: string; + secret: string; + retained: (settings: AppSettings) => boolean; +}> = [ + { kind: "realdebrid-api", identity: "", secret: "fixture-retain-rd-1aC3", retained: (settings) => settings.token === "fixture-retain-rd-1aC3" }, + { kind: "megadebrid-api", identity: "retain-mega-api@example.test", secret: "fixture-retain-mega-api-2bD4", retained: (settings) => settings.megaDebridApiCredentials === "retain-mega-api@example.test:fixture-retain-mega-api-2bD4" }, + { kind: "megadebrid-web", identity: "retain-mega-web@example.test", secret: "fixture-retain-mega-web-3cE5", retained: (settings) => settings.megaDebridWebCredentials === "retain-mega-web@example.test:fixture-retain-mega-web-3cE5" }, + { kind: "bestdebrid-api", identity: "", secret: "fixture-retain-best-4dF6", retained: (settings) => settings.bestToken === "fixture-retain-best-4dF6" }, + { kind: "alldebrid-api", identity: "", secret: "fixture-retain-all-5eG7", retained: (settings) => settings.allDebridToken === "fixture-retain-all-5eG7" }, + { kind: "ddownload-login", identity: "retain-dd@example.test", secret: "fixture-retain-dd-6fH8", retained: (settings) => settings.ddownloadPassword === "fixture-retain-dd-6fH8" }, + { kind: "onefichier-api", identity: "", secret: "fixture-retain-one-7gJ9", retained: (settings) => settings.oneFichierApiKey === "fixture-retain-one-7gJ9" }, + { kind: "debridlink-api", identity: "", secret: "fixture-retain-dl-8hK1", retained: (settings) => settings.debridLinkApiKeys === "fixture-retain-dl-8hK1" }, + { kind: "linksnappy-login", identity: "retain-ls@example.test", secret: "fixture-retain-ls-9jL2", retained: (settings) => settings.linkSnappyPassword === "fixture-retain-ls-9jL2" } +]; + +const ACCOUNT_KINDS: RendererAccountKind[] = [ + "realdebrid-api", + "realdebrid-web", + "megadebrid-api", + "megadebrid-web", + "bestdebrid-api", + "bestdebrid-web", + "alldebrid-api", + "alldebrid-web", + "ddownload-login", + "onefichier-api", + "debridlink-api", + "linksnappy-login" +]; + +describe("write-only account commands", () => { + it.each([ + ["realdebrid-api", "", "fixture-rd-provider-secret-1fA4", "token"], + ["realdebrid-web", "", "", "realDebridUseWebLogin"], + ["bestdebrid-api", "", "fixture-best-provider-secret-2gB5", "bestToken"], + ["bestdebrid-web", "", "", "bestDebridUseWebLogin"], + ["alldebrid-api", "", "fixture-ad-provider-secret-3hC6", "allDebridToken"], + ["alldebrid-web", "", "", "allDebridUseWebLogin"], + ["ddownload-login", "dd-safe@example.test", "fixture-dd-provider-secret-4jD7", "ddownloadPassword"], + ["onefichier-api", "", "fixture-one-provider-secret-5kE8", "oneFichierApiKey"], + ["linksnappy-login", "ls-safe@example.test", "fixture-ls-provider-secret-6mF9", "linkSnappyPassword"] + ] as const)("preserves create and delete behavior for %s", (kind, identity, secret, configuredKey) => { + const created = applyAccountCommand(defaultSettings(), validateAccountCommand({ + action: "create", + kind, + identity, + secret, + dailyLimitBytes: 4_294_967_296 + })); + + expect(created.settings[configuredKey]).toBe(secret || true); + expect(JSON.stringify(created.response)).not.toContain(secret || "fixture-never-present"); + + const deleted = applyAccountCommand(created.settings, validateAccountCommand({ + action: "delete", + kind, + accountId: created.response.accountId + })); + + expect(deleted.settings[configuredKey]).toBe(secret ? "" : false); + }); + + it("creates an account without returning submitted secrets", () => { + const command = validateAccountCommand({ + action: "create", + kind: "megadebrid-api", + identity: "new-account@example.test", + secret: ORIGINAL_SECRET, + dailyLimitBytes: 12_884_901_888 + }); + const result = applyAccountCommand(defaultSettings(), command); + + expect(result.settings.megaDebridApiCredentials).toContain(ORIGINAL_SECRET); + expect(JSON.stringify(result.response)).not.toContain(ORIGINAL_SECRET); + expect(result.response.accountId).toBe(getMegaDebridAccountId("new-account@example.test")); + }); + + it("adds a Web Mega-Debrid account without copying it into the API pool or changing preferApi", () => { + const result = applyAccountCommand({ + ...defaultSettings(), + megaCredentials: "api@example.test:fixture-api-secret-1aM2", + megaDebridApiCredentials: "api@example.test:fixture-api-secret-1aM2", + megaDebridWebCredentials: "", + megaDebridApiEnabled: true, + megaDebridWebEnabled: false, + megaDebridPreferApi: false + }, validateAccountCommand({ + action: "create", + kind: "megadebrid-web", + identity: "web@example.test", + secret: "fixture-web-secret-3bN4", + dailyLimitBytes: 0 + })); + + expect(result.settings.megaDebridApiCredentials).toBe("api@example.test:fixture-api-secret-1aM2"); + expect(result.settings.megaDebridWebCredentials).toBe("web@example.test:fixture-web-secret-3bN4"); + expect(result.settings.megaDebridApiEnabled).toBe(true); + expect(result.settings.megaDebridWebEnabled).toBe(true); + expect(result.settings.megaDebridPreferApi).toBe(false); + expect(JSON.stringify(result.response)).not.toContain("fixture-web-secret-3bN4"); + }); + + it("retains a stored secret when replace receives a blank secret", () => { + const identity = "existing-account@example.test"; + const accountId = getMegaDebridAccountId(identity); + const settings = { + ...defaultSettings(), + megaCredentials: `${identity}:${ORIGINAL_SECRET}`, + megaDebridApiCredentials: `${identity}:${ORIGINAL_SECRET}`, + megaDebridApiEnabled: true + }; + const command = validateAccountCommand({ + action: "replace", + kind: "megadebrid-api", + accountId, + identity: "renamed-account@example.test", + secret: "", + dailyLimitBytes: 8_589_934_592 + }); + const result = applyAccountCommand(settings, command); + + expect(result.settings.megaDebridApiCredentials).toBe(`renamed-account@example.test:${ORIGINAL_SECRET}`); + expect(JSON.stringify(result.response)).not.toContain(ORIGINAL_SECRET); + }); + + it.each(SECRET_RETAIN_CASES)("retains the stored $kind secret when replace receives a blank secret", ({ kind, identity, secret, retained }) => { + const created = applyAccountCommand(defaultSettings(), validateAccountCommand({ + action: "create", + kind, + identity, + secret, + dailyLimitBytes: 1 + })); + const replaced = applyAccountCommand(created.settings, validateAccountCommand({ + action: "replace", + kind, + accountId: created.response.accountId, + identity, + secret: "", + dailyLimitBytes: 1 + })); + + expect(retained(replaced.settings)).toBe(true); + expect(JSON.stringify(replaced.response)).not.toContain(secret); + }); + + it("replaces a Mega-Debrid account while preserving sibling accounts and mode-specific state", () => { + const firstId = getMegaDebridAccountId("first@example.test"); + const oldId = getMegaDebridAccountId("second@example.test"); + const newId = getMegaDebridAccountId("renamed@example.test"); + const webId = getMegaDebridAccountId("web@example.test"); + const settings = { + ...defaultSettings(), + megaCredentials: "first@example.test:first-secret\nsecond@example.test:second-secret\nweb@example.test:web-secret", + megaDebridApiCredentials: "first@example.test:first-secret\nsecond@example.test:second-secret", + megaDebridWebCredentials: "web@example.test:web-secret", + megaDebridApiEnabled: true, + megaDebridWebEnabled: true, + megaDebridPreferApi: false, + megaDebridDisabledAccountIds: [oldId, firstId, webId], + megaDebridApiDisabledAccountIds: [oldId, firstId], + megaDebridWebDisabledAccountIds: [webId], + megaDebridAccountDailyLimitBytes: { [oldId]: 15 * GIB, [firstId]: 9 * GIB }, + megaDebridAccountDailyUsageBytes: { [oldId]: 4 * GIB, [firstId]: 2 * GIB }, + megaDebridAccountTotalUsageBytes: { [oldId]: 40 * GIB, [firstId]: 20 * GIB }, + debridAccountStatuses: { + [oldId]: { accountId: oldId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***st", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }, + [firstId]: { accountId: firstId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***st", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } + } + }; + const result = applyAccountCommand(settings, validateAccountCommand({ + action: "replace", + kind: "megadebrid-api", + accountId: oldId, + identity: "renamed@example.test", + secret: "renamed-secret", + dailyLimitBytes: Math.floor(25.5 * GIB) + })); + + expect(result.settings.megaDebridApiCredentials).toBe("first@example.test:first-secret\nrenamed@example.test:renamed-secret"); + expect(result.settings.megaDebridWebCredentials).toBe("web@example.test:web-secret"); + expect(result.settings.megaDebridPreferApi).toBe(false); + expect(result.settings.megaDebridDisabledAccountIds).toEqual([firstId, newId, webId]); + expect(result.settings.megaDebridApiDisabledAccountIds).toEqual([firstId, newId]); + expect(result.settings.megaDebridWebDisabledAccountIds).toEqual([webId]); + expect(result.settings.megaDebridAccountDailyLimitBytes).toEqual({ [firstId]: 9 * GIB, [newId]: Math.floor(25.5 * GIB) }); + expect(result.settings.megaDebridAccountDailyUsageBytes).toEqual({ [firstId]: 2 * GIB }); + expect(result.settings.megaDebridAccountTotalUsageBytes).toEqual({ [firstId]: 20 * GIB }); + expect(result.settings.debridAccountStatuses).toEqual({ + [firstId]: { accountId: firstId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***st", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } + }); + expect(JSON.stringify(result.response)).not.toContain("renamed-secret"); + }); + + it("replaces only the selected Debrid-Link key and migrates its own metadata", () => { + const keyA = "fixture-dl-key-a-1aB2"; + const keyB = "fixture-dl-key-b-3cD4"; + const keyC = "fixture-dl-key-c-5eF6"; + const newKey = "fixture-dl-key-b-new-7gH8"; + const idA = getDebridLinkApiKeyId(keyA); + const idB = getDebridLinkApiKeyId(keyB); + const idNew = getDebridLinkApiKeyId(newKey); + const result = applyAccountCommand({ + ...defaultSettings(), + debridLinkApiKeys: `${keyA}\n${keyB}\n${keyC}`, + debridLinkDisabledKeyIds: [idB, idA], + debridLinkApiKeyDailyLimitBytes: { [idA]: 5 * GIB, [idB]: 10 * GIB }, + debridLinkApiKeyDailyUsageBytes: { [idA]: 2 * GIB, [idB]: 4 * GIB }, + debridLinkApiKeyTotalUsageBytes: { [idA]: 12 * GIB, [idB]: 24 * GIB } + }, validateAccountCommand({ + action: "replace", + kind: "debridlink-api", + accountId: idB, + secret: newKey, + dailyLimitBytes: 12 * GIB + })); + + expect(result.settings.debridLinkApiKeys).toBe(`${keyA}\n${newKey}\n${keyC}`); + expect(result.settings.debridLinkDisabledKeyIds).toEqual([idA, idNew]); + expect(result.settings.debridLinkApiKeyDailyLimitBytes).toEqual({ [idA]: 5 * GIB, [idNew]: 12 * GIB }); + expect(result.settings.debridLinkApiKeyDailyUsageBytes).toEqual({ [idA]: 2 * GIB }); + expect(result.settings.debridLinkApiKeyTotalUsageBytes).toEqual({ [idA]: 12 * GIB }); + expect(JSON.stringify(result.response)).not.toContain(newKey); + }); + + it("keeps a matching Web identity disabled when its API identity is deleted", () => { + const identity = "shared-mode@example.test"; + const accountId = getMegaDebridAccountId(identity); + const settings = { + ...defaultSettings(), + megaCredentials: `${identity}:fixture-api-mode-secret-1mN3`, + megaDebridApiCredentials: `${identity}:fixture-api-mode-secret-1mN3`, + megaDebridWebCredentials: `${identity}:fixture-web-mode-secret-2nP4`, + megaDebridApiEnabled: true, + megaDebridWebEnabled: true, + megaDebridDisabledAccountIds: [accountId], + megaDebridApiDisabledAccountIds: [accountId], + megaDebridWebDisabledAccountIds: [accountId] + }; + + const deleted = applyAccountCommand(settings, validateAccountCommand({ + action: "delete", + kind: "megadebrid-api", + accountId + })); + + expect(deleted.settings.megaDebridApiCredentials).toBe(""); + expect(deleted.settings.megaDebridWebCredentials).toBe(`${identity}:fixture-web-mode-secret-2nP4`); + expect(deleted.settings.megaDebridApiDisabledAccountIds).toEqual([]); + expect(deleted.settings.megaDebridWebDisabledAccountIds).toEqual([accountId]); + expect(deleted.settings.megaDebridDisabledAccountIds).toEqual([accountId]); + }); + + it("updates only the selected secret and deletes only the selected account", () => { + const firstIdentity = "first-account@example.test"; + const secondIdentity = "second-account@example.test"; + const firstId = getMegaDebridAccountId(firstIdentity); + const secondId = getMegaDebridAccountId(secondIdentity); + const settings = { + ...defaultSettings(), + megaCredentials: `${firstIdentity}:${ORIGINAL_SECRET}\n${secondIdentity}:fixture-sibling-secret-3wH7`, + megaDebridApiCredentials: `${firstIdentity}:${ORIGINAL_SECRET}\n${secondIdentity}:fixture-sibling-secret-3wH7`, + megaDebridApiEnabled: true + }; + const updated = applyAccountCommand(settings, validateAccountCommand({ + action: "update-secret", + kind: "megadebrid-api", + accountId: firstId, + secret: REPLACEMENT_SECRET + })); + const deleted = applyAccountCommand(updated.settings, validateAccountCommand({ + action: "delete", + kind: "megadebrid-api", + accountId: firstId + })); + + expect(updated.settings.megaDebridApiCredentials).toContain(`${firstIdentity}:${REPLACEMENT_SECRET}`); + expect(deleted.settings.megaDebridApiCredentials).toBe(`${secondIdentity}:fixture-sibling-secret-3wH7`); + expect(deleted.response.accountId).toBe(secondId); + expect(JSON.stringify([updated.response, deleted.response])).not.toContain(REPLACEMENT_SECRET); + }); + + it("creates and deletes a Debrid-Link API key by stable key identity", () => { + const secret = "fixture-dl-create-delete-1pQ4"; + const created = applyAccountCommand(defaultSettings(), validateAccountCommand({ + action: "create", + kind: "debridlink-api", + secret, + dailyLimitBytes: 3 * GIB + })); + + expect(created.response.accountId).toBe(getDebridLinkApiKeyId(secret)); + expect(created.settings.debridLinkApiKeys).toBe(secret); + expect(created.settings.debridLinkApiKeyDailyLimitBytes).toEqual({ [getDebridLinkApiKeyId(secret)]: 3 * GIB }); + expect(JSON.stringify(created.response)).not.toContain(secret); + + const deleted = applyAccountCommand(created.settings, validateAccountCommand({ + action: "delete", + kind: "debridlink-api", + accountId: created.response.accountId + })); + + expect(deleted.settings.debridLinkApiKeys).toBe(""); + expect(deleted.settings.debridLinkApiKeyDailyLimitBytes).toEqual({}); + expect(deleted.response.accountId).toBeNull(); + }); + + it("rejects malformed payloads without echoing submitted secrets", () => { + let errorText = ""; + try { + validateAccountCommand({ + action: "replace", + kind: "megadebrid-api", + accountId: 42, + secret: REPLACEMENT_SECRET + }); + } catch (error) { + errorText = String(error); + } + + expect(errorText).toMatch(/ungültig/i); + expect(errorText).not.toContain(REPLACEMENT_SECRET); + }); + + it.each(ACCOUNT_KINDS)("rejects malformed %s payloads without echoing their submitted secret", (kind) => { + const secret = `fixture-malformed-${kind}-3qR5`; + let errorText = ""; + try { + validateAccountCommand({ action: "replace", kind, accountId: 42, secret }); + } catch (error) { + errorText = String(error); + } + + expect(errorText).toMatch(/ungültig/i); + expect(errorText).not.toContain(secret); + }); +}); diff --git a/tests/account-dialog-prefer-api.test.ts b/tests/account-dialog-prefer-api.test.ts index b00b946..520b5bb 100644 --- a/tests/account-dialog-prefer-api.test.ts +++ b/tests/account-dialog-prefer-api.test.ts @@ -1,81 +1,30 @@ -import { describe, expect, it } from "vitest"; -import { applyAccountDialogToSettings, createAccountDialogState, AccountDialogState } from "../src/renderer/App"; +import { describe, expect, it } from "vitest"; +import { buildAccountCreateCommand, createAccountDialogState } from "../src/renderer/App"; +import { createRendererSettings } from "../src/main/renderer-state"; import { defaultSettings } from "../src/main/constants"; -import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; -import { isMegaDebridAccountDisabled } from "../src/shared/provider-daily-limits"; - -function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogState { - return { - mode: "edit", - kind, - service: kind, - token: "", - login: "", - password: "", - dailyLimitGb: "", - keyDailyLimitGbById: {}, - megaAccounts: [{ login: "user@x", password: "pw" }], - megaNewLogin: "", - megaNewPassword: "", - megaDisabledIds: [] - }; -} - -describe("applyAccountDialogToSettings — keeps the user's Mega preferApi choice", () => { - it("does not flip megaDebridPreferApi to true when editing the API account", () => { - const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: false }; - const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-api")); - expect(next.megaDebridApiEnabled).toBe(true); - expect(next.megaDebridPreferApi).toBe(false); - }); - - it("does not flip megaDebridPreferApi to false when editing the Web account", () => { - const settings = { ...defaultSettings(), megaDebridApiEnabled: true, megaDebridWebEnabled: true, megaDebridPreferApi: true }; - const next = applyAccountDialogToSettings(settings, megaDialog("megadebrid-web")); - expect(next.megaDebridWebEnabled).toBe(true); - expect(next.megaDebridPreferApi).toBe(true); + +describe("account creation dialog", () => { + it("creates a mode-specific Mega-Debrid command without existing credentials", () => { + const settings = createRendererSettings(defaultSettings()); + const dialog = { + ...createAccountDialogState("create", "megadebrid-web", settings), + megaNewLogin: "web-safe@example.test", + megaNewPassword: "fixture-dialog-secret-7pL2" + }; + + expect(buildAccountCreateCommand(dialog)).toEqual({ + action: "create", + kind: "megadebrid-web", + identity: "web-safe@example.test", + secret: "fixture-dialog-secret-7pL2", + dailyLimitBytes: 0 + }); }); - it("adds a Web account without copying it into the API account pool", () => { - const settings = { - ...defaultSettings(), - megaCredentials: "api@example.test:api-pass", - megaDebridApiCredentials: "api@example.test:api-pass", - megaDebridWebCredentials: "", - megaDebridApiEnabled: true, - megaDebridWebEnabled: false - }; - const dialog = createAccountDialogState("create", "megadebrid-web", settings); - + it("keeps all account fields blank before user input", () => { + const dialog = createAccountDialogState("create", "debridlink-api", createRendererSettings(defaultSettings())); + expect(dialog.token).toBe(""); + expect(dialog.password).toBe(""); expect(dialog.megaAccounts).toEqual([]); - - const next = applyAccountDialogToSettings(settings, { - ...dialog, - megaAccounts: [{ login: "web@example.test", password: "web-pass" }] - }); - - expect(next.megaDebridApiCredentials).toBe("api@example.test:api-pass"); - expect(next.megaDebridWebCredentials).toBe("web@example.test:web-pass"); - expect(next.megaDebridApiEnabled).toBe(true); - expect(next.megaDebridWebEnabled).toBe(true); - }); - - it("disables an API account without disabling the matching Web account", () => { - const accountId = getMegaDebridAccountId("shared@example.test"); - const settings = { - ...defaultSettings(), - megaCredentials: "shared@example.test:api-pass", - megaDebridApiCredentials: "shared@example.test:api-pass", - megaDebridWebCredentials: "shared@example.test:web-pass", - megaDebridApiEnabled: true, - megaDebridWebEnabled: true - }; - const next = applyAccountDialogToSettings(settings, { - ...createAccountDialogState("edit", "megadebrid-api", settings), - megaDisabledIds: [accountId] - }); - - expect(isMegaDebridAccountDisabled(next, accountId, "api")).toBe(true); - expect(isMegaDebridAccountDisabled(next, accountId, "web")).toBe(false); }); }); diff --git a/tests/account-edit.test.ts b/tests/account-edit.test.ts index c5bb1d1..d61ff1d 100644 --- a/tests/account-edit.test.ts +++ b/tests/account-edit.test.ts @@ -1,305 +1,82 @@ -import { describe, expect, it } from "vitest"; -import { defaultSettings } from "../src/main/constants"; -import { - applyAccountEdit, - buildAccountEditCheckSettings, - createAccountEditState, - removeAccountTarget, - validateAccountEdit, - validateAccountEditStatuses, - type AccountEditTarget -} from "../src/renderer/account-edit"; -import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys"; -import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; - -const GIB = 1024 * 1024 * 1024; - -function megaTarget(login: string): AccountEditTarget { - return { - type: "mega", - rowKey: `mega-${getMegaDebridAccountId(login)}`, - kind: "megadebrid-api", - service: "megadebrid-api", - accountId: getMegaDebridAccountId(login) - }; -} - -function debridLinkTarget(token: string): AccountEditTarget { - return { - type: "debridlink", - rowKey: `dl-${getDebridLinkApiKeyId(token)}`, - kind: "debridlink-api", - service: "debridlink", - keyId: getDebridLinkApiKeyId(token) - }; -} - -describe("account-specific editing", () => { - it("changes only the selected Mega-Debrid account and preserves sibling order and mode settings", () => { - const oldId = getMegaDebridAccountId("second@example.com"); - const newId = getMegaDebridAccountId("renamed@example.com"); - const firstId = getMegaDebridAccountId("first@example.com"); - const webId = getMegaDebridAccountId("web@example.com"); - const settings = { +import { describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { createRendererState } from "../src/main/renderer-state"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import { + buildAccountDeleteCommand, + buildAccountReplaceCommand, + createAccountEditState, + validateAccountEdit, + type AccountEditTarget +} from "../src/renderer/account-edit"; + +function megaTarget(identity: string): AccountEditTarget { + const accountId = getMegaDebridAccountId(identity); + return { + type: "mega", + rowKey: `mega-megadebrid-api-${accountId}`, + kind: "megadebrid-api", + service: "megadebrid-api", + accountId + }; +} + +describe("renderer-safe account editing", () => { + it("opens an existing account without reading its stored secret", () => { + const identity = "safe-edit@example.test"; + const state = createRendererState({ ...defaultSettings(), - megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass\nweb@example.com:web-pass", - megaDebridApiCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass\nthird@example.com:third-pass", - megaDebridWebCredentials: "web@example.com:web-pass", - megaLogin: "first@example.com", - megaPassword: "first-pass", - megaDebridApiEnabled: true, - megaDebridWebEnabled: true, - megaDebridPreferApi: false, - megaDebridDisabledAccountIds: [oldId, firstId, webId], - megaDebridApiDisabledAccountIds: [oldId, firstId], - megaDebridWebDisabledAccountIds: [webId], - megaDebridAccountDailyLimitBytes: { [oldId]: 15 * GIB, [firstId]: 9 * GIB }, - megaDebridAccountDailyUsageBytes: { [oldId]: 4 * GIB, [firstId]: 2 * GIB }, - megaDebridAccountTotalUsageBytes: { [oldId]: 40 * GIB, [firstId]: 20 * GIB }, - debridAccountStatuses: { - [oldId]: { accountId: oldId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 }, - [firstId]: { accountId: firstId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } - } - }; - const state = { - ...createAccountEditState(megaTarget("second@example.com"), settings), - login: "renamed@example.com", - password: "new-pass", - dailyLimitGb: "25,5" - }; - - expect(validateAccountEdit(state, settings)).toBeNull(); - const next = applyAccountEdit(settings, state); - - expect(next.megaCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass\nweb@example.com:web-pass"); - expect(next.megaDebridApiCredentials).toBe("first@example.com:first-pass\nrenamed@example.com:new-pass\nthird@example.com:third-pass"); - expect(next.megaDebridWebCredentials).toBe("web@example.com:web-pass"); - expect(next.megaLogin).toBe("first@example.com"); - expect(next.megaPassword).toBe("first-pass"); - expect(next.megaDebridApiEnabled).toBe(true); - expect(next.megaDebridWebEnabled).toBe(true); - expect(next.megaDebridPreferApi).toBe(false); - expect(next.megaDebridDisabledAccountIds).toEqual([firstId, newId, webId]); - expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [firstId]: 9 * GIB, [newId]: Math.floor(25.5 * GIB) }); - expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [firstId]: 2 * GIB }); - expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [firstId]: 20 * GIB }); - expect(next.debridAccountStatuses).toEqual({ - [firstId]: { accountId: firstId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } - }); - }); - - it("keeps Mega-Debrid usage when only the password changes", () => { - const id = getMegaDebridAccountId("user@example.com"); - const settings = { - ...defaultSettings(), - megaCredentials: "user@example.com:old-pass", - megaLogin: "user@example.com", - megaPassword: "old-pass", - megaDebridAccountDailyLimitBytes: { [id]: 8 * GIB }, - megaDebridAccountDailyUsageBytes: { [id]: 3 * GIB }, - megaDebridAccountTotalUsageBytes: { [id]: 33 * GIB } - }; - const state = { ...createAccountEditState(megaTarget("user@example.com"), settings), password: "new-pass" }; - const next = applyAccountEdit(settings, state); - - expect(next.megaCredentials).toBe("user@example.com:new-pass"); - expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [id]: 3 * GIB }); - expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [id]: 33 * GIB }); - }); - - it("preserves exact account limits when the rounded display value is left unchanged", () => { - const megaLogin = "user@example.com"; - const megaId = getMegaDebridAccountId(megaLogin); - const key = "debrid-link-token"; - const keyId = getDebridLinkApiKeyId(key); - const exactLimit = Math.floor(10.05 * GIB); - const settings = { - ...defaultSettings(), - megaCredentials: `${megaLogin}:pass`, - megaLogin, - megaPassword: "pass", - megaDebridAccountDailyLimitBytes: { [megaId]: exactLimit }, - debridLinkApiKeys: key, - debridLinkApiKeyDailyLimitBytes: { [keyId]: exactLimit } - }; - - const megaNext = applyAccountEdit(settings, createAccountEditState(megaTarget(megaLogin), settings)); - const debridLinkNext = applyAccountEdit(settings, createAccountEditState(debridLinkTarget(key), settings)); - - expect(megaNext.megaDebridAccountDailyLimitBytes[megaId]).toBe(exactLimit); - expect(debridLinkNext.debridLinkApiKeyDailyLimitBytes[keyId]).toBe(exactLimit); - }); - - it("rejects whitespace-only Mega-Debrid passwords and empty or mismatched check results", () => { - const login = "user@example.com"; - const settings = { - ...defaultSettings(), - megaCredentials: `${login}:pass`, - megaLogin: login, - megaPassword: "pass" - }; - const state = { ...createAccountEditState(megaTarget(login), settings), password: " " }; - - expect(validateAccountEdit(state, settings)).toMatch(/Passwort/i); - expect(validateAccountEditStatuses(state, [])).toMatch(/keinen Account/i); - expect(validateAccountEditStatuses(state, [{ - accountId: "mda_other", - provider: "megadebrid", - label: "Account 1", - maskedLogin: "ot***er", - valid: true, - isPremium: true, - premiumUntilMs: null, - message: "OK", - checkedAt: 1 - }])).toMatch(/falschen Account/i); - }); - - it("rejects duplicate Mega-Debrid logins and missing row targets", () => { - const settings = { - ...defaultSettings(), - megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass" - }; - const duplicate = { - ...createAccountEditState(megaTarget("second@example.com"), settings), - login: "first@example.com" - }; - - expect(validateAccountEdit(duplicate, settings)).toMatch(/bereits vorhanden/i); - expect(() => createAccountEditState(megaTarget("missing@example.com"), settings)).toThrow(/nicht gefunden/i); - }); - - it("replaces only the selected Debrid-Link key and migrates its own metadata", () => { - const keyA = "token-a"; - const keyB = "token-b"; - const keyC = "token-c"; - const newKey = "token-b-new"; - const idA = getDebridLinkApiKeyId(keyA); - const idB = getDebridLinkApiKeyId(keyB); - const idNew = getDebridLinkApiKeyId(newKey); - const settings = { - ...defaultSettings(), - debridLinkApiKeys: `${keyA}\n${keyB}\n${keyC}`, - debridLinkDisabledKeyIds: [idB, idA], - debridLinkApiKeyDailyLimitBytes: { [idA]: 5 * GIB, [idB]: 10 * GIB }, - debridLinkApiKeyDailyUsageBytes: { [idA]: 2 * GIB, [idB]: 4 * GIB }, - debridLinkApiKeyTotalUsageBytes: { [idA]: 12 * GIB, [idB]: 24 * GIB } - }; - const state = { - ...createAccountEditState(debridLinkTarget(keyB), settings), - token: newKey, - dailyLimitGb: "12" - }; - const next = applyAccountEdit(settings, state); - - expect(next.debridLinkApiKeys).toBe(`${keyA}\n${newKey}\n${keyC}`); - expect(next.debridLinkDisabledKeyIds).toEqual([idA, idNew]); - expect(next.debridLinkApiKeyDailyLimitBytes).toEqual({ [idA]: 5 * GIB, [idNew]: 12 * GIB }); - expect(next.debridLinkApiKeyDailyUsageBytes).toEqual({ [idA]: 2 * GIB }); - expect(next.debridLinkApiKeyTotalUsageBytes).toEqual({ [idA]: 12 * GIB }); - }); - - it("edits a single login without changing unrelated credentials", () => { - const settings = { - ...defaultSettings(), - ddownloadLogin: "old@example.com", - ddownloadPassword: "old-pass", - linkSnappyLogin: "keep@example.com", - linkSnappyPassword: "keep-pass" - }; - const target: AccountEditTarget = { - type: "single", - rowKey: "svc-ddownload", - kind: "ddownload-login", - service: "ddownload", - provider: "ddownload" - }; - const state = { - ...createAccountEditState(target, settings), - login: "new@example.com", - password: "new-pass", - dailyLimitGb: "7" - }; - const next = applyAccountEdit(settings, state); - - expect(next.ddownloadLogin).toBe("new@example.com"); - expect(next.ddownloadPassword).toBe("new-pass"); - expect(next.linkSnappyLogin).toBe("keep@example.com"); - expect(next.linkSnappyPassword).toBe("keep-pass"); - expect(next.providerDailyLimitBytes.ddownload).toBe(7 * GIB); - }); - - it("rejects whitespace-only passwords for direct login accounts", () => { - const settings = { - ...defaultSettings(), - ddownloadLogin: "user@example.com", - ddownloadPassword: "password", - linkSnappyLogin: "member@example.com", - linkSnappyPassword: "secret" - }; - const ddownloadTarget: AccountEditTarget = { - type: "single", - rowKey: "svc-ddownload", - kind: "ddownload-login", - service: "ddownload", - provider: "ddownload" - }; - const linkSnappyTarget: AccountEditTarget = { - type: "single", - rowKey: "svc-linksnappy", - kind: "linksnappy-login", - service: "linksnappy", - provider: "linksnappy" - }; - - expect(validateAccountEdit({ ...createAccountEditState(ddownloadTarget, settings), password: " " }, settings)).toMatch(/Passwort/i); - expect(validateAccountEdit({ ...createAccountEditState(linkSnappyTarget, settings), password: "\t" }, settings)).toMatch(/Passwort/i); - }); - - it("builds a targeted check snapshot without invalid sibling accounts", () => { - const settings = { - ...defaultSettings(), - megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass", - megaLogin: "first@example.com", - megaPassword: "first-pass", - debridLinkApiKeys: "one\ntwo" - }; - const target = megaTarget("second@example.com"); - const state = createAccountEditState(target, settings); - const checkSettings = buildAccountEditCheckSettings(settings, state); - - expect(checkSettings.megaCredentials).toBe("second@example.com:second-pass"); - expect(checkSettings.megaLogin).toBe("second@example.com"); - expect(checkSettings.megaPassword).toBe("second-pass"); - expect(checkSettings.debridLinkApiKeys).toBe(""); - }); - - it("removes only the selected Mega-Debrid account and all metadata belonging to it", () => { - const removeId = getMegaDebridAccountId("second@example.com"); - const keepId = getMegaDebridAccountId("first@example.com"); - const settings = { - ...defaultSettings(), - megaCredentials: "first@example.com:first-pass\nsecond@example.com:second-pass", - megaLogin: "first@example.com", - megaPassword: "first-pass", - megaDebridDisabledAccountIds: [removeId, keepId], - megaDebridAccountDailyLimitBytes: { [removeId]: 2, [keepId]: 1 }, - megaDebridAccountDailyUsageBytes: { [removeId]: 4, [keepId]: 3 }, - megaDebridAccountTotalUsageBytes: { [removeId]: 6, [keepId]: 5 }, - debridAccountStatuses: { - [removeId]: { accountId: removeId, provider: "megadebrid" as const, label: "Account 2", maskedLogin: "se***om", valid: true, isPremium: false, premiumUntilMs: null, message: "Free", checkedAt: 1 }, - [keepId]: { accountId: keepId, provider: "megadebrid" as const, label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } - } - }; - const next = removeAccountTarget(settings, megaTarget("second@example.com")); - - expect(next.megaCredentials).toBe("first@example.com:first-pass"); - expect(next.megaDebridDisabledAccountIds).toEqual([keepId]); - expect(next.megaDebridAccountDailyLimitBytes).toEqual({ [keepId]: 1 }); - expect(next.megaDebridAccountDailyUsageBytes).toEqual({ [keepId]: 3 }); - expect(next.megaDebridAccountTotalUsageBytes).toEqual({ [keepId]: 5 }); - expect(next.debridAccountStatuses).toEqual({ - [keepId]: { accountId: keepId, provider: "megadebrid", label: "Account 1", maskedLogin: "fi***om", valid: true, isPremium: true, premiumUntilMs: null, message: "OK", checkedAt: 1 } - }); - }); -}); + megaCredentials: `${identity}:fixture-stored-secret-4dH8`, + megaDebridApiCredentials: `${identity}:fixture-stored-secret-4dH8`, + megaDebridApiEnabled: true + }); + + const edit = createAccountEditState(megaTarget(identity), state.accounts); + + expect(edit.login).toBe(identity); + expect(edit.password).toBe(""); + expect(JSON.stringify(edit)).not.toContain("fixture-stored-secret-4dH8"); + }); + + it("builds a replace command whose blank secret retains the main-process value", () => { + const identity = "safe-edit@example.test"; + const renderer = createRendererState({ + ...defaultSettings(), + megaCredentials: `${identity}:fixture-stored-secret-4dH8`, + megaDebridApiCredentials: `${identity}:fixture-stored-secret-4dH8`, + megaDebridApiEnabled: true + }); + const edit = createAccountEditState(megaTarget(identity), renderer.accounts); + + expect(validateAccountEdit(edit, renderer.accounts)).toBeNull(); + expect(buildAccountReplaceCommand(edit)).toEqual(expect.objectContaining({ + action: "replace", + accountId: getMegaDebridAccountId(identity), + identity, + secret: "" + })); + }); + + it("rejects duplicate identities using safe account metadata", () => { + const first = "first-safe@example.test"; + const second = "second-safe@example.test"; + const renderer = createRendererState({ + ...defaultSettings(), + megaCredentials: `${first}:fixture-first-secret-1aB2\n${second}:fixture-second-secret-3cD4`, + megaDebridApiCredentials: `${first}:fixture-first-secret-1aB2\n${second}:fixture-second-secret-3cD4`, + megaDebridApiEnabled: true + }); + const edit = { ...createAccountEditState(megaTarget(second), renderer.accounts), login: first }; + + expect(validateAccountEdit(edit, renderer.accounts)).toMatch(/bereits vorhanden/i); + }); + + it("builds an identity-only delete command", () => { + const target = megaTarget("delete-safe@example.test"); + expect(buildAccountDeleteCommand(target)).toEqual({ + action: "delete", + kind: "megadebrid-api", + accountId: target.type === "mega" ? target.accountId : "" + }); + }); +}); diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts new file mode 100644 index 0000000..984903e --- /dev/null +++ b/tests/account-preload.test.ts @@ -0,0 +1,64 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { IPC_CHANNELS } from "../src/shared/ipc"; +import type { ElectronApi } from "../src/shared/preload-api"; + +const electron = vi.hoisted(() => ({ + api: undefined as ElectronApi | undefined, + invoke: vi.fn<(...args: unknown[]) => Promise>(async () => undefined) +})); + +vi.mock("electron", () => ({ + contextBridge: { + exposeInMainWorld: (_name: string, api: ElectronApi) => { + electron.api = api; + } + }, + ipcRenderer: { + invoke: electron.invoke, + on: vi.fn(), + removeListener: vi.fn(), + send: vi.fn() + } +})); + +describe("account preload contract", () => { + beforeAll(async () => { + await import("../src/preload/preload"); + }); + + beforeEach(() => { + electron.invoke.mockClear(); + }); + + it("forwards submitted secrets only in write-only account commands", async () => { + const secret = "fixture-preload-secret-5zK1"; + electron.invoke.mockResolvedValueOnce({ accountId: "mda_fixture", settings: { language: "de" }, accounts: [] }); + const result = await electron.api?.createAccount({ + action: "create", + kind: "megadebrid-api", + identity: "preload-account@example.test", + secret, + dailyLimitBytes: 0 + }); + + expect(electron.invoke).toHaveBeenCalledWith( + IPC_CHANNELS.CREATE_ACCOUNT, + expect.objectContaining({ secret }) + ); + expect(JSON.stringify(result)).not.toContain(secret); + }); + + it("exposes separate replace, update-secret and delete channels", async () => { + electron.invoke.mockResolvedValue({ accountId: "mda_fixture", settings: { language: "de" }, accounts: [] }); + + await electron.api?.replaceAccount({ action: "replace", kind: "megadebrid-api", accountId: "mda_fixture", identity: "account@example.test", secret: "", dailyLimitBytes: 0 }); + await electron.api?.updateAccountSecret({ action: "update-secret", kind: "megadebrid-api", accountId: "mda_fixture", secret: "fixture-new-secret-8sP4" }); + await electron.api?.deleteAccount({ action: "delete", kind: "megadebrid-api", accountId: "mda_fixture" }); + + expect(electron.invoke.mock.calls.map((call) => call[0])).toEqual([ + IPC_CHANNELS.REPLACE_ACCOUNT, + IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, + IPC_CHANNELS.DELETE_ACCOUNT + ]); + }); +}); diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index 17b4039..7d231c9 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { once } from "node:events"; import AdmZip from "adm-zip"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../src/main/windows-host-diagnostics", () => ({ getWindowsHostDiagnostics: () => ({ @@ -40,7 +40,9 @@ vi.mock("../src/main/windows-host-diagnostics", () => ({ }) })); -import { defaultSettings } from "../src/main/constants"; +import { defaultSettings } from "../src/main/constants"; +import { configureCredentialProtector } from "../src/main/credential-protection"; +import { createRendererState } from "../src/main/renderer-state"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log"; import { startDebugServer, stopDebugServer } from "../src/main/debug-server"; import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log"; @@ -70,6 +72,14 @@ const forbiddenSupportMarkers = [ ["M", "C", "P"].join(""), ["K", "I"].join("") ]; + +beforeEach(() => { + configureCredentialProtector({ + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value, "utf8").reverse(), + decryptString: (value) => Buffer.from(value).reverse().toString("utf8") + }); +}); async function getFreePort(): Promise { const probe = http.createServer(); @@ -99,15 +109,16 @@ async function waitForReady(url: string): Promise { throw new Error(`debug server not ready: ${url}`); } -function buildSnapshot(baseDir: string): UiSnapshot { +function buildSnapshot(baseDir: string): UiSnapshot { const settings = { ...defaultSettings(), outputDir: path.join(baseDir, "downloads"), extractDir: path.join(baseDir, "extract") }; - return { - settings, + const renderer = createRendererState(settings); + return { + ...renderer, session: { version: 1, packageOrder: ["pkg-1"], @@ -220,8 +231,10 @@ async function createFixture() { const debridLinkApiKeys = "key-a\nkey-b"; const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys); - saveSettings(storagePaths, { - ...snapshot.settings, + saveSettings(storagePaths, { + ...defaultSettings(), + outputDir: snapshot.settings.outputDir, + extractDir: snapshot.settings.extractDir, token: "rd-secret-token", realDebridUseWebLogin: true, debridLinkApiKeys, diff --git a/tests/link-export.test.ts b/tests/link-export.test.ts index 7195178..63d4b4b 100644 --- a/tests/link-export.test.ts +++ b/tests/link-export.test.ts @@ -4,8 +4,9 @@ import { parseCollectorInput } from "../src/main/link-parser"; import type { UiSnapshot } from "../src/shared/types"; function buildSnapshot(): UiSnapshot { - return { - settings: {} as UiSnapshot["settings"], + return { + settings: {} as UiSnapshot["settings"], + accounts: [], session: { version: 1, packageOrder: ["pkg-1", "pkg-2"], diff --git a/tests/renderer-state.test.ts b/tests/renderer-state.test.ts new file mode 100644 index 0000000..91061f7 --- /dev/null +++ b/tests/renderer-state.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { defaultSettings } from "../src/main/constants"; +import { createRendererState } from "../src/main/renderer-state"; +import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys"; +import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; +import type { AppSettings, RendererAccountKind } from "../src/shared/types"; + +const SECRETS = { + token: "fixture-rd-token-7vQ2", + megaPassword: "fixture-mega-password-8kM3", + bestToken: "fixture-best-token-5xL9", + allDebridToken: "fixture-ad-token-4pR6", + ddownloadPassword: "fixture-dd-password-2tN8", + oneFichierApiKey: "fixture-onefichier-key-6cW1", + debridLinkApiKey: "fixture-debridlink-key-9hS4", + linkSnappyPassword: "fixture-linksnappy-password-3mB7", + archivePassword: "fixture-archive-password-1jD5", + notifyUrl: "https://notify.example.test/hooks/fixture-notify-secret-0fA2" +} as const; + +const ACCOUNT_FIXTURES: Array<{ + kind: RendererAccountKind; + secret: string; + settings: Partial; +}> = [ + { kind: "realdebrid-api", secret: "fixture-rd-api-secret-1aK4", settings: { token: "fixture-rd-api-secret-1aK4" } }, + { kind: "realdebrid-web", secret: "fixture-rd-web-session-2bL5", settings: { token: "fixture-rd-web-session-2bL5", realDebridUseWebLogin: true } }, + { kind: "megadebrid-api", secret: "fixture-mega-api-secret-3cM6", settings: { megaCredentials: "mega-api@example.test:fixture-mega-api-secret-3cM6", megaDebridApiCredentials: "mega-api@example.test:fixture-mega-api-secret-3cM6", megaDebridApiEnabled: true } }, + { kind: "megadebrid-web", secret: "fixture-mega-web-secret-4dN7", settings: { megaCredentials: "mega-web@example.test:fixture-mega-web-secret-4dN7", megaDebridWebCredentials: "mega-web@example.test:fixture-mega-web-secret-4dN7", megaDebridWebEnabled: true } }, + { kind: "bestdebrid-api", secret: "fixture-best-api-secret-5eP8", settings: { bestToken: "fixture-best-api-secret-5eP8" } }, + { kind: "bestdebrid-web", secret: "fixture-best-web-session-6fQ9", settings: { bestToken: "fixture-best-web-session-6fQ9", bestDebridUseWebLogin: true } }, + { kind: "alldebrid-api", secret: "fixture-all-api-secret-7gR1", settings: { allDebridToken: "fixture-all-api-secret-7gR1" } }, + { kind: "alldebrid-web", secret: "fixture-all-web-session-8hS2", settings: { allDebridToken: "fixture-all-web-session-8hS2", allDebridUseWebLogin: true } }, + { kind: "ddownload-login", secret: "fixture-dd-secret-9jT3", settings: { ddownloadLogin: "dd@example.test", ddownloadPassword: "fixture-dd-secret-9jT3" } }, + { kind: "onefichier-api", secret: "fixture-one-secret-0kU4", settings: { oneFichierApiKey: "fixture-one-secret-0kU4" } }, + { kind: "debridlink-api", secret: "fixture-dl-secret-1mV5", settings: { debridLinkApiKeys: "fixture-dl-secret-1mV5" } }, + { kind: "linksnappy-login", secret: "fixture-ls-secret-2nW6", settings: { linkSnappyLogin: "ls@example.test", linkSnappyPassword: "fixture-ls-secret-2nW6" } } +]; + +describe("renderer state serialization", () => { + it.each(ACCOUNT_FIXTURES)("serializes $kind without its representative secret", ({ kind, secret, settings }) => { + const state = createRendererState({ ...defaultSettings(), ...settings }); + + expect(state.accounts).toEqual(expect.arrayContaining([expect.objectContaining({ kind, hasSecret: true })])); + expect(JSON.stringify(state)).not.toContain(secret); + expect(JSON.stringify(state.settings)).not.toContain(secret); + }); + + it("excludes every provider and settings secret while preserving safe account metadata", () => { + const megaLogin = "renderer-fixture@example.test"; + const settings = { + ...defaultSettings(), + token: SECRETS.token, + megaLogin, + megaPassword: SECRETS.megaPassword, + megaCredentials: `${megaLogin}:${SECRETS.megaPassword}`, + megaDebridApiCredentials: `${megaLogin}:${SECRETS.megaPassword}`, + megaDebridApiEnabled: true, + bestToken: SECRETS.bestToken, + allDebridToken: SECRETS.allDebridToken, + ddownloadLogin: "renderer-dd@example.test", + ddownloadPassword: SECRETS.ddownloadPassword, + oneFichierApiKey: SECRETS.oneFichierApiKey, + debridLinkApiKeys: SECRETS.debridLinkApiKey, + linkSnappyLogin: "renderer-linksnappy@example.test", + linkSnappyPassword: SECRETS.linkSnappyPassword, + archivePasswordList: SECRETS.archivePassword, + notifyUrl: SECRETS.notifyUrl + }; + + const state = createRendererState(settings); + const serialized = JSON.stringify(state); + + for (const secret of Object.values(SECRETS)) { + expect(serialized).not.toContain(secret); + } + expect(state.settings.archivePasswordListConfigured).toBe(true); + expect(state.settings.notifyUrlConfigured).toBe(true); + expect(state.accounts).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "realdebrid-api", hasSecret: true }), + expect.objectContaining({ kind: "megadebrid-api", identity: megaLogin, hasSecret: true }), + expect.objectContaining({ kind: "debridlink-api", hasSecret: true }) + ])); + }); + + it("redacts individual secrets embedded in status metadata for multi-account pools", () => { + const firstMegaSecret = "fixture-first-mega-pool-secret-3pX7"; + const secondMegaSecret = "fixture-second-mega-pool-secret-4qY8"; + const firstKey = "fixture-first-debridlink-pool-secret-5rZ9"; + const secondKey = "fixture-second-debridlink-pool-secret-6sA1"; + const secondMegaId = getMegaDebridAccountId("second-pool@example.test"); + const secondKeyId = getDebridLinkApiKeyId(secondKey); + const settings = { + ...defaultSettings(), + megaCredentials: `first-pool@example.test:${firstMegaSecret}\nsecond-pool@example.test:${secondMegaSecret}`, + megaDebridApiCredentials: `first-pool@example.test:${firstMegaSecret}\nsecond-pool@example.test:${secondMegaSecret}`, + megaDebridApiEnabled: true, + debridLinkApiKeys: `${firstKey}\n${secondKey}`, + debridAccountStatuses: { + [secondMegaId]: { + accountId: secondMegaId, + provider: "megadebrid" as const, + label: "Account 2", + maskedLogin: "se***st", + valid: false, + isPremium: false, + premiumUntilMs: null, + message: `Rejected ${secondMegaSecret}`, + checkedAt: 1 + }, + [secondKeyId]: { + accountId: secondKeyId, + provider: "debridlink" as const, + label: "Key 2", + maskedLogin: "fi***A1", + valid: false, + isPremium: false, + premiumUntilMs: null, + message: `Rejected ${secondKey}`, + checkedAt: 1 + } + } + }; + + const serialized = JSON.stringify(createRendererState(settings)); + + for (const secret of [firstMegaSecret, secondMegaSecret, firstKey, secondKey]) { + expect(serialized).not.toContain(secret); + } + }); +}); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 7f71ae7..4aeca1f 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -3,12 +3,9 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { defaultSettings } from "../src/main/constants"; +import { createRendererSettings, createRendererState } from "../src/main/renderer-state"; import { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App"; -import { - applyAccountEdit, - createAccountEditState, - type AccountEditTarget -} from "../src/renderer/account-edit"; +import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit"; import { buildBulkAccountEnabledState, buildConfiguredProviderOrder @@ -416,7 +413,6 @@ describe("settings model", () => { const login = "member@example.test"; const oldId = getMegaDebridAccountId(login); const newLogin = "renamed@example.test"; - const newId = getMegaDebridAccountId(newLogin); const exactLimit = Math.floor(10.05 * GIB); const settings = { ...defaultSettings(), @@ -435,17 +431,17 @@ describe("settings model", () => { service: "megadebrid-api", accountId: oldId }; - const unchanged = applyAccountEdit(settings, createAccountEditState(target, settings)); - const renamed = applyAccountEdit(settings, { - ...createAccountEditState(target, settings), + const renderer = createRendererState(settings); + const unchanged = buildAccountReplaceCommand(createAccountEditState(target, renderer.accounts)); + const renamed = buildAccountReplaceCommand({ + ...createAccountEditState(target, renderer.accounts), login: newLogin }); - expect(unchanged.megaDebridAccountDailyLimitBytes[oldId]).toBe(exactLimit); - expect(renamed.megaDebridDisabledAccountIds).toEqual([newId]); - expect(renamed.megaDebridAccountDailyLimitBytes[newId]).toBe(exactLimit); - expect(renamed.megaDebridAccountDailyUsageBytes[newId]).toBeUndefined(); - expect(renamed.megaDebridAccountTotalUsageBytes[newId]).toBeUndefined(); + expect(unchanged.dailyLimitBytes).toBe(exactLimit); + expect(unchanged.secret).toBe(""); + expect(renamed.identity).toBe(newLogin); + expect(renamed.secret).toBe(""); }); }); @@ -468,7 +464,7 @@ describe("settings views", () => { it("offers animated language and bounded history retention choices", () => { const form = buildSettingsFormViewModel({ - settings: defaultSettings(), + settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" }, section: "allgemein", speedLimitInput: "0", scheduleSpeedInputs: {} @@ -912,9 +908,10 @@ describe("settings App integration", () => { megaCredentials: "first@example.test:first-secret\nsecond@example.test:second-secret", debridLinkApiKeys: "existing-debrid-link-key" }; - const megaDialog = createAccountDialogState("create", "megadebrid-api", settings); + const rendererSettings = createRendererSettings(settings); + const megaDialog = createAccountDialogState("create", "megadebrid-api", rendererSettings); const megaFields = buildAccountAddFields(megaDialog); - const debridLinkFields = buildAccountAddFields(createAccountDialogState("create", "debridlink-api", settings)); + const debridLinkFields = buildAccountAddFields(createAccountDialogState("create", "debridlink-api", rendererSettings)); expect(megaDialog.megaNewLogin).toBe(""); expect(megaDialog.megaNewPassword).toBe(""); diff --git a/tests/visual-fixtures.test.ts b/tests/visual-fixtures.test.ts index 8ad283e..8ccc1c1 100644 --- a/tests/visual-fixtures.test.ts +++ b/tests/visual-fixtures.test.ts @@ -1,8 +1,6 @@ import React, { type ReactElement } from "react"; import { describe, expect, it } from "vitest"; import { App } from "../src/renderer/App"; -import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys"; -import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import type { ElectronApi } from "../src/shared/preload-api"; import * as visualFixtures from "./visual/fixtures"; import * as visualMain from "./visual/main"; @@ -136,11 +134,12 @@ describe("visual fixtures", () => { it("aligns dense account table values with credential-derived account IDs", async () => { const dense = createVisualFixture("dense"); const settings = dense.snapshot.settings; - const megaAccountId = getMegaDebridAccountId(settings.megaLogin); - const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys); + const megaAccount = dense.snapshot.accounts.find((account) => account.kind === "megadebrid-api"); + const debridLinkKeys = dense.snapshot.accounts.filter((account) => account.kind === "debridlink-api"); + const megaAccountId = megaAccount?.accountId || ""; expect(megaAccountId).toBe("mda_2f92guyzhdf6j"); - expect(debridLinkKeys.map((entry) => entry.id)).toEqual([ + expect(debridLinkKeys.map((entry) => entry.accountId)).toEqual([ "dlk_1ix5qlyx6mtm1", "dlk_1ix5pfvlg4nkg" ]); @@ -152,23 +151,23 @@ describe("visual fixtures", () => { ); for (const entry of debridLinkKeys) { - expect(settings.debridAccountStatuses[entry.id]?.valid).toBe(true); - expect(settings.debridLinkApiKeyDailyLimitBytes[entry.id]).toBeGreaterThan(0); - expect(settings.debridLinkApiKeyDailyUsageBytes[entry.id]).toBeGreaterThan(0); - expect(settings.debridLinkApiKeyDailyUsageBytes[entry.id]).toBeLessThan( - settings.debridLinkApiKeyDailyLimitBytes[entry.id] + expect(settings.debridAccountStatuses[entry.accountId]?.valid).toBe(true); + expect(settings.debridLinkApiKeyDailyLimitBytes[entry.accountId]).toBeGreaterThan(0); + expect(settings.debridLinkApiKeyDailyUsageBytes[entry.accountId]).toBeGreaterThan(0); + expect(settings.debridLinkApiKeyDailyUsageBytes[entry.accountId]).toBeLessThan( + settings.debridLinkApiKeyDailyLimitBytes[entry.accountId] ); - expect(settings.debridLinkApiKeyTotalUsageBytes[entry.id]).toBeGreaterThan( - settings.debridLinkApiKeyDailyUsageBytes[entry.id] + expect(settings.debridLinkApiKeyTotalUsageBytes[entry.accountId]).toBeGreaterThan( + settings.debridLinkApiKeyDailyUsageBytes[entry.accountId] ); } const debridLinkItem = Object.values(dense.snapshot.session.items).find( (item) => item.provider === "debridlink" ); - expect(debridLinkItem?.providerAccountId).toBe(debridLinkKeys[0].id); + expect(debridLinkItem?.providerAccountId).toBe(debridLinkKeys[0].accountId); const hostLimits = await createVisualElectronApi(dense).getDebridLinkHostLimits(); - expect(hostLimits[0]?.keyId).toBe(debridLinkKeys[0].id); + expect(hostLimits[0]?.keyId).toBe(debridLinkKeys[0].accountId); }); it("stores every mutable bridge state inside the visual fixture", async () => { diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 74f3e97..d7f7922 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -7,7 +7,8 @@ import type { UpdateCheckResult } from "../../src/shared/types"; import { parseDebridLinkApiKeys } from "../../src/shared/debrid-link-keys"; -import { getMegaDebridAccountId } from "../../src/shared/mega-debrid-accounts"; +import { getMegaDebridAccountId } from "../../src/shared/mega-debrid-accounts"; +import { createRendererState } from "../../src/main/renderer-state"; export const VISUAL_SCENARIOS = ["empty", "dense", "update"] as const; @@ -228,9 +229,10 @@ function createSettings(): AppSettings { }; } -function createEmptySnapshot(): UiSnapshot { - return { - settings: createSettings(), +function createEmptySnapshot(): UiSnapshot { + const renderer = createRendererState(createSettings()); + return { + ...renderer, session: { version: 1, packageOrder: [], @@ -274,9 +276,9 @@ function createEmptySnapshot(): UiSnapshot { }; } -function createDenseSnapshot(): UiSnapshot { - const snapshot = createEmptySnapshot(); - const debridLinkKeys = parseDebridLinkApiKeys(snapshot.settings.debridLinkApiKeys); +function createDenseSnapshot(): UiSnapshot { + const snapshot = createEmptySnapshot(); + const debridLinkKeys = snapshot.accounts.filter((account) => account.kind === "debridlink-api"); snapshot.session = { version: 1, packageOrder: ["visual-package-active", "visual-package-complete", "visual-package-failed"], @@ -359,7 +361,7 @@ function createDenseSnapshot(): UiSnapshot { url: "https://ddownload.com/visual-active-2", provider: "debridlink", providerLabel: "Debrid-Link", - providerAccountId: debridLinkKeys[0].id, + providerAccountId: debridLinkKeys[0].accountId, providerAccountLabel: "Debrid-Link Key 1", status: "queued", retries: 0, diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index c568bfc..252e3ca 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -1,6 +1,5 @@ import type { ElectronApi } from "../../src/shared/preload-api"; -import type { AppSettings, HistoryEntry } from "../../src/shared/types"; -import { parseDebridLinkApiKeys } from "../../src/shared/debrid-link-keys"; +import type { HistoryEntry, RendererSettings, RendererSettingsUpdate } from "../../src/shared/types"; import type { VisualFixture } from "./fixtures"; const stableNoopUnsubscribe = (): void => {}; @@ -15,8 +14,9 @@ export function createVisualElectronApi( ): ElectronApi { const historyState = new URLSearchParams(search).get("history-state"); let historyRequestCount = 0; - const updateSettings = (settings: Partial): AppSettings => { - Object.assign(fixture.snapshot.settings, settings); + const updateSettings = (settings: RendererSettingsUpdate): RendererSettings => { + const { archivePasswordList: _archivePasswordList, notifyUrl: _notifyUrl, ...safe } = settings; + Object.assign(fixture.snapshot.settings, safe); return clone(fixture.snapshot.settings); }; @@ -35,6 +35,10 @@ export function createVisualElectronApi( fixture.snapshot.settings.debridLinkApiKeyDailyUsageBytes[keyId] = 0; return clone(fixture.snapshot.settings); }, + createAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), + replaceAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), + updateAccountSecret: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), + deleteAccount: async () => ({ accountId: null, settings: clone(fixture.snapshot.settings), accounts: clone(fixture.snapshot.accounts) }), addLinks: async () => ({ addedPackages: 0, addedLinks: 0, invalidCount: 0 }), addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }), getStartConflicts: async () => [], @@ -291,10 +295,11 @@ export function createVisualElectronApi( note: "Visual host status" }), getDebridLinkHostLimits: async () => { - const primaryKey = parseDebridLinkApiKeys(fixture.snapshot.settings.debridLinkApiKeys)[0]; + const primaryKey = fixture.snapshot.accounts.find((account) => account.kind === "debridlink-api"); + if (!primaryKey) return []; return [{ - keyId: primaryKey.id, - keyLabel: primaryKey.label, + keyId: primaryKey.accountId, + keyLabel: "Key 1", host: "ddownload.com", fetchedAt: 1786312800000, trafficCurrentBytes: 53687091200, @@ -314,12 +319,17 @@ export function createVisualElectronApi( }]; }, checkDebridAccounts: async () => clone(Object.values(fixture.snapshot.settings.debridAccountStatuses)), - checkMegaDebridAccount: async () => { - const status = Object.values(fixture.snapshot.settings.debridAccountStatuses).find( - (entry) => entry.provider === "megadebrid" - ); - return status ? clone(status) : null; - }, + checkAccountCredentials: async (input) => clone(fixture.snapshot.accounts.find((account) => account.accountId === input.accountId)?.status || { + accountId: input.accountId || "visual-account", + provider: input.kind === "debridlink-api" ? "debridlink" : "megadebrid", + label: "Visual Account", + maskedLogin: "vi***al", + valid: true, + isPremium: true, + premiumUntilMs: null, + message: "Premium aktiv", + checkedAt: 1786312800000 + }), retryExtraction: async (packageId) => { const entry = fixture.snapshot.session.packages[packageId]; if (entry) {