diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b26d35..28b343c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to Multi-Debrid Downloader are documented in this file. ## [Unreleased] +## [2.0.46] - 2026-08-20 + +### Archive passwords + +- Restored the saved archive password list automatically when the Extraction settings section is opened. +- Kept the visible password list intact after settings saves, account updates, and live state refreshes. +- Prevented late password-list loads from overwriting unsaved edits and invalidated stale loads during backup imports. +- Kept archive passwords out of general renderer snapshots and exposed them only through a dedicated trusted IPC channel. + ## [2.0.45] - 2026-08-19 ### Account availability diff --git a/package-lock.json b/package-lock.json index d9d67a6..e9cfe71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-debrid-downloader", - "version": "2.0.45", + "version": "2.0.46", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-debrid-downloader", - "version": "2.0.45", + "version": "2.0.46", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 753c0bb..852135a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-debrid-downloader", - "version": "2.0.45", + "version": "2.0.46", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index abf7486..be9ca4f 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -13,6 +13,7 @@ import { AccountCredentialCheckInput, AccountSecretRequest, AccountSecretResult, + ArchivePasswordListResult, DebridAccountStatus, DebridProvider, DuplicatePolicy, @@ -562,6 +563,13 @@ export class AppController { return { secret }; } + public getArchivePasswordList(): ArchivePasswordListResult { + const passwords = this.settings.archivePasswordList; + const entryCount = passwords.split(/\r?\n/).filter((entry) => entry.trim().length > 0).length; + this.audit("INFO", "Archiv-Passwortliste explizit angezeigt", { entryCount }); + return { passwords }; + } + public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise { const redactions = collectAccountStatusRedactionValues(this.settings, input); if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") { diff --git a/src/main/main.ts b/src/main/main.ts index e17c921..3d7d160 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -451,7 +451,8 @@ function registerIpcHandlers(): void { handleTrusted(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawRequest: unknown) => { return controller.revealAccountSecret(validateAccountSecretRequest(rawRequest)); }); - handleTrusted(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { + handleTrusted(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST, () => controller.getArchivePasswordList()); + handleTrusted(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { validatePlainObject(payload ?? {}, "payload"); validateString(payload?.rawText, "rawText"); if (payload.packageName !== undefined) { diff --git a/src/preload/preload.ts b/src/preload/preload.ts index a20120a..3a9ffea 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -110,6 +110,7 @@ const api: ElectronApi = { checkDebridAccounts: (scope: AccountCheckScope = "active"): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, scope), checkAccountCredentials: (input: AccountCredentialCheckInput): Promise => ipcRenderer.invoke(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, input), revealAccountSecret: (input: AccountSecretRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, input), + getArchivePasswordList: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST), 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), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index cc1e17a..2ba13f7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1536,6 +1536,7 @@ export function App(): ReactElement { const updateCheckGenerationRef = useRef(0); const settingsDirtyRef = useRef(false); const writeOnlySettingsDirtyRef = useRef(new Set<"archivePasswordList" | "notifyUrl">()); + const archivePasswordLoadGenerationRef = useRef(0); const settingsDraftRevisionRef = useRef(0); useEffect(() => { @@ -1770,6 +1771,32 @@ export function App(): ReactElement { }, timeoutMs); }, []); + useEffect(() => { + if (settingsSubTab !== "extract" || writeOnlySettingsDirtyRef.current.has("archivePasswordList")) { + archivePasswordLoadGenerationRef.current += 1; + return; + } + const generation = archivePasswordLoadGenerationRef.current + 1; + archivePasswordLoadGenerationRef.current = generation; + void window.rd.getArchivePasswordList().then(({ passwords }) => { + if (archivePasswordLoadGenerationRef.current !== generation || writeOnlySettingsDirtyRef.current.has("archivePasswordList")) { + return; + } + setSettingsDraft((current) => current.archivePasswordList === passwords + ? current + : { ...current, archivePasswordList: passwords }); + }).catch(() => { + if (archivePasswordLoadGenerationRef.current === generation) { + showToast("Archiv-Passwortliste konnte nicht geladen werden", 2800); + } + }); + return () => { + if (archivePasswordLoadGenerationRef.current === generation) { + archivePasswordLoadGenerationRef.current += 1; + } + }; + }, [settingsSubTab, snapshot.settings.archivePasswordListConfigured, showToast]); + const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => { const availableIds = entries.map((entry) => entry.id); const availableSet = new Set(availableIds); @@ -1913,7 +1940,7 @@ export function App(): ReactElement { if (state.settings.columnOrder?.length > 0) { setColumnOrder(state.settings.columnOrder); } - setSettingsDraft(createSettingsDraft(state.settings)); + setSettingsDraft((current) => createSettingsDraft(state.settings, current)); writeOnlySettingsDirtyRef.current.clear(); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; @@ -1969,9 +1996,9 @@ export function App(): ReactElement { if (next.settings.columnOrder?.length > 0) { setColumnOrder(next.settings.columnOrder); } - if (!settingsDirtyRef.current) { - setSettingsDraft(createSettingsDraft(next.settings)); - } + if (!settingsDirtyRef.current) { + setSettingsDraft((current) => createSettingsDraft(next.settings, current)); + } latestStateRef.current = null; } }, flushDelay); @@ -2627,8 +2654,11 @@ export function App(): ReactElement { }); }; - const applyPersistedSettings = (result: RendererSettings): void => { - setSettingsDraft(createSettingsDraft(result)); + const applyPersistedSettings = (result: RendererSettings, preserveWriteOnlyValues = true): void => { + if (!preserveWriteOnlyValues) { + archivePasswordLoadGenerationRef.current += 1; + } + setSettingsDraft((current) => createSettingsDraft(result, preserveWriteOnlyValues ? current : undefined)); writeOnlySettingsDirtyRef.current.clear(); settingsDirtyRef.current = false; panelDirtyRevisionRef.current = 0; @@ -2783,6 +2813,7 @@ export function App(): ReactElement { return; } await performQuickAction(async () => { + await persistDraftSettings(); const result = await window.rd.replaceAccount(buildAccountReplaceCommand(editSnapshot)); setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); applyPersistedSettings(result.settings); @@ -2813,6 +2844,7 @@ export function App(): ReactElement { } const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null; await performQuickAction(async () => { + await persistDraftSettings(); if (dialogSnapshot.kind === "realdebrid-web") { const accountId = `rdw_${crypto.randomUUID().replace(/-/g, "")}`; const request = buildRealDebridWebCreateLoginRequest(dialogSnapshot, accountId); @@ -2966,6 +2998,7 @@ export function App(): ReactElement { 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 () => { + await persistDraftSettings(); 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); @@ -3055,6 +3088,7 @@ export function App(): ReactElement { return; } await performQuickAction(async () => { + await persistDraftSettings(); for (const selectedRow of rows) { const result = await window.rd.deleteAccount(buildAccountDeleteCommand(selectedRow.editTarget)); setSnapshot((current) => ({ ...current, settings: result.settings, accounts: result.accounts })); @@ -4304,7 +4338,7 @@ export function App(): ReactElement { // fresh settings and re-seed the draft so the UI reflects the import. if (!result.relaunch) { const fresh = await window.rd.getSnapshot(); - applyPersistedSettings(fresh.settings); + applyPersistedSettings(fresh.settings, false); } } else if (result.message !== "Abgebrochen") { showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000); @@ -4338,7 +4372,7 @@ export function App(): ReactElement { try { const result = await window.rd.importOnlineBackup(key); const fresh = await window.rd.getSnapshot(); - applyPersistedSettings(fresh.settings); + applyPersistedSettings(fresh.settings, false); setOnlineBackupDialog(null); showToast(result.message, 4000); } catch { diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index d394e5e..022191a 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -74,6 +74,7 @@ export const IPC_CHANNELS = { CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts", CHECK_ACCOUNT_CREDENTIALS: "app:check-account-credentials", REVEAL_ACCOUNT_SECRET: "app:reveal-account-secret", + GET_ARCHIVE_PASSWORD_LIST: "app:get-archive-password-list", RETRY_EXTRACTION: "queue:retry-extraction", EXTRACT_NOW: "queue:extract-now", RESET_PACKAGE: "queue:reset-package", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index be22a78..200c78f 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -5,6 +5,7 @@ import type { AccountCredentialCheckInput, AccountSecretRequest, AccountSecretResult, + ArchivePasswordListResult, AccountCreateCommand, AccountDeleteCommand, AccountReplaceCommand, @@ -136,6 +137,7 @@ export interface ElectronApi { checkDebridAccounts: (scope?: AccountCheckScope) => Promise; checkAccountCredentials: (input: AccountCredentialCheckInput) => Promise; revealAccountSecret: (input: AccountSecretRequest) => Promise; + getArchivePasswordList: () => Promise; retryExtraction: (packageId: string) => Promise; extractNow: (packageId: string) => Promise; resetPackage: (packageId: string) => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index f0ce4aa..0a1d792 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -389,8 +389,12 @@ export interface AccountSecretRequest { export interface AccountSecretResult { secret: string; } - -export interface DownloadItem { + +export interface ArchivePasswordListResult { + passwords: string; +} + +export interface DownloadItem { id: string; packageId: string; url: string; diff --git a/tests/account-check-ipc-sanitizer.test.ts b/tests/account-check-ipc-sanitizer.test.ts index bddc784..2c71415 100644 --- a/tests/account-check-ipc-sanitizer.test.ts +++ b/tests/account-check-ipc-sanitizer.test.ts @@ -103,4 +103,19 @@ Backup-Passphrase=${echoedPassphrase}`; expect((controller as unknown as { manager: { applyDebridAccountStatuses: ReturnType } }).manager.applyDebridAccountStatuses) .toHaveBeenCalledWith([expect.objectContaining({ accountId, valid: true })]); }); + + it("returns the stored archive password list only through the explicit accessor", () => { + const passwords = "fixture-archive-password-one\nfixture-archive-password-two"; + const controller = createController({ ...defaultSettings(), archivePasswordList: passwords }); + + const result = controller.getArchivePasswordList(); + + expect(result).toEqual({ passwords }); + expect((controller as unknown as { audit: ReturnType }).audit).toHaveBeenCalledWith( + "INFO", + "Archiv-Passwortliste explizit angezeigt", + { entryCount: 2 } + ); + expect(JSON.stringify((controller as unknown as { audit: ReturnType }).audit.mock.calls)).not.toContain(passwords); + }); }); diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts index b0a8e2f..9c5520d 100644 --- a/tests/account-preload.test.ts +++ b/tests/account-preload.test.ts @@ -97,4 +97,16 @@ describe("account preload contract", () => { ); expect(result).toEqual({ secret: "fixture-revealed-secret-7gH8" }); }); + + it("loads the stored archive password list only through its dedicated channel", async () => { + const passwords = "fixture-archive-one\nfixture-archive-two"; + electron.invoke.mockResolvedValueOnce({ passwords }); + + const result = await (electron.api as ElectronApi & { + getArchivePasswordList: () => Promise<{ passwords: string }>; + }).getArchivePasswordList(); + + expect(electron.invoke).toHaveBeenCalledWith(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST); + expect(result).toEqual({ passwords }); + }); }); diff --git a/tests/renderer-settings.test.ts b/tests/renderer-settings.test.ts index 3fbf0f6..d669258 100644 --- a/tests/renderer-settings.test.ts +++ b/tests/renderer-settings.test.ts @@ -39,4 +39,12 @@ describe("renderer settings validation", () => { expect(validateRendererSettingsUpdate({ columnOrderVersion: undefined }, current)).toEqual({}); expect(() => validateRendererSettingsUpdate({ obsoleteSetting: true }, current)).toThrow("Settings-Payload ist ungültig"); }); + + it("keeps archive passwords out of general renderer settings", () => { + const password = "fixture-renderer-hidden-archive-password"; + const projected = createRendererSettings({ ...defaultSettings(), archivePasswordList: password }); + + expect(projected.archivePasswordListConfigured).toBe(true); + expect(JSON.stringify(projected)).not.toContain(password); + }); }); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 3b2f6c2..9cdb761 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -1124,6 +1124,38 @@ describe("account workspace", () => { }); describe("settings App integration", () => { + it("loads and preserves the stored archive password list in the extraction section", () => { + const revealBlock = sourceBlock(appSource, "const showToast", "const clearImportQueueFocusListener"); + const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings"); + const mainSource = readFileSync(new URL("../src/main/main.ts", import.meta.url), "utf8"); + + expect(revealBlock).toContain("window.rd.getArchivePasswordList()"); + expect(revealBlock).toContain('settingsSubTab !== "extract"'); + expect(appSource).toContain("setSettingsDraft((current) => createSettingsDraft(state.settings, current))"); + expect(appSource).toContain("setSettingsDraft((current) => createSettingsDraft(next.settings, current))"); + expect(applyBlock).toContain("setSettingsDraft((current) => createSettingsDraft(result, preserveWriteOnlyValues ? current : undefined))"); + expect(mainSource).toContain("handleTrusted(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST"); + }); + + it("persists an edited archive password list before unrelated account mutations", () => { + const editBlock = sourceBlock(appSource, "const onSaveAccountEditDialog", "const onSaveAccountDialog"); + const createBlock = sourceBlock(appSource, "const onSaveAccountDialog", "const onResetAccountDailyUsage"); + const deleteKeyBlock = sourceBlock(appSource, "const onRemoveDebridLinkKey", "const onToggleAccountEnabled"); + const deleteRowsBlock = sourceBlock(appSource, "const removeAccountTableRows", "const checkAccountsActive"); + + expect(editBlock.indexOf("await persistDraftSettings()")).toBeLessThan(editBlock.indexOf("window.rd.replaceAccount")); + expect(createBlock.indexOf("await persistDraftSettings()")).toBeLessThan(createBlock.indexOf("window.rd.createAccount")); + expect(deleteKeyBlock.indexOf("await persistDraftSettings()")).toBeLessThan(deleteKeyBlock.indexOf("window.rd.deleteAccount")); + expect(deleteRowsBlock.indexOf("await persistDraftSettings()")).toBeLessThan(deleteRowsBlock.indexOf("window.rd.deleteAccount")); + }); + + it("invalidates an archive password reveal before applying imported settings", () => { + const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings"); + + expect(applyBlock).toContain("archivePasswordLoadGenerationRef.current += 1"); + expect(applyBlock.indexOf("archivePasswordLoadGenerationRef.current += 1")).toBeLessThan(applyBlock.indexOf("setSettingsDraft")); + }); + it("enables an account by clearing both its row-level and provider-level locks", () => { expect(buildScopedAccountEnabledState( ["debridlink", "linksnappy"], diff --git a/tests/visual-fixtures.test.ts b/tests/visual-fixtures.test.ts index 426f603..5ade9ec 100644 --- a/tests/visual-fixtures.test.ts +++ b/tests/visual-fixtures.test.ts @@ -213,6 +213,20 @@ describe("visual fixtures", () => { expect((await api.getSnapshot()).settings.animatePackageDisclosure).toBe(false); }); + it("keeps a configured archive password list stateful in the isolated renderer", async () => { + const dense = createVisualFixture("dense"); + const api = createVisualElectronApi(dense, "?archive-passwords=configured"); + + expect(await api.getArchivePasswordList()).toEqual({ + passwords: "visual-archive-password-one\nvisual-archive-password-two" + }); + expect((await api.getSnapshot()).settings.archivePasswordListConfigured).toBe(true); + + await api.updateSettings({ archivePasswordList: "updated-visual-password" }); + + expect(await api.getArchivePasswordList()).toEqual({ passwords: "updated-visual-password" }); + }); + it("boots the dense query once and waits for both visible package names", async () => { expect(typeof window).toBe("undefined"); const harness = createTestVisualBootstrap( diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index c1e10b8..58e7724 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -15,6 +15,10 @@ export function createVisualElectronApi( const searchParams = new URLSearchParams(search); const historyState = searchParams.get("history-state"); if (searchParams.get("animations") === "off") fixture.snapshot.settings.animatePackageDisclosure = false; + let archivePasswordList = searchParams.get("archive-passwords") === "configured" + ? "visual-archive-password-one\nvisual-archive-password-two" + : fixture.snapshot.settings.archivePasswordListConfigured ? "visual-archive-password" : ""; + fixture.snapshot.settings.archivePasswordListConfigured = archivePasswordList.length > 0; let historyRequestCount = 0; const stateUpdateListeners = new Set[0]>(); const emitStateUpdate = (): void => { @@ -23,6 +27,10 @@ export function createVisualElectronApi( }; const updateSettings = (settings: RendererSettingsUpdate): RendererSettings => { const { archivePasswordList: _archivePasswordList, notifyUrl: _notifyUrl, ...safe } = settings; + if (typeof _archivePasswordList === "string") { + archivePasswordList = _archivePasswordList; + fixture.snapshot.settings.archivePasswordListConfigured = archivePasswordList.trim().length > 0; + } Object.assign(fixture.snapshot.settings, safe); emitStateUpdate(); return clone(fixture.snapshot.settings); @@ -30,6 +38,7 @@ export function createVisualElectronApi( return { getSnapshot: async () => clone(fixture.snapshot), + getArchivePasswordList: async () => ({ passwords: archivePasswordList }), getVersion: async () => "2.0.12", checkUpdates: async () => clone(fixture.update), installUpdate: async () => ({ started: true, message: "Visual update gestartet" }),