From 56ff988a7ca188b0bd62e274235ba1e5c500bfc5 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 12 Aug 2026 11:59:16 +0200 Subject: [PATCH] fix(logs): make log storage location safe and configurable Adds an AppData/Desktop log storage selector with controlled migration of known log files only. Keeps runtime data and credentials in AppData, consolidates desktop rename logs into the chosen directory, and exposes the active log folder through the desktop UI.\n\nFlushes early recovery diagnostics into the runtime log before a location migration, preserves the active trace configuration as valid JSON during moves, and prevents backup imports from failing when the requested log location is unavailable. Adds regression coverage for desktop paths, legacy log migration, secret exclusion, and trace-config replacement. --- src/main/app-controller.ts | 179 +++++++++++++----- src/main/constants.ts | 7 +- src/main/desktop-rename-log.ts | 33 ++-- src/main/log-storage.ts | 170 +++++++++++++++++ src/main/logger.ts | 56 +++--- src/main/main.ts | 16 +- src/main/renderer-state.ts | 1 + src/main/storage.ts | 12 +- src/preload/preload.ts | 7 +- src/renderer/App.tsx | 9 +- src/renderer/views/settings/settings-model.ts | 14 +- src/shared/ipc.ts | 5 +- src/shared/preload-api.ts | 7 +- src/shared/types.ts | 9 +- tests/log-storage.test.ts | 92 +++++++++ tests/visual/fixtures.ts | 9 +- tests/visual/mock-electron-api.ts | 1 + 17 files changed, 513 insertions(+), 114 deletions(-) create mode 100644 src/main/log-storage.ts create mode 100644 tests/log-storage.test.ts diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 183c053..45d8ba9 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -38,7 +38,7 @@ import { applyAccountCommand } from "./account-commands"; import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer"; import { createRendererState } from "./renderer-state"; import { parseCollectorInput } from "./link-parser"; -import { configureLogger, getLogFilePath, logger } from "./logger"; +import { configureLogger, flushLoggerSync, getLogFilePath, logger } from "./logger"; import { AllDebridWebFallback } from "./all-debrid-web"; import { BestDebridWebFallback } from "./bestdebrid-web"; import { RealDebridWebFallback } from "./realdebrid-web"; @@ -60,13 +60,14 @@ import { runStartupHealthCheck } from "./startup-health-check"; import { getDebugSetupCheck } from "./debug-setup"; import { buildLinkExportSelection, serializeLinkExportText } from "./link-export"; import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log"; -import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog } from "./desktop-rename-log"; +import { getDesktopRenameLogPath, initDesktopRenameLogAt, shutdownDesktopRenameLog } from "./desktop-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 { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./online-backup"; import { overlayLiveUsageCounters } from "./settings-live-overlay"; +import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -94,33 +95,38 @@ export class AppController { private lastUpdateCheckAt = 0; - private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); + private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); + + private logDirectory = this.storagePaths.baseDir; private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; private autoResumePending = false; private runtimeStatsTimer: NodeJS.Timeout | null = null; - private lastMemoryWarnAt = 0; - - public constructor() { - configureLogger(this.storagePaths.baseDir); - initSessionLog(this.storagePaths.baseDir); - initPackageLogs(this.storagePaths.baseDir); - initItemLogs(this.storagePaths.baseDir); - initAuditLog(this.storagePaths.baseDir); - initAccountRotationLog(this.storagePaths.baseDir); - initConversionLog(this.storagePaths.baseDir); - initRenameLog(this.storagePaths.baseDir); - let desktopDir: string | null = null; - try { - desktopDir = app.getPath("desktop"); - } catch { - desktopDir = null; - } - initDesktopRenameLog(desktopDir); - initTraceLog(this.storagePaths.baseDir); - this.settings = loadSettings(this.storagePaths); - resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); + private lastMemoryWarnAt = 0; + + public constructor() { + configureLogger(this.storagePaths.baseDir); + this.settings = loadSettings(this.storagePaths); + flushLoggerSync(); + const desktopDir = this.getDesktopDirectory(); + const requestedLogDirectory = resolveLogDirectory(this.storagePaths.baseDir, desktopDir, this.settings.logStorageLocation); + if (!prepareLogDirectory(requestedLogDirectory)) { + this.settings = normalizeSettings({ ...this.settings, logStorageLocation: "appdata" }); + saveSettings(this.storagePaths, this.settings); + this.logDirectory = this.storagePaths.baseDir; + } else { + this.logDirectory = requestedLogDirectory; + const legacyDirectory = this.settings.logStorageLocation === "desktop" + ? getLegacyDesktopLogDirectory(desktopDir) + : null; + migrateLogDirectories( + [this.storagePaths.baseDir, ...(legacyDirectory ? [legacyDirectory] : [])], + this.logDirectory + ); + } + this.initializeLogStorage(); + resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); const loadResult = loadSessionWithStatus(this.storagePaths); const session = loadResult.session; this.megaWebFallback = new MegaWebFallback(() => ({ @@ -430,7 +436,14 @@ export class AppController { } private applySettingsOnlyBackup(importedSettings: AppSettings, remoteDiagnostics?: unknown, restoreRemoteDiagnostics = false): void { - const restoredSettings = normalizeSettings(importedSettings); + let restoredSettings = normalizeSettings(importedSettings); + if (this.settings.logStorageLocation !== restoredSettings.logStorageLocation + && !this.reconfigureLogStorage(restoredSettings.logStorageLocation)) { + restoredSettings = normalizeSettings({ + ...restoredSettings, + logStorageLocation: this.settings.logStorageLocation + }); + } this.overlayLiveUsageCounters(restoredSettings); this.settings = restoredSettings; saveSettings(this.storagePaths, this.settings); @@ -443,16 +456,23 @@ export class AppController { public updateSettings(partial: Partial): AppSettings { const sanitizedPatch = sanitizeSettingsPatch(partial); const previousSettings = this.settings; - const nextSettings = normalizeSettings({ - ...previousSettings, - ...sanitizedPatch - }); + let nextSettings = normalizeSettings({ + ...previousSettings, + ...sanitizedPatch + }); if (settingsFingerprint(nextSettings) === settingsFingerprint(previousSettings)) { return previousSettings; } - this.overlayLiveUsageCounters(nextSettings); + if (previousSettings.logStorageLocation !== nextSettings.logStorageLocation + && !this.reconfigureLogStorage(nextSettings.logStorageLocation)) { + nextSettings = normalizeSettings({ + ...nextSettings, + logStorageLocation: previousSettings.logStorageLocation + }); + } + this.overlayLiveUsageCounters(nextSettings); const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; const historyLimitsChanged = previousSettings.historyMaxEntries !== nextSettings.historyMaxEntries || previousSettings.historyMaxAgeDays !== nextSettings.historyMaxAgeDays; @@ -889,7 +909,7 @@ public async checkDebridAccounts(): Promise { importedSettingsRecord[key] = currentSettingsRecord[key]; } } - const restoredSettings = normalizeSettings(importedSettings); + let restoredSettings = normalizeSettings(importedSettings); // Settings-only backup: keep the running queue AND the live counters untouched. // Overlay the live usage/status counters so they don't roll back to the backup's @@ -909,8 +929,15 @@ public async checkDebridAccounts(): Promise { }; } - this.settings = restoredSettings; - saveSettings(this.storagePaths, this.settings); + if (this.settings.logStorageLocation !== restoredSettings.logStorageLocation + && !this.reconfigureLogStorage(restoredSettings.logStorageLocation)) { + restoredSettings = normalizeSettings({ + ...restoredSettings, + logStorageLocation: this.settings.logStorageLocation + }); + } + this.settings = restoredSettings; + saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); this.manager.stop(); @@ -947,9 +974,13 @@ public async checkDebridAccounts(): Promise { return { restored: true, relaunch: true, message: "Backup wiederhergestellt – App startet automatisch neu…" }; } - public getSessionLogPath(): string | null { - return getSessionLogPath(); - } + public getSessionLogPath(): string | null { + return getSessionLogPath(); + } + + public getLogDirectory(): string { + return this.logDirectory; + } public getPackageLogPath(packageId: string): string | null { return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId); @@ -959,7 +990,7 @@ public async checkDebridAccounts(): Promise { return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId); } - public shutdown(): void { + public shutdown(): void { if (this.runtimeStatsTimer) { clearInterval(this.runtimeStatsTimer); this.runtimeStatsTimer = null; @@ -972,21 +1003,73 @@ public async checkDebridAccounts(): Promise { this.realDebridWebFallback.dispose(); this.allDebridWebFallback.dispose(); this.bestDebridWebFallback.dispose(); - shutdownSessionLog(); - shutdownPackageLogs(); - shutdownItemLogs(); - shutdownRenameLog(); - shutdownDesktopRenameLog(); - this.audit("INFO", "App beendet"); - shutdownTraceLog(); - shutdownAccountRotationLog(); - shutdownConversionLog(); - shutdownAuditLog(); + this.shutdownLogStorage(); + this.audit("INFO", "App beendet"); + shutdownTraceLog(); + shutdownAccountRotationLog(); + shutdownConversionLog(); + shutdownAuditLog(); if (this.settings.historyRetentionMode === "session") { clearHistory(this.storagePaths); } logger.info("App beendet"); - } + } + + private getDesktopDirectory(): string | null { + try { + return app.getPath("desktop"); + } catch { + return null; + } + } + + private initializeLogStorage(): void { + configureLogger(this.logDirectory); + initSessionLog(this.logDirectory); + initPackageLogs(this.logDirectory); + initItemLogs(this.logDirectory); + initAuditLog(this.logDirectory); + initAccountRotationLog(this.logDirectory); + initConversionLog(this.logDirectory); + initRenameLog(this.logDirectory); + initDesktopRenameLogAt(this.logDirectory); + initTraceLog(this.logDirectory); + } + + private shutdownLogStorage(): void { + flushLoggerSync(); + shutdownSessionLog(); + shutdownPackageLogs(); + shutdownItemLogs(); + shutdownRenameLog(); + shutdownDesktopRenameLog(); + } + + private reconfigureLogStorage(location: AppSettings["logStorageLocation"]): boolean { + const nextDirectory = resolveLogDirectory(this.storagePaths.baseDir, this.getDesktopDirectory(), location); + if (nextDirectory === this.logDirectory) { + return true; + } + if (!prepareLogDirectory(nextDirectory)) { + return false; + } + this.shutdownLogStorage(); + shutdownTraceLog(); + shutdownAccountRotationLog(); + shutdownConversionLog(); + shutdownAuditLog(); + const legacyDirectory = location === "desktop" + ? getLegacyDesktopLogDirectory(this.getDesktopDirectory()) + : null; + migrateLogDirectories( + [this.logDirectory, ...(legacyDirectory ? [legacyDirectory] : [])], + nextDirectory + ); + this.logDirectory = nextDirectory; + this.initializeLogStorage(); + logger.info(`Log-Speicherort geändert: ${this.logDirectory}`); + return true; + } private historyLimits(): { maxEntries: number; maxAgeDays: number } { return { maxEntries: this.settings.historyMaxEntries, maxAgeDays: this.settings.historyMaxAgeDays }; diff --git a/src/main/constants.ts b/src/main/constants.ts index bfa7b97..8369a4f 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -101,9 +101,10 @@ export function defaultSettings(): AppSettings { updateRepo: DEFAULT_UPDATE_REPO, autoUpdateCheck: true, clipboardWatch: false, - minimizeToTray: false, - theme: "dark" as const, - collapseNewPackages: true, + minimizeToTray: false, + theme: "dark" as const, + logStorageLocation: "appdata", + collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, diff --git a/src/main/desktop-rename-log.ts b/src/main/desktop-rename-log.ts index da9cea1..32e2ffc 100644 --- a/src/main/desktop-rename-log.ts +++ b/src/main/desktop-rename-log.ts @@ -4,7 +4,7 @@ import { logTimestamp } from "./log-timestamp"; type DesktopRenameLevel = "INFO" | "WARN" | "ERROR"; -const FOLDER_NAME = "Downloader-Log"; +const LEGACY_FOLDER_NAME = "Downloader-Log"; let logDir: string | null = null; let logFilePath: string | null = null; @@ -58,15 +58,15 @@ function ensureWritable(): boolean { } } -export function initDesktopRenameLog(desktopDir: string | null | undefined): void { - try { - const base = String(desktopDir || "").trim(); - if (!base) { - logDir = null; - logFilePath = null; - return; - } - logDir = path.join(base, FOLDER_NAME); +function initializeRenameLog(directory: string | null | undefined): void { + try { + const base = String(directory || "").trim(); + if (!base) { + logDir = null; + logFilePath = null; + return; + } + logDir = path.resolve(base); logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`); sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n` + "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\n" @@ -77,8 +77,17 @@ export function initDesktopRenameLog(desktopDir: string | null | undefined): voi } catch { logDir = null; logFilePath = null; - } -} + } +} + +export function initDesktopRenameLog(desktopDir: string | null | undefined): void { + const base = String(desktopDir || "").trim(); + initializeRenameLog(base ? path.join(base, LEGACY_FOLDER_NAME) : null); +} + +export function initDesktopRenameLogAt(directory: string | null | undefined): void { + initializeRenameLog(directory); +} export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record): void { if (!ensureWritable() || !logFilePath) { diff --git a/src/main/log-storage.ts b/src/main/log-storage.ts new file mode 100644 index 0000000..f8ec822 --- /dev/null +++ b/src/main/log-storage.ts @@ -0,0 +1,170 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { LogStorageLocation } from "../shared/types"; + +export const DESKTOP_LOG_DIRECTORY_NAME = "Downloader Log"; +export const LEGACY_DESKTOP_LOG_DIRECTORY_NAME = "Downloader-Log"; + +const LOG_FILE_NAMES = new Set([ + "rd_downloader.log", + "audit.log", + "account-rotation.log", + "conversion.log", + "rename.log", + "trace.log", + "trace_config.json" +]); + +const LOG_DIRECTORY_NAMES = new Set(["session-logs", "package-logs", "item-logs"]); + +export interface LogMigrationResult { + copiedFiles: number; + skippedFiles: number; +} + +function samePath(left: string, right: string): boolean { + const normalizedLeft = path.resolve(left); + const normalizedRight = path.resolve(right); + return process.platform === "win32" + ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase() + : normalizedLeft === normalizedRight; +} + +function mergeTextFile(sourcePath: string, targetPath: string): boolean { + try { + const source = fs.readFileSync(sourcePath, "utf8"); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + if (!fs.existsSync(targetPath)) { + fs.writeFileSync(targetPath, source, "utf8"); + return true; + } + const target = fs.readFileSync(targetPath, "utf8"); + if (target === source || target.startsWith(source)) { + return false; + } + if (source.startsWith(target)) { + fs.appendFileSync(targetPath, source.slice(target.length), "utf8"); + return true; + } + const separator = target.endsWith("\n") ? "" : "\n"; + fs.appendFileSync(targetPath, `${separator}${source}`, "utf8"); + return true; + } catch { + return false; + } +} + +function copyTraceConfig(sourcePath: string, targetPath: string): boolean { + try { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + const source = fs.readFileSync(sourcePath, "utf8"); + if (fs.existsSync(targetPath) && fs.readFileSync(targetPath, "utf8") === source) { + return false; + } + fs.writeFileSync(targetPath, source, "utf8"); + return true; + } catch { + return false; + } +} + +function isRootLogFile(fileName: string): boolean { + const baseName = fileName.endsWith(".old") ? fileName.slice(0, -4) : fileName; + return LOG_FILE_NAMES.has(baseName) || /^rename-session_.*\.txt$/i.test(fileName); +} + +function copyAllowedEntries(sourceDirectory: string, targetDirectory: string, result: LogMigrationResult): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(sourceDirectory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const sourcePath = path.join(sourceDirectory, entry.name); + const targetPath = path.join(targetDirectory, entry.name); + if (entry.isDirectory()) { + if (!LOG_DIRECTORY_NAMES.has(entry.name)) { + continue; + } + fs.mkdirSync(targetPath, { recursive: true }); + copyDirectoryFiles(sourcePath, targetPath, result); + continue; + } + if (!entry.isFile() || !isRootLogFile(entry.name)) { + result.skippedFiles += 1; + continue; + } + const copied = entry.name === "trace_config.json" + ? copyTraceConfig(sourcePath, targetPath) + : mergeTextFile(sourcePath, targetPath); + if (copied) { + result.copiedFiles += 1; + } + } +} + +function copyDirectoryFiles(sourceDirectory: string, targetDirectory: string, result: LogMigrationResult): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(sourceDirectory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const sourcePath = path.join(sourceDirectory, entry.name); + const targetPath = path.join(targetDirectory, entry.name); + if (entry.isDirectory()) { + fs.mkdirSync(targetPath, { recursive: true }); + copyDirectoryFiles(sourcePath, targetPath, result); + continue; + } + if (!entry.isFile()) { + continue; + } + if (mergeTextFile(sourcePath, targetPath)) { + result.copiedFiles += 1; + } + } +} + +export function resolveLogDirectory( + runtimeDirectory: string, + desktopDirectory: string | null | undefined, + location: LogStorageLocation +): string { + const runtimePath = path.resolve(runtimeDirectory); + const desktopPath = String(desktopDirectory || "").trim(); + if (location === "desktop" && desktopPath) { + return path.resolve(desktopPath, DESKTOP_LOG_DIRECTORY_NAME); + } + return runtimePath; +} + +export function getLegacyDesktopLogDirectory(desktopDirectory: string | null | undefined): string | null { + const desktopPath = String(desktopDirectory || "").trim(); + return desktopPath ? path.resolve(desktopPath, LEGACY_DESKTOP_LOG_DIRECTORY_NAME) : null; +} + +export function prepareLogDirectory(directory: string): boolean { + try { + fs.mkdirSync(directory, { recursive: true }); + return fs.statSync(directory).isDirectory(); + } catch { + return false; + } +} + +export function migrateLogDirectories(sourceDirectories: readonly string[], targetDirectory: string): LogMigrationResult { + const result: LogMigrationResult = { copiedFiles: 0, skippedFiles: 0 }; + if (!prepareLogDirectory(targetDirectory)) { + return result; + } + for (const sourceDirectory of sourceDirectories) { + if (!sourceDirectory || samePath(sourceDirectory, targetDirectory)) { + continue; + } + copyAllowedEntries(sourceDirectory, targetDirectory, result); + } + return result; +} diff --git a/src/main/logger.ts b/src/main/logger.ts index c724bca..9aa26eb 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -57,11 +57,19 @@ export function removeLogListener(listener: LogListener): void { } } -export function configureLogger(baseDir: string): void { - logFilePath = path.join(baseDir, "rd_downloader.log"); - const cwdLogPath = path.resolve(process.cwd(), "rd_downloader.log"); - fallbackLogFilePath = cwdLogPath === logFilePath ? null : cwdLogPath; -} +export function configureLogger(baseDir: string): void { + logFilePath = path.join(baseDir, "rd_downloader.log"); + const cwdLogPath = path.resolve(process.cwd(), "rd_downloader.log"); + fallbackLogFilePath = cwdLogPath === logFilePath ? null : cwdLogPath; +} + +export function flushLoggerSync(): void { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + flushSyncPending(); +} function appendLine(filePath: string, line: string): { ok: boolean; errorText: string } { try { @@ -99,15 +107,15 @@ function flushSyncPending(): void { pendingLines = []; pendingChars = 0; - rotateIfNeeded(logFilePath); - const primary = appendLine(logFilePath, chunk); - if (fallbackLogFilePath) { - rotateIfNeeded(fallbackLogFilePath); - const fallback = appendLine(fallbackLogFilePath, chunk); - if (!primary.ok && !fallback.ok) { - writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); - } - return; + rotateIfNeeded(logFilePath); + const primary = appendLine(logFilePath, chunk); + if (!primary.ok && fallbackLogFilePath) { + rotateIfNeeded(fallbackLogFilePath); + const fallback = appendLine(fallbackLogFilePath, chunk); + if (!fallback.ok) { + writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); + } + return; } if (!primary.ok) { @@ -194,16 +202,16 @@ async function flushAsync(): Promise { const chunk = linesSnapshot.join(""); try { - await rotateIfNeededAsync(logFilePath); - const primary = await appendChunk(logFilePath, chunk); - let wroteAny = primary.ok; - if (fallbackLogFilePath) { - await rotateIfNeededAsync(fallbackLogFilePath); - const fallback = await appendChunk(fallbackLogFilePath, chunk); - wroteAny = wroteAny || fallback.ok; - if (!primary.ok && !fallback.ok) { - writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); - } + await rotateIfNeededAsync(logFilePath); + const primary = await appendChunk(logFilePath, chunk); + let wroteAny = primary.ok; + if (!primary.ok && fallbackLogFilePath) { + await rotateIfNeededAsync(fallbackLogFilePath); + const fallback = await appendChunk(fallbackLogFilePath, chunk); + wroteAny = fallback.ok; + if (!fallback.ok) { + writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`); + } } else if (!primary.ok) { writeStderr(`LOGGER write failed: ${primary.errorText}\n`); } diff --git a/src/main/main.ts b/src/main/main.ts index 79f044f..fc3cac7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -673,12 +673,16 @@ function registerIpcHandlers(): void { return { saved: true, filePath: result.filePath }; }); - handleTrusted(IPC_CHANNELS.OPEN_LOG, async () => { - const logPath = getLogFilePath(); - await shell.openPath(logPath); - }); - - handleTrusted(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => { + handleTrusted(IPC_CHANNELS.OPEN_LOG, async () => { + const logPath = getLogFilePath(); + await shell.openPath(logPath); + }); + + handleTrusted(IPC_CHANNELS.OPEN_LOG_DIRECTORY, async () => { + await shell.openPath(controller.getLogDirectory()); + }); + + handleTrusted(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => { const logPath = controller.getAuditLogPath(); if (logPath) { await shell.openPath(logPath); diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 5d51449..232d9ba 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -179,6 +179,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings clipboardWatch: settings.clipboardWatch, minimizeToTray: settings.minimizeToTray, theme: settings.theme, + logStorageLocation: settings.logStorageLocation, collapseNewPackages: settings.collapseNewPackages, historyRetentionMode: settings.historyRetentionMode, historyMaxEntries: settings.historyMaxEntries, diff --git a/src/main/storage.ts b/src/main/storage.ts index 89fe9ca..42bfc23 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; -import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types"; +import { AppSettings, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, SessionState } from "../shared/types"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { defaultSettings } from "./constants"; import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection"; @@ -18,7 +18,8 @@ const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "pack const VALID_SPEED_MODES = new Set(["global", "per_download"]); const VALID_THEMES = new Set(["dark", "light"]); const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]); -const VALID_HISTORY_RETENTION_MODES = new Set(["never", "session", "permanent"]); +const VALID_HISTORY_RETENTION_MODES = new Set(["never", "session", "permanent"]); +const VALID_LOG_STORAGE_LOCATIONS = new Set(["appdata", "desktop"]); const VALID_PACKAGE_PRIORITIES = new Set(["high", "normal", "low"]); const VALID_DOWNLOAD_STATUSES = new Set([ "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" @@ -516,8 +517,11 @@ export function normalizeSettings(settings: AppSettings): AppSettings { autoUpdateCheck: Boolean(settings.autoUpdateCheck), updateRepo: migrateUpdateRepo(asText(settings.updateRepo), defaults.updateRepo), clipboardWatch: Boolean(settings.clipboardWatch), - minimizeToTray: Boolean(settings.minimizeToTray), - collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages, + minimizeToTray: Boolean(settings.minimizeToTray), + logStorageLocation: VALID_LOG_STORAGE_LOCATIONS.has(settings.logStorageLocation) + ? settings.logStorageLocation + : defaults.logStorageLocation, + collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages, historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode) ? settings.historyRetentionMode : defaults.historyRetentionMode, diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 0369141..6d4f940 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -78,9 +78,10 @@ const api: ElectronApi = { cancelBackupImport: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_BACKUP_IMPORT), exportOnlineBackup: (): Promise<{ key: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ONLINE_BACKUP), importOnlineBackup: (key: string): Promise<{ restored: boolean; relaunch: false; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, key), - exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), - openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), - openAuditLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), + exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), + openLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), + openLogDirectory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG_DIRECTORY), + openAuditLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), openRenameLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG), openSessionLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG), openTraceLog: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index e735383..4e65e4c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -821,7 +821,7 @@ const emptySnapshot = (): UiSnapshot => ({ autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, - theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, + theme: "dark", logStorageLocation: "appdata", collapseNewPackages: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, accountListShowDetailedDebridLinkKeys: false, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, @@ -5075,6 +5075,12 @@ export function App(): ReactElement { }); return; } + if (fieldId === "logStorageDirectory") { + void performQuickAction(async () => { + await window.rd.openLogDirectory(); + }); + return; + } const targetKey = fieldId === "outputDir" ? "outputDir" : fieldId === "extractDir" ? "extractDir" : fieldId === "mkvLibraryDir" ? "mkvLibraryDir" : null; if (targetKey) { void performQuickAction(async () => { @@ -5467,6 +5473,7 @@ export function App(): ReactElement { className={`menu-submenu-dropdown${openSubmenu === "hilfe-log" ? " is-open" : ""}`} > + diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index 0042936..cb9ba5b 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -466,7 +466,19 @@ export function buildSettingsFormViewModel({ title: "Speicherort", fields: [ { id: "outputDir", kind: "path", label: "Download-Ordner", value: settings.outputDir, actionLabel: "Wählen", help: "Zielordner für heruntergeladene Dateien." }, - { id: "packageName", kind: "text", label: "Paketname (optional)", value: settings.packageName } + { id: "packageName", kind: "text", label: "Paketname (optional)", value: settings.packageName }, + { + id: "logStorageLocation", + kind: "select", + label: "Log-Speicherort", + value: settings.logStorageLocation, + options: [ + { value: "appdata", label: "AppData (empfohlen)" }, + { value: "desktop", label: "Desktop / Downloader Log" } + ], + help: "Beim Wechsel werden vorhandene Log-Dateien übernommen. Zugangsdaten und App-Konfiguration bleiben in AppData." + }, + { id: "logStorageDirectory", kind: "action", label: "Log-Ordner", actionLabel: "Ordner öffnen" } ] }, { diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 2457feb..e82d35e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -47,8 +47,9 @@ export const IPC_CHANNELS = { EXPORT_ONLINE_BACKUP: "app:export-online-backup", IMPORT_ONLINE_BACKUP: "app:import-online-backup", EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle", - OPEN_LOG: "app:open-log", - OPEN_AUDIT_LOG: "app:open-audit-log", + OPEN_LOG: "app:open-log", + OPEN_LOG_DIRECTORY: "app:open-log-directory", + OPEN_AUDIT_LOG: "app:open-audit-log", OPEN_RENAME_LOG: "app:open-rename-log", OPEN_SESSION_LOG: "app:open-session-log", OPEN_TRACE_LOG: "app:open-trace-log", diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index 5093a22..4dca7ff 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -75,9 +75,10 @@ export interface ElectronApi { cancelBackupImport: () => Promise; exportOnlineBackup: () => Promise<{ key: string }>; importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>; - exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; - openLog: () => Promise; - openAuditLog: () => Promise; + exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; + openLog: () => Promise; + openLogDirectory: () => Promise; + openAuditLog: () => Promise; openRenameLog: () => Promise; openSessionLog: () => Promise; openTraceLog: () => Promise; diff --git a/src/shared/types.ts b/src/shared/types.ts index 3734ec2..6da3f02 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -30,7 +30,8 @@ export type AppTheme = "dark" | "light"; export type AppLanguage = "en" | "de"; export type PackagePriority = "high" | "normal" | "low"; export type ExtractCpuPriority = "high" | "middle" | "low"; -export type HistoryRetentionMode = "never" | "session" | "permanent"; +export type HistoryRetentionMode = "never" | "session" | "permanent"; +export type LogStorageLocation = "appdata" | "desktop"; export interface BandwidthScheduleEntry { id: string; @@ -127,8 +128,9 @@ export interface AppSettings { autoUpdateCheck: boolean; clipboardWatch: boolean; minimizeToTray: boolean; - theme: AppTheme; - collapseNewPackages: boolean; + theme: AppTheme; + logStorageLocation: LogStorageLocation; + collapseNewPackages: boolean; historyRetentionMode: HistoryRetentionMode; historyMaxEntries: number; historyMaxAgeDays: number; @@ -246,6 +248,7 @@ export interface RendererSettings { clipboardWatch: boolean; minimizeToTray: boolean; theme: AppTheme; + logStorageLocation: LogStorageLocation; collapseNewPackages: boolean; historyRetentionMode: HistoryRetentionMode; historyMaxEntries: number; diff --git a/tests/log-storage.test.ts b/tests/log-storage.test.ts new file mode 100644 index 0000000..7b9209b --- /dev/null +++ b/tests/log-storage.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + DESKTOP_LOG_DIRECTORY_NAME, + LEGACY_DESKTOP_LOG_DIRECTORY_NAME, + migrateLogDirectories, + prepareLogDirectory, + resolveLogDirectory +} from "../src/main/log-storage"; +import { defaultSettings } from "../src/main/constants"; +import { normalizeSettings } from "../src/main/storage"; + +const createdDirectories: string[] = []; + +function createTempDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-log-storage-")); + createdDirectories.push(directory); + return directory; +} + +afterEach(() => { + for (const directory of createdDirectories) { + fs.rmSync(directory, { recursive: true, force: true }); + } + createdDirectories.length = 0; +}); + +describe("log-storage", () => { + it("uses the runtime directory by default and the named desktop directory on request", () => { + const runtimeDirectory = path.join(createTempDirectory(), "runtime"); + const desktopDirectory = path.join(createTempDirectory(), "Desktop"); + + expect(resolveLogDirectory(runtimeDirectory, desktopDirectory, "appdata")).toBe(runtimeDirectory); + expect(resolveLogDirectory(runtimeDirectory, desktopDirectory, "desktop")).toBe( + path.join(desktopDirectory, DESKTOP_LOG_DIRECTORY_NAME) + ); + expect(resolveLogDirectory(runtimeDirectory, null, "desktop")).toBe(runtimeDirectory); + expect(defaultSettings().logStorageLocation).toBe("appdata"); + expect(normalizeSettings({ ...defaultSettings(), logStorageLocation: "invalid" as "appdata" }).logStorageLocation).toBe("appdata"); + }); + + it("creates the selected directory and carries only log files and log folders", () => { + const source = path.join(createTempDirectory(), "source"); + const target = path.join(createTempDirectory(), "target"); + fs.mkdirSync(path.join(source, "session-logs"), { recursive: true }); + fs.mkdirSync(path.join(source, "package-logs"), { recursive: true }); + fs.writeFileSync(path.join(source, "rd_downloader.log"), "main\n", "utf8"); + fs.writeFileSync(path.join(source, "session-logs", "session_1.txt"), "session\n", "utf8"); + fs.writeFileSync(path.join(source, "package-logs", "package_1.txt"), "package\n", "utf8"); + fs.writeFileSync(path.join(source, "settings.json"), "secret\n", "utf8"); + + expect(prepareLogDirectory(target)).toBe(true); + const result = migrateLogDirectories([source], target); + + expect(result.copiedFiles).toBe(3); + expect(fs.readFileSync(path.join(target, "rd_downloader.log"), "utf8")).toBe("main\n"); + expect(fs.readFileSync(path.join(target, "session-logs", "session_1.txt"), "utf8")).toBe("session\n"); + expect(fs.readFileSync(path.join(target, "package-logs", "package_1.txt"), "utf8")).toBe("package\n"); + expect(fs.existsSync(path.join(target, "settings.json"))).toBe(false); + }); + + it("merges a legacy hyphenated desktop folder without exposing unrelated files", () => { + const root = createTempDirectory(); + const legacy = path.join(root, LEGACY_DESKTOP_LOG_DIRECTORY_NAME); + const target = path.join(root, DESKTOP_LOG_DIRECTORY_NAME); + fs.mkdirSync(legacy, { recursive: true }); + fs.writeFileSync(path.join(legacy, "rename-session_1.txt"), "rename\n", "utf8"); + fs.writeFileSync(path.join(legacy, "credentials.json"), "secret\n", "utf8"); + + const result = migrateLogDirectories([legacy], target); + + expect(result.copiedFiles).toBe(1); + expect(fs.readFileSync(path.join(target, "rename-session_1.txt"), "utf8")).toBe("rename\n"); + expect(fs.existsSync(path.join(target, "credentials.json"))).toBe(false); + }); + + it("replaces trace configuration instead of concatenating JSON from an older location", () => { + const source = path.join(createTempDirectory(), "source"); + const target = path.join(createTempDirectory(), "target"); + fs.mkdirSync(source, { recursive: true }); + fs.mkdirSync(target, { recursive: true }); + fs.writeFileSync(path.join(source, "trace_config.json"), JSON.stringify({ enabled: true, expiresAt: 123 }), "utf8"); + fs.writeFileSync(path.join(target, "trace_config.json"), JSON.stringify({ enabled: false, expiresAt: 456 }), "utf8"); + + const result = migrateLogDirectories([source], target); + + expect(result.copiedFiles).toBe(1); + expect(JSON.parse(fs.readFileSync(path.join(target, "trace_config.json"), "utf8"))).toEqual({ enabled: true, expiresAt: 123 }); + }); +}); diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index d7f7922..425b966 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -112,10 +112,11 @@ function createSettings(): AppSettings { speedLimitMode: "global", updateRepo: "Sucukdeluxe/multi-debrid-downloader", autoUpdateCheck: true, - clipboardWatch: true, - minimizeToTray: false, - theme: "dark", - collapseNewPackages: false, + clipboardWatch: true, + minimizeToTray: false, + theme: "dark", + logStorageLocation: "appdata", + collapseNewPackages: false, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, diff --git a/tests/visual/mock-electron-api.ts b/tests/visual/mock-electron-api.ts index 252e3ca..9822b57 100644 --- a/tests/visual/mock-electron-api.ts +++ b/tests/visual/mock-electron-api.ts @@ -182,6 +182,7 @@ export function createVisualElectronApi( importOnlineBackup: async () => ({ restored: true, relaunch: false, message: "Visual online backup importiert" }), exportSupportBundle: async () => ({ saved: true, filePath: "C:\\Visual\\Support\\support.zip" }), openLog: async () => {}, + openLogDirectory: async () => {}, openAuditLog: async () => {}, openRenameLog: async () => {}, openSessionLog: async () => {},