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.
This commit is contained in:
Sucukdeluxe
2026-08-12 11:59:16 +02:00
parent a02ffe4623
commit 56ff988a7c
17 changed files with 513 additions and 114 deletions
+108 -25
View File
@@ -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<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@@ -96,6 +97,8 @@ export class AppController {
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
private logDirectory = this.storagePaths.baseDir;
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
private autoResumePending = false;
@@ -104,22 +107,25 @@ export class AppController {
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);
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;
@@ -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,7 +456,7 @@ export class AppController {
public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial);
const previousSettings = this.settings;
const nextSettings = normalizeSettings({
let nextSettings = normalizeSettings({
...previousSettings,
...sanitizedPatch
});
@@ -452,6 +465,13 @@ export class AppController {
return previousSettings;
}
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
@@ -889,7 +909,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
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,6 +929,13 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
};
}
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);
@@ -951,6 +978,10 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
return getSessionLogPath();
}
public getLogDirectory(): string {
return this.logDirectory;
}
public getPackageLogPath(packageId: string): string | null {
return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId);
}
@@ -972,11 +1003,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
this.realDebridWebFallback.dispose();
this.allDebridWebFallback.dispose();
this.bestDebridWebFallback.dispose();
shutdownSessionLog();
shutdownPackageLogs();
shutdownItemLogs();
shutdownRenameLog();
shutdownDesktopRenameLog();
this.shutdownLogStorage();
this.audit("INFO", "App beendet");
shutdownTraceLog();
shutdownAccountRotationLog();
@@ -988,6 +1015,62 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
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 };
}
+1
View File
@@ -103,6 +103,7 @@ export function defaultSettings(): AppSettings {
clipboardWatch: false,
minimizeToTray: false,
theme: "dark" as const,
logStorageLocation: "appdata",
collapseNewPackages: true,
historyRetentionMode: "permanent",
historyMaxEntries: 500,
+13 -4
View File
@@ -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 {
function initializeRenameLog(directory: string | null | undefined): void {
try {
const base = String(desktopDir || "").trim();
const base = String(directory || "").trim();
if (!base) {
logDir = null;
logFilePath = null;
return;
}
logDir = path.join(base, FOLDER_NAME);
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"
@@ -80,6 +80,15 @@ export function initDesktopRenameLog(desktopDir: string | null | undefined): voi
}
}
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<string, unknown>): void {
if (!ensureWritable() || !logFilePath) {
return;
+170
View File
@@ -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;
}
+13 -5
View File
@@ -63,6 +63,14 @@ export function configureLogger(baseDir: string): void {
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 {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -101,10 +109,10 @@ function flushSyncPending(): void {
rotateIfNeeded(logFilePath);
const primary = appendLine(logFilePath, chunk);
if (fallbackLogFilePath) {
if (!primary.ok && fallbackLogFilePath) {
rotateIfNeeded(fallbackLogFilePath);
const fallback = appendLine(fallbackLogFilePath, chunk);
if (!primary.ok && !fallback.ok) {
if (!fallback.ok) {
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
}
return;
@@ -197,11 +205,11 @@ async function flushAsync(): Promise<void> {
await rotateIfNeededAsync(logFilePath);
const primary = await appendChunk(logFilePath, chunk);
let wroteAny = primary.ok;
if (fallbackLogFilePath) {
if (!primary.ok && fallbackLogFilePath) {
await rotateIfNeededAsync(fallbackLogFilePath);
const fallback = await appendChunk(fallbackLogFilePath, chunk);
wroteAny = wroteAny || fallback.ok;
if (!primary.ok && !fallback.ok) {
wroteAny = fallback.ok;
if (!fallback.ok) {
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
}
} else if (!primary.ok) {
+4
View File
@@ -678,6 +678,10 @@ function registerIpcHandlers(): void {
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) {
+1
View File
@@ -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,
+5 -1
View File
@@ -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";
@@ -19,6 +19,7 @@ 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<HistoryRetentionMode>(["never", "session", "permanent"]);
const VALID_LOG_STORAGE_LOCATIONS = new Set<LogStorageLocation>(["appdata", "desktop"]);
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
@@ -517,6 +518,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
updateRepo: migrateUpdateRepo(asText(settings.updateRepo), defaults.updateRepo),
clipboardWatch: Boolean(settings.clipboardWatch),
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
+1
View File
@@ -80,6 +80,7 @@ const api: ElectronApi = {
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<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
openLogDirectory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG_DIRECTORY),
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
+8 -1
View File
@@ -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" : ""}`}
>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLog().catch(() => {}); }}><span>Haupt-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openLogDirectory().catch(() => {}); }}><span>Log-Ordner öffnen</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openAuditLog().catch(() => {}); }}><span>Audit-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openRenameLog().catch(() => {}); }}><span>Rename-Log</span></button>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void window.rd.openSessionLog().catch(() => {}); }}><span>Session-Log</span></button>
+13 -1
View File
@@ -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" }
]
},
{
+1
View File
@@ -48,6 +48,7 @@ export const IPC_CHANNELS = {
IMPORT_ONLINE_BACKUP: "app:import-online-backup",
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
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",
+1
View File
@@ -77,6 +77,7 @@ export interface ElectronApi {
importOnlineBackup: (key: string) => Promise<{ restored: boolean; relaunch: false; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
openLog: () => Promise<void>;
openLogDirectory: () => Promise<void>;
openAuditLog: () => Promise<void>;
openRenameLog: () => Promise<void>;
openSessionLog: () => Promise<void>;
+3
View File
@@ -31,6 +31,7 @@ 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 LogStorageLocation = "appdata" | "desktop";
export interface BandwidthScheduleEntry {
id: string;
@@ -128,6 +129,7 @@ export interface AppSettings {
clipboardWatch: boolean;
minimizeToTray: boolean;
theme: AppTheme;
logStorageLocation: LogStorageLocation;
collapseNewPackages: boolean;
historyRetentionMode: HistoryRetentionMode;
historyMaxEntries: number;
@@ -246,6 +248,7 @@ export interface RendererSettings {
clipboardWatch: boolean;
minimizeToTray: boolean;
theme: AppTheme;
logStorageLocation: LogStorageLocation;
collapseNewPackages: boolean;
historyRetentionMode: HistoryRetentionMode;
historyMaxEntries: number;
+92
View File
@@ -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 });
});
});
+1
View File
@@ -115,6 +115,7 @@ function createSettings(): AppSettings {
clipboardWatch: true,
minimizeToTray: false,
theme: "dark",
logStorageLocation: "appdata",
collapseNewPackages: false,
historyRetentionMode: "permanent",
historyMaxEntries: 500,
+1
View File
@@ -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 () => {},