fix(settings): restore archive password list
Load the stored archive password list through a dedicated trusted IPC channel when extraction settings are opened. Preserve write-only drafts across settings and account updates, guard unsaved edits against late responses, and invalidate stale loads during backup imports. Prepare version 2.0.46 with public release notes.
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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<DebridAccountStatus> {
|
||||
const redactions = collectAccountStatusRedactionValues(this.settings, input);
|
||||
if (input.kind === "realdebrid-api" || input.kind === "realdebrid-web") {
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
@@ -110,6 +110,7 @@ const api: ElectronApi = {
|
||||
checkDebridAccounts: (scope: AccountCheckScope = "active"): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, scope),
|
||||
checkAccountCredentials: (input: AccountCredentialCheckInput): Promise<DebridAccountStatus> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, input),
|
||||
revealAccountSecret: (input: AccountSecretRequest): Promise<AccountSecretResult> => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, input),
|
||||
getArchivePasswordList: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST),
|
||||
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
||||
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
||||
|
||||
+42
-8
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
AccountCredentialCheckInput,
|
||||
AccountSecretRequest,
|
||||
AccountSecretResult,
|
||||
ArchivePasswordListResult,
|
||||
AccountCreateCommand,
|
||||
AccountDeleteCommand,
|
||||
AccountReplaceCommand,
|
||||
@@ -136,6 +137,7 @@ export interface ElectronApi {
|
||||
checkDebridAccounts: (scope?: AccountCheckScope) => Promise<DebridAccountStatus[]>;
|
||||
checkAccountCredentials: (input: AccountCredentialCheckInput) => Promise<DebridAccountStatus>;
|
||||
revealAccountSecret: (input: AccountSecretRequest) => Promise<AccountSecretResult>;
|
||||
getArchivePasswordList: () => Promise<ArchivePasswordListResult>;
|
||||
retryExtraction: (packageId: string) => Promise<void>;
|
||||
extractNow: (packageId: string) => Promise<void>;
|
||||
resetPackage: (packageId: string) => Promise<void>;
|
||||
|
||||
+6
-2
@@ -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;
|
||||
|
||||
@@ -103,4 +103,19 @@ Backup-Passphrase=${echoedPassphrase}`;
|
||||
expect((controller as unknown as { manager: { applyDebridAccountStatuses: ReturnType<typeof vi.fn> } }).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<typeof vi.fn> }).audit).toHaveBeenCalledWith(
|
||||
"INFO",
|
||||
"Archiv-Passwortliste explizit angezeigt",
|
||||
{ entryCount: 2 }
|
||||
);
|
||||
expect(JSON.stringify((controller as unknown as { audit: ReturnType<typeof vi.fn> }).audit.mock.calls)).not.toContain(passwords);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Parameters<ElectronApi["onStateUpdate"]>[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" }),
|
||||
|
||||
Reference in New Issue
Block a user