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 () => {},