From 5aeab9ecad7e7b329410c1f059fdfad21f5330d1 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Thu, 26 Mar 2026 19:34:48 +0100 Subject: [PATCH] Prevent queue loss during app updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase quit timeout from 900ms to 5000ms to ensure pending saves complete - Add persistNowSync() called before update install to flush queue to disk - Remove blockAllPersistence from shutdown save condition — shutdown must always persist to prevent data loss across restarts - Add temp file recovery as last resort when both primary and backup session files are corrupted Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/app-controller.ts | 849 ++++++++++++++++++----------------- src/main/download-manager.ts | 21 +- src/main/main.ts | 286 ++++++------ src/main/storage.ts | 20 +- 4 files changed, 606 insertions(+), 570 deletions(-) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index e1763b5..ad72241 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -24,26 +24,26 @@ import { APP_VERSION } from "./constants"; import { DownloadManager } from "./download-manager"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; import { parseCollectorInput } from "./link-parser"; -import { configureLogger, getLogFilePath, logger } from "./logger"; -import { AllDebridWebFallback } from "./all-debrid-web"; -import { BestDebridWebFallback } from "./bestdebrid-web"; -import { RealDebridWebFallback } from "./realdebrid-web"; -import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; -import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; -import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; +import { configureLogger, getLogFilePath, logger } from "./logger"; +import { AllDebridWebFallback } from "./all-debrid-web"; +import { BestDebridWebFallback } from "./bestdebrid-web"; +import { RealDebridWebFallback } from "./realdebrid-web"; +import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; +import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; +import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { MegaWebFallback } from "./mega-web-fallback"; -import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; -import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; -import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; -import { encryptBackup, decryptBackup } from "./backup-crypto"; -import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; -import { getDebugSetupCheck } from "./debug-setup"; -import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; -import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; -import { buildAccountSummary, diffAccountSummary } from "./support-data"; -import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; -import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; -import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; +import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; +import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; +import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; +import { encryptBackup, decryptBackup } from "./backup-crypto"; +import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; +import { getDebugSetupCheck } from "./debug-setup"; +import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; +import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; +import { buildAccountSummary, diffAccountSummary } from "./support-data"; +import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; +import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; +import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -73,22 +73,22 @@ export class AppController { private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); - private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; - - private autoResumePending = false; - private runtimeStatsTimer: NodeJS.Timeout | null = null; + private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; - public constructor() { - configureLogger(this.storagePaths.baseDir); - initSessionLog(this.storagePaths.baseDir); - initPackageLogs(this.storagePaths.baseDir); - initItemLogs(this.storagePaths.baseDir); - initAuditLog(this.storagePaths.baseDir); - initRenameLog(this.storagePaths.baseDir); - initTraceLog(this.storagePaths.baseDir); - this.settings = loadSettings(this.storagePaths); - resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - const session = loadSession(this.storagePaths); + private autoResumePending = false; + private runtimeStatsTimer: NodeJS.Timeout | null = null; + + public constructor() { + configureLogger(this.storagePaths.baseDir); + initSessionLog(this.storagePaths.baseDir); + initPackageLogs(this.storagePaths.baseDir); + initItemLogs(this.storagePaths.baseDir); + initAuditLog(this.storagePaths.baseDir); + initRenameLog(this.storagePaths.baseDir); + initTraceLog(this.storagePaths.baseDir); + this.settings = loadSettings(this.storagePaths); + resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + const session = loadSession(this.storagePaths); this.megaWebFallback = new MegaWebFallback(() => ({ login: this.settings.megaLogin, password: this.settings.megaPassword @@ -98,31 +98,31 @@ export class AppController { this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken); this.manager = new DownloadManager(this.settings, session, this.storagePaths, { megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal), - allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), - realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), - bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), - invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), - onHistoryEntry: (entry: HistoryEntry) => { - addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry); - } - }); + allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), + realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), + bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), + invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), + onHistoryEntry: (entry: HistoryEntry) => { + addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry); + } + }); this.manager.on("state", (snapshot: UiSnapshot) => { this.onStateHandler?.(snapshot); - }); - logger.info(`App gestartet v${APP_VERSION}`); - logger.info(`Log-Datei: ${getLogFilePath()}`); - logAuditEvent("INFO", "App gestartet", { - appVersion: APP_VERSION, - runtimeDir: this.storagePaths.baseDir - }); - startDebugServer(this.manager, this.storagePaths.baseDir); - this.runtimeStatsTimer = setInterval(() => { - this.manager.persistRuntimeStats(); - this.settings = this.manager.getSettings(); - }, 60_000); - this.runtimeStatsTimer.unref?.(); - - if (this.settings.autoResumeOnStart) { + }); + logger.info(`App gestartet v${APP_VERSION}`); + logger.info(`Log-Datei: ${getLogFilePath()}`); + logAuditEvent("INFO", "App gestartet", { + appVersion: APP_VERSION, + runtimeDir: this.storagePaths.baseDir + }); + startDebugServer(this.manager, this.storagePaths.baseDir); + this.runtimeStatsTimer = setInterval(() => { + this.manager.persistRuntimeStats(); + this.settings = this.manager.getSettings(); + }, 60_000); + this.runtimeStatsTimer.unref?.(); + + if (this.settings.autoResumeOnStart) { const snapshot = this.manager.getSnapshot(); const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); if (hasPending) { @@ -187,46 +187,46 @@ export class AppController { return APP_VERSION; } - public getSettings(): AppSettings { - return this.settings; - } - - public getAuditLogPath(): string | null { - return getAuditLogPath(); - } - - public getRenameLogPath(): string | null { - return getRenameLogPath(); - } - - public getTraceLogPath(): string | null { - return getTraceLogPath(); - } - - public getTraceConfig(): SupportTraceConfig { - return getTraceConfig(); - } - - public rotateDebugToken(): { path: string; token: string } { - const rotated = rotateDebugToken(this.storagePaths.baseDir); - this.audit("WARN", "Debug-Token rotiert", { path: rotated.path }); - return rotated; - } - - public getDebugSetupCheck(): DebugSetupCheckResult { - return getDebugSetupCheck(this.storagePaths.baseDir); - } - - private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record): void { - logAuditEvent(level, message, fields); - logTraceEvent(level, "audit", message, fields); - } - - public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig { - const next = setTraceEnabled(enabled, note, durationMs); - this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note }); - return next; - } + public getSettings(): AppSettings { + return this.settings; + } + + public getAuditLogPath(): string | null { + return getAuditLogPath(); + } + + public getRenameLogPath(): string | null { + return getRenameLogPath(); + } + + public getTraceLogPath(): string | null { + return getTraceLogPath(); + } + + public getTraceConfig(): SupportTraceConfig { + return getTraceConfig(); + } + + public rotateDebugToken(): { path: string; token: string } { + const rotated = rotateDebugToken(this.storagePaths.baseDir); + this.audit("WARN", "Debug-Token rotiert", { path: rotated.path }); + return rotated; + } + + public getDebugSetupCheck(): DebugSetupCheckResult { + return getDebugSetupCheck(this.storagePaths.baseDir); + } + + private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record): void { + logAuditEvent(level, message, fields); + logTraceEvent(level, "audit", message, fields); + } + + public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig { + const next = setTraceEnabled(enabled, note, durationMs); + this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note }); + return next; + } public updateSettings(partial: Partial): AppSettings { const sanitizedPatch = sanitizeSettingsPatch(partial); @@ -240,32 +240,32 @@ export class AppController { return previousSettings; } - // Preserve the live all-time counters from the download manager - const liveSettings = this.manager.getSettings(); - nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); - nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0); - nextSettings.totalRuntimeAllTimeMs = Math.max(nextSettings.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs()); - nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay; - nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) }; - nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) }; - nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries( - Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId)) - ); - nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries( - Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId)) - ); - const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; - this.settings = nextSettings; - if (retentionChanged) { - resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - } - saveSettings(this.storagePaths, this.settings); - this.manager.setSettings(this.settings); - this.audit("INFO", "Einstellungen aktualisiert", { - changedKeys: Object.keys(sanitizedPatch), - accountChanges: diffAccountSummary(previousSettings, this.settings) - }); - if (previousSettings.rememberToken && !this.settings.rememberToken) { + // Preserve the live all-time counters from the download manager + const liveSettings = this.manager.getSettings(); + nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); + nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0); + nextSettings.totalRuntimeAllTimeMs = Math.max(nextSettings.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs()); + nextSettings.providerDailyUsageDay = liveSettings.providerDailyUsageDay; + nextSettings.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) }; + nextSettings.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) }; + nextSettings.debridLinkApiKeyDailyUsageBytes = Object.fromEntries( + Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId)) + ); + nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries( + Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId)) + ); + const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; + this.settings = nextSettings; + if (retentionChanged) { + resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + } + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings); + this.audit("INFO", "Einstellungen aktualisiert", { + changedKeys: Object.keys(sanitizedPatch), + accountChanges: diffAccountSummary(previousSettings, this.settings) + }); + if (previousSettings.rememberToken && !this.settings.rememberToken) { void this.realDebridWebFallback.clearSessions().catch((error) => { logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); }); @@ -285,12 +285,12 @@ export class AppController { ...liveSettings, ...resetProviderDailyUsage(liveSettings, provider) }); - this.settings = nextSettings; - saveSettings(this.storagePaths, this.settings); - this.manager.setSettings(this.settings); - this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider }); - return this.settings; - } + this.settings = nextSettings; + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings); + this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider }); + return this.settings; + } public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings { const liveSettings = this.manager.getSettings(); @@ -298,31 +298,31 @@ export class AppController { ...liveSettings, ...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId) }); - this.settings = nextSettings; - saveSettings(this.storagePaths, this.settings); - this.manager.setSettings(this.settings); - this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId }); - return this.settings; - } - - public async openRealDebridLoginWindow(): Promise { - this.audit("INFO", "Real-Debrid Login-Fenster geöffnet"); - await this.realDebridWebFallback.openLoginWindow(); - } - - public async openAllDebridLoginWindow(): Promise { - this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); - await this.allDebridWebFallback.openLoginWindow(); - } - - public async importBestDebridCookies(filePath: string): Promise { - const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath); - this.audit("INFO", "BestDebrid Cookies importiert", { - filePath, - imported - }); - return imported; - } + this.settings = nextSettings; + saveSettings(this.storagePaths, this.settings); + this.manager.setSettings(this.settings); + this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId }); + return this.settings; + } + + public async openRealDebridLoginWindow(): Promise { + this.audit("INFO", "Real-Debrid Login-Fenster geöffnet"); + await this.realDebridWebFallback.openLoginWindow(); + } + + public async openAllDebridLoginWindow(): Promise { + this.audit("INFO", "AllDebrid Login-Fenster geöffnet"); + await this.allDebridWebFallback.openLoginWindow(); + } + + public async importBestDebridCookies(filePath: string): Promise { + const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath); + this.audit("INFO", "BestDebrid Cookies importiert", { + filePath, + imported + }); + return imported; + } public async getAllDebridHostInfo(host = "rapidgator"): Promise { if (this.settings.allDebridUseWebLogin) { @@ -354,6 +354,9 @@ export class AppController { if (this.manager.isSessionRunning()) { this.manager.stop(); } + // Flush any pending async saves BEFORE the update process starts. + // This ensures the queue is fully persisted to disk so it survives the restart. + this.manager.persistNowSync(); const cacheAgeMs = Date.now() - this.lastUpdateCheckAt; const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000 @@ -367,38 +370,38 @@ export class AppController { return result; } - public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } { - const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName); - if (parsed.length === 0) { - this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", { - hasPackageName: Boolean(payload.packageName) - }); - return { addedPackages: 0, addedLinks: 0, invalidCount: 1 }; - } - const result = this.manager.addPackages(parsed); - this.audit("INFO", "Links hinzugefügt", { - addedPackages: result.addedPackages, - addedLinks: result.addedLinks, - requestedPackages: parsed.length - }); - return { ...result, invalidCount: 0 }; - } + public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } { + const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName); + if (parsed.length === 0) { + this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", { + hasPackageName: Boolean(payload.packageName) + }); + return { addedPackages: 0, addedLinks: 0, invalidCount: 1 }; + } + const result = this.manager.addPackages(parsed); + this.audit("INFO", "Links hinzugefügt", { + addedPackages: result.addedPackages, + addedLinks: result.addedLinks, + requestedPackages: parsed.length + }); + return { ...result, invalidCount: 0 }; + } public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> { const packages = await importDlcContainers(filePaths); - const merged: ParsedPackageInput[] = packages.map((pkg) => ({ - name: pkg.name, - links: pkg.links, - ...(pkg.fileNames ? { fileNames: pkg.fileNames } : {}) - })); - const result = this.manager.addPackages(merged); - this.audit("INFO", "Container importiert", { - files: filePaths.length, - addedPackages: result.addedPackages, - addedLinks: result.addedLinks - }); - return result; - } + const merged: ParsedPackageInput[] = packages.map((pkg) => ({ + name: pkg.name, + links: pkg.links, + ...(pkg.fileNames ? { fileNames: pkg.fileNames } : {}) + })); + const result = this.manager.addPackages(merged); + this.audit("INFO", "Container importiert", { + files: filePaths.length, + addedPackages: result.addedPackages, + addedLinks: result.addedLinks + }); + return result; + } public async getStartConflicts(): Promise { return this.manager.getStartConflicts(); @@ -408,163 +411,163 @@ export class AppController { return this.manager.resolveStartConflict(packageId, policy); } - public clearAll(): void { - this.audit("WARN", "Queue komplett geleert"); - this.manager.clearAll(); - } - - public async start(): Promise { - this.audit("INFO", "Session-Start ausgelöst"); - await this.manager.start(); - } - - public async startPackages(packageIds: string[]): Promise { - this.audit("INFO", "Paket-Start ausgelöst", { packageIds }); - await this.manager.startPackages(packageIds); - } - - public async startItems(itemIds: string[]): Promise { - this.audit("INFO", "Item-Start ausgelöst", { itemIds }); - await this.manager.startItems(itemIds); - } - - public stop(): void { - this.audit("INFO", "Session-Stopp ausgelöst"); - this.manager.stop(); - } - - public togglePause(): boolean { - const paused = this.manager.togglePause(); - this.audit("INFO", "Pause umgeschaltet", { paused }); - return paused; - } - - public retryExtraction(packageId: string): void { - this.audit("INFO", "Extraktion manuell wiederholt", { packageId }); - this.manager.retryExtraction(packageId); - } - - public extractNow(packageId: string): void { - this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId }); - this.manager.extractNow(packageId); - } - - public resetPackage(packageId: string): void { - this.audit("INFO", "Paket zurückgesetzt", { packageId }); - this.manager.resetPackage(packageId); - } - - public cancelPackage(packageId: string): void { - this.audit("WARN", "Paket abgebrochen", { packageId }); - this.manager.cancelPackage(packageId); - } - - public renamePackage(packageId: string, newName: string): void { - this.audit("INFO", "Paket umbenannt", { packageId, newName }); - this.manager.renamePackage(packageId, newName); - } - - public reorderPackages(packageIds: string[]): void { - this.audit("INFO", "Paketreihenfolge geändert", { packageIds }); - this.manager.reorderPackages(packageIds); - } - - public removeItem(itemId: string): void { - this.audit("WARN", "Item entfernt", { itemId }); - this.manager.removeItem(itemId); - } + public clearAll(): void { + this.audit("WARN", "Queue komplett geleert"); + this.manager.clearAll(); + } - public togglePackage(packageId: string): void { - this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId }); - this.manager.togglePackage(packageId); - } - - public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } { - const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []); - this.audit("INFO", "Paket-Auswahl exportiert", { - packageCount: selection.packageCount, - linkCount: selection.linkCount, - packageIds - }); - return { - text: serializeLinkExportText(selection.packages), - defaultFileName: selection.defaultFileName, - packageCount: selection.packageCount, - linkCount: selection.linkCount - }; - } - - public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } { - const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds); - this.audit("INFO", "Item-Auswahl exportiert", { - packageCount: selection.packageCount, - linkCount: selection.linkCount, - itemIds - }); - return { - text: serializeLinkExportText(selection.packages), - defaultFileName: selection.defaultFileName, - packageCount: selection.packageCount, - linkCount: selection.linkCount - }; - } - - public exportQueue(): string { - return this.manager.exportQueue(); - } - - public importQueue(json: string): { addedPackages: number; addedLinks: number } { - const result = this.manager.importQueue(json); - this.audit("INFO", "Import-Datei verarbeitet", result); - return result; - } + public async start(): Promise { + this.audit("INFO", "Session-Start ausgelöst"); + await this.manager.start(); + } - public getSessionStats(): SessionStats { - return this.manager.getSessionStats(); - } - - public resetSessionStats(): void { - this.audit("INFO", "Session-Statistik zurückgesetzt"); - this.manager.resetSessionStats(); - } - - public resetDownloadStats(): void { - this.manager.resetDownloadStats(); - this.settings = this.manager.getSettings(); - this.audit("INFO", "Download-Statistik zurückgesetzt"); - } + public async startPackages(packageIds: string[]): Promise { + this.audit("INFO", "Paket-Start ausgelöst", { packageIds }); + await this.manager.startPackages(packageIds); + } - public exportBackup(): Buffer { - const settings = { ...this.settings }; - const session = this.manager.getSession(); - const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + public async startItems(itemIds: string[]): Promise { + this.audit("INFO", "Item-Start ausgelöst", { itemIds }); + await this.manager.startItems(itemIds); + } + + public stop(): void { + this.audit("INFO", "Session-Stopp ausgelöst"); + this.manager.stop(); + } + + public togglePause(): boolean { + const paused = this.manager.togglePause(); + this.audit("INFO", "Pause umgeschaltet", { paused }); + return paused; + } + + public retryExtraction(packageId: string): void { + this.audit("INFO", "Extraktion manuell wiederholt", { packageId }); + this.manager.retryExtraction(packageId); + } + + public extractNow(packageId: string): void { + this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId }); + this.manager.extractNow(packageId); + } + + public resetPackage(packageId: string): void { + this.audit("INFO", "Paket zurückgesetzt", { packageId }); + this.manager.resetPackage(packageId); + } + + public cancelPackage(packageId: string): void { + this.audit("WARN", "Paket abgebrochen", { packageId }); + this.manager.cancelPackage(packageId); + } + + public renamePackage(packageId: string, newName: string): void { + this.audit("INFO", "Paket umbenannt", { packageId, newName }); + this.manager.renamePackage(packageId, newName); + } + + public reorderPackages(packageIds: string[]): void { + this.audit("INFO", "Paketreihenfolge geändert", { packageIds }); + this.manager.reorderPackages(packageIds); + } + + public removeItem(itemId: string): void { + this.audit("WARN", "Item entfernt", { itemId }); + this.manager.removeItem(itemId); + } + + public togglePackage(packageId: string): void { + this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId }); + this.manager.togglePackage(packageId); + } + + public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } { + const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []); + this.audit("INFO", "Paket-Auswahl exportiert", { + packageCount: selection.packageCount, + linkCount: selection.linkCount, + packageIds + }); + return { + text: serializeLinkExportText(selection.packages), + defaultFileName: selection.defaultFileName, + packageCount: selection.packageCount, + linkCount: selection.linkCount + }; + } + + public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } { + const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds); + this.audit("INFO", "Item-Auswahl exportiert", { + packageCount: selection.packageCount, + linkCount: selection.linkCount, + itemIds + }); + return { + text: serializeLinkExportText(selection.packages), + defaultFileName: selection.defaultFileName, + packageCount: selection.packageCount, + linkCount: selection.linkCount + }; + } + + public exportQueue(): string { + return this.manager.exportQueue(); + } + + public importQueue(json: string): { addedPackages: number; addedLinks: number } { + const result = this.manager.importQueue(json); + this.audit("INFO", "Import-Datei verarbeitet", result); + return result; + } + + public getSessionStats(): SessionStats { + return this.manager.getSessionStats(); + } + + public resetSessionStats(): void { + this.audit("INFO", "Session-Statistik zurückgesetzt"); + this.manager.resetSessionStats(); + } + + public resetDownloadStats(): void { + this.manager.resetDownloadStats(); + this.settings = this.manager.getSettings(); + this.audit("INFO", "Download-Statistik zurückgesetzt"); + } + + public exportBackup(): Buffer { + const settings = { ...this.settings }; + const session = this.manager.getSession(); + const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); const payload = JSON.stringify({ version: 2, appVersion: APP_VERSION, exportedAt: new Date().toISOString(), - settings, - session, - history - }); - this.audit("INFO", "Backup exportiert", { - historyEntries: history.length, - sessionItems: Object.keys(session.items).length, - sessionPackages: Object.keys(session.packages).length - }); - return encryptBackup(payload); - } - - public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } { - this.audit("INFO", "Support-Bundle exportiert"); - logTraceEvent("INFO", "support", "Support-Bundle erstellt", { - packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, - itemCount: Object.keys(this.manager.getSnapshot().session.items).length - }); - return { - buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir), - defaultFileName: getSupportBundleDefaultFileName() - }; - } + settings, + session, + history + }); + this.audit("INFO", "Backup exportiert", { + historyEntries: history.length, + sessionItems: Object.keys(session.items).length, + sessionPackages: Object.keys(session.packages).length + }); + return encryptBackup(payload); + } + + public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } { + this.audit("INFO", "Support-Bundle exportiert"); + logTraceEvent("INFO", "support", "Support-Bundle erstellt", { + packageCount: Object.keys(this.manager.getSnapshot().session.packages).length, + itemCount: Object.keys(this.manager.getSnapshot().session.items).length + }); + return { + buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir), + defaultFileName: getSupportBundleDefaultFileName() + }; + } public importBackup(data: Buffer): { restored: boolean; message: string } { let parsed: Record; @@ -586,21 +589,21 @@ export class AppController { } // Restore settings — ALL credentials are included (no more masking) - const importedSettings = parsed.settings as AppSettings; - const importedSettingsRecord = importedSettings as unknown as Record; - const currentSettingsRecord = this.settings as unknown as Record; + const importedSettings = parsed.settings as AppSettings; + const importedSettingsRecord = importedSettings as unknown as Record; + const currentSettingsRecord = this.settings as unknown as Record; // Legacy backup compatibility: if credentials were masked with ***, keep current values const SENSITIVE_KEYS: (keyof AppSettings)[] = [ "token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey", "debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword" ]; - for (const key of SENSITIVE_KEYS) { - const val = importedSettingsRecord[key]; - if (typeof val === "string" && val.startsWith("***")) { - importedSettingsRecord[key] = currentSettingsRecord[key]; - } - } + for (const key of SENSITIVE_KEYS) { + const val = importedSettingsRecord[key]; + if (typeof val === "string" && val.startsWith("***")) { + importedSettingsRecord[key] = currentSettingsRecord[key]; + } + } const restoredSettings = normalizeSettings(importedSettings); this.settings = restoredSettings; saveSettings(this.storagePaths, this.settings); @@ -629,93 +632,93 @@ export class AppController { } } - resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - - // Prevent prepareForShutdown from overwriting the restored data + resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + + // Prevent prepareForShutdown from overwriting the restored data this.manager.skipShutdownPersist = true; this.manager.blockAllPersistence = true; - logger.info("Backup wiederhergestellt (verschlüsseltes Format)"); - this.audit("WARN", "Backup importiert", { - historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, - accountSummary: buildAccountSummary(this.settings) - }); - return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; - } + logger.info("Backup wiederhergestellt (verschlüsseltes Format)"); + this.audit("WARN", "Backup importiert", { + historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, + accountSummary: buildAccountSummary(this.settings) + }); + return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; + } - public getSessionLogPath(): string | null { - return getSessionLogPath(); - } - - public getPackageLogPath(packageId: string): string | null { - return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId); - } - - public getItemLogPath(itemId: string): string | null { - return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId); - } - - public shutdown(): void { - if (this.runtimeStatsTimer) { - clearInterval(this.runtimeStatsTimer); - this.runtimeStatsTimer = null; - } - stopDebugServer(); + public getSessionLogPath(): string | null { + return getSessionLogPath(); + } + + public getPackageLogPath(packageId: string): string | null { + return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId); + } + + public getItemLogPath(itemId: string): string | null { + return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId); + } + + public shutdown(): void { + if (this.runtimeStatsTimer) { + clearInterval(this.runtimeStatsTimer); + this.runtimeStatsTimer = null; + } + stopDebugServer(); abortActiveUpdateDownload(); this.manager.prepareForShutdown(); this.megaWebFallback.dispose(); this.realDebridWebFallback.dispose(); - this.allDebridWebFallback.dispose(); - this.bestDebridWebFallback.dispose(); - shutdownSessionLog(); - shutdownPackageLogs(); - shutdownItemLogs(); - shutdownRenameLog(); - this.audit("INFO", "App beendet"); - shutdownTraceLog(); - shutdownAuditLog(); - if (this.settings.historyRetentionMode === "session") { - clearHistory(this.storagePaths); - } - logger.info("App beendet"); - } - - public getHistory(): HistoryEntry[] { - return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - } + this.allDebridWebFallback.dispose(); + this.bestDebridWebFallback.dispose(); + shutdownSessionLog(); + shutdownPackageLogs(); + shutdownItemLogs(); + shutdownRenameLog(); + this.audit("INFO", "App beendet"); + shutdownTraceLog(); + shutdownAuditLog(); + if (this.settings.historyRetentionMode === "session") { + clearHistory(this.storagePaths); + } + logger.info("App beendet"); + } - public clearHistory(): void { - this.audit("WARN", "Verlauf geleert"); - clearHistory(this.storagePaths); - } - - public setPackagePriority(packageId: string, priority: PackagePriority): void { - this.audit("INFO", "Paket-Priorität geändert", { packageId, priority }); - this.manager.setPackagePriority(packageId, priority); - } - - public skipItems(itemIds: string[]): void { - this.audit("INFO", "Items übersprungen", { itemIds }); - this.manager.skipItems(itemIds); - } - - public resetItems(itemIds: string[]): void { - this.audit("INFO", "Items zurückgesetzt", { itemIds }); - this.manager.resetItems(itemIds); - } - - public removeHistoryEntry(entryId: string): void { - this.audit("INFO", "Verlaufseintrag entfernt", { entryId }); - removeHistoryEntry(this.storagePaths, entryId); - } - - public addToHistory(entry: HistoryEntry): void { - this.audit("INFO", "Verlaufseintrag hinzugefügt", { - id: entry.id, - name: entry.name, - status: entry.status, - provider: entry.provider, - fileCount: entry.fileCount - }); - addHistoryEntry(this.storagePaths, entry); - } + public getHistory(): HistoryEntry[] { + return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + } + + public clearHistory(): void { + this.audit("WARN", "Verlauf geleert"); + clearHistory(this.storagePaths); + } + + public setPackagePriority(packageId: string, priority: PackagePriority): void { + this.audit("INFO", "Paket-Priorität geändert", { packageId, priority }); + this.manager.setPackagePriority(packageId, priority); + } + + public skipItems(itemIds: string[]): void { + this.audit("INFO", "Items übersprungen", { itemIds }); + this.manager.skipItems(itemIds); + } + + public resetItems(itemIds: string[]): void { + this.audit("INFO", "Items zurückgesetzt", { itemIds }); + this.manager.resetItems(itemIds); + } + + public removeHistoryEntry(entryId: string): void { + this.audit("INFO", "Verlaufseintrag entfernt", { entryId }); + removeHistoryEntry(this.storagePaths, entryId); + } + + public addToHistory(entry: HistoryEntry): void { + this.audit("INFO", "Verlaufseintrag hinzugefügt", { + id: entry.id, + name: entry.name, + status: entry.status, + provider: entry.provider, + fileCount: entry.fileCount + }); + addHistoryEntry(this.storagePaths, entry); + } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 23b5303..7ed7853 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -4696,9 +4696,12 @@ export class DownloadManager extends EventEmitter { this.pacedStartReservationByItem.clear(); this.nonResumableActive = 0; this.session.summaryText = ""; - // Persist synchronously on shutdown to guarantee data is written before process exits - // Skip if a backup was just imported — the restored session on disk must not be overwritten - if (!this.skipShutdownPersist && !this.blockAllPersistence) { + // Persist synchronously on shutdown to guarantee data is written before process exits. + // Only skip if a backup was just imported (skipShutdownPersist) — the restored session + // on disk must not be overwritten. blockAllPersistence is intentionally NOT checked + // here: it guards async/periodic saves during runtime, but shutdown must always persist + // to prevent queue loss across restarts/updates. + if (!this.skipShutdownPersist) { const pkgCount = Object.keys(this.session.packages).length; const itemCount = Object.keys(this.session.items).length; logger.info(`Shutdown-Save: ${pkgCount} Pakete, ${itemCount} Items`); @@ -5030,6 +5033,18 @@ export class DownloadManager extends EventEmitter { } } + /** Synchronous persist — guarantees state is on disk before returning. + * Used before update installs to prevent queue loss. */ + public persistNowSync(): void { + this.clearPersistTimer(); + const pkgCount = Object.keys(this.session.packages).length; + const itemCount = Object.keys(this.session.items).length; + logger.info(`Pre-Update Sync-Save: ${pkgCount} Pakete, ${itemCount} Items`); + this.foldRuntimeIntoSettings(nowMs()); + saveSession(this.storagePaths, this.session); + saveSettings(this.storagePaths, this.settings); + } + private emitState(force = false): void { const now = nowMs(); const MIN_FORCE_GAP_MS = 120; diff --git a/src/main/main.ts b/src/main/main.ts index cf97bbe..9a23ea4 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -257,7 +257,7 @@ function registerIpcHandlers(): void { if (result.started) { updateQuitTimer = setTimeout(() => { app.quit(); - }, 900); + }, 5000); } return result; }); @@ -379,48 +379,48 @@ function registerIpcHandlers(): void { validateString(itemId, "itemId"); return controller.removeItem(itemId); }); - ipcMain.handle(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { - validateString(packageId, "packageId"); - return controller.togglePackage(packageId); - }); - ipcMain.handle(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => { - const validPackageIds = validateStringArray(packageIds ?? [], "packageIds"); - const exported = controller.exportPackageSelection(validPackageIds); - if (exported.packageCount === 0 || exported.linkCount === 0) { - return { saved: false, packageCount: 0, linkCount: 0 }; - } - const options = { - defaultPath: exported.defaultFileName, - filters: [{ name: "Link Export", extensions: ["txt"] }] - }; - const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); - if (result.canceled || !result.filePath) { - return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount }; - } - await fs.promises.writeFile(result.filePath, exported.text, "utf8"); - return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; - }); - ipcMain.handle(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => { - const validItemIds = validateStringArray(itemIds ?? [], "itemIds"); - const exported = controller.exportItemSelection(validItemIds); - if (exported.packageCount === 0 || exported.linkCount === 0) { - return { saved: false, packageCount: 0, linkCount: 0 }; - } - const options = { - defaultPath: exported.defaultFileName, - filters: [{ name: "Link Export", extensions: ["txt"] }] - }; - const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); - if (result.canceled || !result.filePath) { - return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount }; - } - await fs.promises.writeFile(result.filePath, exported.text, "utf8"); - return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; - }); - ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => { - validateString(packageId, "packageId"); - return controller.retryExtraction(packageId); - }); + ipcMain.handle(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { + validateString(packageId, "packageId"); + return controller.togglePackage(packageId); + }); + ipcMain.handle(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => { + const validPackageIds = validateStringArray(packageIds ?? [], "packageIds"); + const exported = controller.exportPackageSelection(validPackageIds); + if (exported.packageCount === 0 || exported.linkCount === 0) { + return { saved: false, packageCount: 0, linkCount: 0 }; + } + const options = { + defaultPath: exported.defaultFileName, + filters: [{ name: "Link Export", extensions: ["txt"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount }; + } + await fs.promises.writeFile(result.filePath, exported.text, "utf8"); + return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; + }); + ipcMain.handle(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => { + const validItemIds = validateStringArray(itemIds ?? [], "itemIds"); + const exported = controller.exportItemSelection(validItemIds); + if (exported.packageCount === 0 || exported.linkCount === 0) { + return { saved: false, packageCount: 0, linkCount: 0 }; + } + const options = { + defaultPath: exported.defaultFileName, + filters: [{ name: "Link Export", extensions: ["txt"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount }; + } + await fs.promises.writeFile(result.filePath, exported.text, "utf8"); + return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; + }); + ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => { + validateString(packageId, "packageId"); + return controller.retryExtraction(packageId); + }); ipcMain.handle(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.extractNow(packageId); @@ -496,12 +496,12 @@ function registerIpcHandlers(): void { }; const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); return result.canceled ? [] : result.filePaths; - }); - ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); - ipcMain.handle(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats()); - ipcMain.handle(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats()); - - ipcMain.handle(IPC_CHANNELS.RESTART, () => { + }); + ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); + ipcMain.handle(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats()); + ipcMain.handle(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats()); + + ipcMain.handle(IPC_CHANNELS.RESTART, () => { app.relaunch(); app.quit(); }); @@ -510,106 +510,106 @@ function registerIpcHandlers(): void { app.quit(); }); - ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { - const options = { - defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`, - filters: [{ name: "MDD Backup", extensions: ["mdd"] }] + ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => { + const options = { + defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`, + filters: [{ name: "MDD Backup", extensions: ["mdd"] }] }; const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); if (result.canceled || !result.filePath) { return { saved: false }; } const encrypted = controller.exportBackup(); - await fs.promises.writeFile(result.filePath, encrypted); - return { saved: true }; - }); - - ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { - const exported = controller.exportSupportBundle(); - const options = { - defaultPath: exported.defaultFileName, - filters: [{ name: "Support Bundle", extensions: ["zip"] }] - }; - const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); - if (result.canceled || !result.filePath) { - return { saved: false }; - } - await fs.promises.writeFile(result.filePath, exported.buffer); - return { saved: true, filePath: result.filePath }; - }); - - ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => { - const logPath = getLogFilePath(); - await shell.openPath(logPath); - }); - - ipcMain.handle(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => { - const logPath = controller.getAuditLogPath(); - if (logPath) { - await shell.openPath(logPath); - } - }); - - ipcMain.handle(IPC_CHANNELS.OPEN_RENAME_LOG, async () => { - const logPath = controller.getRenameLogPath(); - if (logPath) { - await shell.openPath(logPath); - } - }); - - ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => { - const logPath = controller.getSessionLogPath(); - if (logPath) { - await shell.openPath(logPath); - } - }); - - ipcMain.handle(IPC_CHANNELS.OPEN_TRACE_LOG, async () => { - const logPath = controller.getTraceLogPath(); - if (logPath) { - await shell.openPath(logPath); - } - }); - - ipcMain.handle(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => { - validateString(packageId, "packageId"); - const logPath = controller.getPackageLogPath(packageId); - if (logPath) { - await shell.openPath(logPath); - } - }); - - ipcMain.handle(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck()); - - ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig()); - - ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => { - if (typeof enabled !== "boolean") { - throw new Error("enabled muss ein Boolean sein"); - } - if (note !== undefined) { - validateString(note, "note"); - } - if (durationMinutes !== undefined && (!Number.isFinite(durationMinutes) || durationMinutes <= 0)) { - throw new Error("durationMinutes muss eine positive Zahl sein"); - } - return controller.setTraceEnabled(enabled, note, durationMinutes ? durationMinutes * 60 * 1000 : undefined); - }); - - ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => { - const rotated = controller.rotateDebugToken(); - return { path: rotated.path }; - }); - - ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { - validateString(itemId, "itemId"); - const logPath = controller.getItemLogPath(itemId); - if (logPath) { - await shell.openPath(logPath); - } - }); - - ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => { + await fs.promises.writeFile(result.filePath, encrypted); + return { saved: true }; + }); + + ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { + const exported = controller.exportSupportBundle(); + const options = { + defaultPath: exported.defaultFileName, + filters: [{ name: "Support Bundle", extensions: ["zip"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false }; + } + await fs.promises.writeFile(result.filePath, exported.buffer); + return { saved: true, filePath: result.filePath }; + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => { + const logPath = getLogFilePath(); + await shell.openPath(logPath); + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => { + const logPath = controller.getAuditLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_RENAME_LOG, async () => { + const logPath = controller.getRenameLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => { + const logPath = controller.getSessionLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_TRACE_LOG, async () => { + const logPath = controller.getTraceLogPath(); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => { + validateString(packageId, "packageId"); + const logPath = controller.getPackageLogPath(packageId); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck()); + + ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig()); + + ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => { + if (typeof enabled !== "boolean") { + throw new Error("enabled muss ein Boolean sein"); + } + if (note !== undefined) { + validateString(note, "note"); + } + if (durationMinutes !== undefined && (!Number.isFinite(durationMinutes) || durationMinutes <= 0)) { + throw new Error("durationMinutes muss eine positive Zahl sein"); + } + return controller.setTraceEnabled(enabled, note, durationMinutes ? durationMinutes * 60 * 1000 : undefined); + }); + + ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => { + const rotated = controller.rotateDebugToken(); + return { path: rotated.path }; + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { + validateString(itemId, "itemId"); + const logPath = controller.getItemLogPath(itemId); + if (logPath) { + await shell.openPath(logPath); + } + }); + + ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => { await controller.openRealDebridLoginWindow(); }); diff --git a/src/main/storage.ts b/src/main/storage.ts index 9dd72c3..a185857 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -881,7 +881,25 @@ export function loadSession(paths: StoragePaths): SessionState { return backup; } - logger.error("Session konnte nicht geladen werden (auch Backup fehlgeschlagen)"); + // Last resort: try to recover from temp files left by interrupted writes + for (const kind of ["sync", "async"] as const) { + const tmpPath = sessionTempPath(paths.sessionFile, kind); + if (fs.existsSync(tmpPath)) { + const tmpSession = readSessionFile(tmpPath); + if (tmpSession && Object.keys(tmpSession.packages).length > 0) { + logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`); + try { + const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }); + fs.writeFileSync(paths.sessionFile, payload, "utf8"); + } catch { + // ignore restore write failure + } + return tmpSession; + } + } + } + + logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)"); return emptySession(); }