From d4b98ad17246dd04fe7a2bce1ced9882ae4015f3 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 19 Apr 2026 11:53:07 +0200 Subject: [PATCH] Better error logging for non-Administrator/headless server scenarios Three improvements for users running on servers where the Windows account is not "Administrator" or where the environment is headless (Service, RDP- disconnected, no interactive desktop): 1. readSettingsFile / readSessionFile: distinguish ENOENT (normal first run) from EACCES/EPERM (permission problem). The latter logs an explicit message including the current Windows username so the user can spot misconfigured ACLs immediately. 2. ensureBaseDir: log EACCES/EPERM with the current username before re- throwing. Previously the error bubbled up without any hint why the AppData directory creation failed. 3. createTray: log a warning when Tray creation fails (typical on Windows Service / headless servers / RDP disconnected sessions). Previously the error was silently swallowed and minimize-to-tray would just not work without explanation. These errors were silently swallowed before, making it impossible to diagnose problems on servers with restricted user accounts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/main.ts | 32 ++++--- src/main/storage.ts | 218 ++++++++++++++++++++++++-------------------- 2 files changed, 139 insertions(+), 111 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index e834609..d4fbdd7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -136,7 +136,11 @@ function createTray(): void { const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico"); try { tray = new Tray(iconPath); - } catch { + } catch (error) { + // Fails on headless servers / Windows Service / RDP-disconnected sessions. + // Log so a user running on a non-Administrator/headless server can see + // why minimize-to-tray doesn't work, instead of getting an inaccessible window. + logger.warn(`Tray-Icon konnte nicht erstellt werden (Headless/RDP/Service?): ${String(error)} - Minimize-to-Tray steht nicht zur Verfuegung, Fenster bleibt sichtbar.`); return; } tray.setToolTip(APP_NAME); @@ -524,19 +528,19 @@ function registerIpcHandlers(): void { return { saved: true }; }); - ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { - const options = { - defaultPath: controller.getSupportBundleDefaultFileName(), - filters: [{ name: "Support Bundle", extensions: ["zip"] }] - }; - const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); - if (result.canceled || !result.filePath) { - return { saved: false }; - } - const exported = controller.exportSupportBundle(); - await fs.promises.writeFile(result.filePath, exported.buffer); - return { saved: true, filePath: result.filePath }; - }); + ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { + const options = { + defaultPath: controller.getSupportBundleDefaultFileName(), + filters: [{ name: "Support Bundle", extensions: ["zip"] }] + }; + const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); + if (result.canceled || !result.filePath) { + return { saved: false }; + } + const exported = controller.exportSupportBundle(); + await fs.promises.writeFile(result.filePath, exported.buffer); + return { saved: true, filePath: result.filePath }; + }); ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => { const logPath = getLogFilePath(); diff --git a/src/main/storage.ts b/src/main/storage.ts index 1fef58f..1590279 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -17,48 +17,48 @@ 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_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" -]); -const VALID_ITEM_PROVIDERS = new Set(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]); -const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); -const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; +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" +]); +const VALID_ITEM_PROVIDERS = new Set(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]); +const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); +const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; -function asText(value: unknown): string { - return String(value ?? "").trim(); -} - -function normalizeSessionId(value: unknown): string { - const text = asText(value); - if (!text || !SAFE_SESSION_ID_RE.test(text)) { - return ""; - } - return text; -} - -function isPathInsideDir(filePath: string, dirPath: string): boolean { - try { - const resolvedFile = path.resolve(filePath); - const resolvedDir = path.resolve(dirPath); - const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile; - const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir; - return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`); - } catch { - return false; - } -} - -function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string { - const targetPath = asText(value); - if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) { - return ""; - } - if (!isPathInsideDir(targetPath, packageOutputDir)) { - return ""; - } - return path.resolve(targetPath); -} +function asText(value: unknown): string { + return String(value ?? "").trim(); +} + +function normalizeSessionId(value: unknown): string { + const text = asText(value); + if (!text || !SAFE_SESSION_ID_RE.test(text)) { + return ""; + } + return text; +} + +function isPathInsideDir(filePath: string, dirPath: string): boolean { + try { + const resolvedFile = path.resolve(filePath); + const resolvedDir = path.resolve(dirPath); + const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile; + const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir; + return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`); + } catch { + return false; + } +} + +function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string { + const targetPath = asText(value); + if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) { + return ""; + } + if (!isPathInsideDir(targetPath, packageOutputDir)) { + return ""; + } + return path.resolve(targetPath); +} function clampNumber(value: unknown, fallback: number, min: number, max: number): number { const num = Number(value); @@ -530,7 +530,15 @@ export function createStoragePaths(baseDir: string): StoragePaths { } function ensureBaseDir(baseDir: string): void { - fs.mkdirSync(baseDir, { recursive: true }); + try { + fs.mkdirSync(baseDir, { recursive: true }); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code || ""; + if (code === "EACCES" || code === "EPERM") { + logger.error(`AppData-Ordner kann nicht erstellt werden (${code}): ${baseDir} - pruefe Schreibrechte fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`); + } + throw error; + } } /** JSON replacer that sanitizes NaN/Infinity to null to prevent file corruption. */ @@ -556,7 +564,18 @@ function readSettingsFile(filePath: string): AppSettings | null { ...parsed }); return sanitizeCredentialPersistence(merged); - } catch { + } catch (error) { + // Distinguish permission/access errors from missing/corrupt JSON so a + // misconfigured server (e.g. unusual user, restricted AppData) shows a + // clear log entry instead of silently falling back to defaults. + const code = (error as NodeJS.ErrnoException)?.code || ""; + if (code === "ENOENT") { + // file doesn't exist — normal on first run + } else if (code === "EACCES" || code === "EPERM") { + logger.error(`Settings-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`); + } else { + logger.warn(`Settings-Datei nicht lesbar: ${filePath}: ${String(error)}`); + } return null; } } @@ -570,18 +589,18 @@ export function normalizeLoadedSession(raw: unknown): SessionState { const now = Date.now(); const itemsById: Record = {}; - const rawItems = asRecord(parsed.items) ?? {}; - for (const [entryId, rawItem] of Object.entries(rawItems)) { - const item = asRecord(rawItem); - if (!item) { - continue; - } - const id = normalizeSessionId(item.id) || normalizeSessionId(entryId); - const packageId = normalizeSessionId(item.packageId); - const url = asText(item.url); - if (!id || !packageId || !url) { - continue; - } + const rawItems = asRecord(parsed.items) ?? {}; + for (const [entryId, rawItem] of Object.entries(rawItems)) { + const item = asRecord(rawItem); + if (!item) { + continue; + } + const id = normalizeSessionId(item.id) || normalizeSessionId(entryId); + const packageId = normalizeSessionId(item.packageId); + const url = asText(item.url); + if (!id || !packageId || !url) { + continue; + } const statusRaw = asText(item.status) as DownloadStatus; const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; @@ -616,16 +635,16 @@ export function normalizeLoadedSession(raw: unknown): SessionState { } const packagesById: Record = {}; - const rawPackages = asRecord(parsed.packages) ?? {}; - for (const [entryId, rawPkg] of Object.entries(rawPackages)) { - const pkg = asRecord(rawPkg); - if (!pkg) { - continue; - } - const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId); - if (!id) { - continue; - } + const rawPackages = asRecord(parsed.packages) ?? {}; + for (const [entryId, rawPkg] of Object.entries(rawPackages)) { + const pkg = asRecord(rawPkg); + if (!pkg) { + continue; + } + const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId); + if (!id) { + continue; + } const statusRaw = asText(pkg.status) as DownloadStatus; const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : []; @@ -633,11 +652,11 @@ export function normalizeLoadedSession(raw: unknown): SessionState { id, name: asText(pkg.name) || "Paket", outputDir: asText(pkg.outputDir), - extractDir: asText(pkg.extractDir), - status, - itemIds: rawItemIds - .map((value) => normalizeSessionId(value)) - .filter((value) => value.length > 0), + extractDir: asText(pkg.extractDir), + status, + itemIds: rawItemIds + .map((value) => normalizeSessionId(value)) + .filter((value) => value.length > 0), cancelled: Boolean(pkg.cancelled), enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", @@ -655,25 +674,25 @@ export function normalizeLoadedSession(raw: unknown): SessionState { delete itemsById[itemId]; } } - if (orphanedItemCount > 0) { - logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`); - } - - let droppedUnsafeTargetPathCount = 0; - for (const item of Object.values(itemsById)) { - const pkg = packagesById[item.packageId]; - if (!pkg) { - continue; - } - const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir); - if (!safeTargetPath && asText(item.targetPath)) { - droppedUnsafeTargetPathCount += 1; - } - item.targetPath = safeTargetPath; - } - if (droppedUnsafeTargetPathCount > 0) { - logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`); - } + if (orphanedItemCount > 0) { + logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`); + } + + let droppedUnsafeTargetPathCount = 0; + for (const item of Object.values(itemsById)) { + const pkg = packagesById[item.packageId]; + if (!pkg) { + continue; + } + const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir); + if (!safeTargetPath && asText(item.targetPath)) { + droppedUnsafeTargetPathCount += 1; + } + item.targetPath = safeTargetPath; + } + if (droppedUnsafeTargetPathCount > 0) { + logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`); + } for (const pkg of Object.values(packagesById)) { pkg.itemIds = pkg.itemIds.filter((itemId) => { @@ -682,13 +701,13 @@ export function normalizeLoadedSession(raw: unknown): SessionState { }); } - const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; - const seenOrder = new Set(); - const packageOrder = rawOrder - .map((entry) => normalizeSessionId(entry)) - .filter((id) => { - if (!(id in packagesById) || seenOrder.has(id)) { - return false; + const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : []; + const seenOrder = new Set(); + const packageOrder = rawOrder + .map((entry) => normalizeSessionId(entry)) + .filter((id) => { + if (!(id in packagesById) || seenOrder.has(id)) { + return false; } seenOrder.add(id); return true; @@ -807,7 +826,12 @@ function readSessionFile(filePath: string): SessionState | null { logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`); return session; } catch (error) { - logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`); + const code = (error as NodeJS.ErrnoException)?.code || ""; + if (code === "EACCES" || code === "EPERM") { + logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`); + } else { + logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`); + } return null; } }