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) <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-04-19 11:53:07 +02:00
parent f44a321e74
commit d4b98ad172
2 changed files with 139 additions and 111 deletions

View File

@ -136,7 +136,11 @@ function createTray(): void {
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico"); const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
try { try {
tray = new Tray(iconPath); 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; return;
} }
tray.setToolTip(APP_NAME); tray.setToolTip(APP_NAME);
@ -524,19 +528,19 @@ function registerIpcHandlers(): void {
return { saved: true }; return { saved: true };
}); });
ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
const options = { const options = {
defaultPath: controller.getSupportBundleDefaultFileName(), defaultPath: controller.getSupportBundleDefaultFileName(),
filters: [{ name: "Support Bundle", extensions: ["zip"] }] filters: [{ name: "Support Bundle", extensions: ["zip"] }]
}; };
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
if (result.canceled || !result.filePath) { if (result.canceled || !result.filePath) {
return { saved: false }; return { saved: false };
} }
const exported = controller.exportSupportBundle(); const exported = controller.exportSupportBundle();
await fs.promises.writeFile(result.filePath, exported.buffer); await fs.promises.writeFile(result.filePath, exported.buffer);
return { saved: true, filePath: result.filePath }; return { saved: true, filePath: result.filePath };
}); });
ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => { ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => {
const logPath = getLogFilePath(); const logPath = getLogFilePath();

View File

@ -17,48 +17,48 @@ const VALID_SPEED_MODES = new Set(["global", "per_download"]);
const VALID_THEMES = new Set(["dark", "light"]); const VALID_THEMES = new Set(["dark", "light"]);
const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]); const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]);
const VALID_HISTORY_RETENTION_MODES = new Set<HistoryRetentionMode>(["never", "session", "permanent"]); const VALID_HISTORY_RETENTION_MODES = new Set<HistoryRetentionMode>(["never", "session", "permanent"]);
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]); const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([ const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
]); ]);
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]); const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/; const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
function asText(value: unknown): string { function asText(value: unknown): string {
return String(value ?? "").trim(); return String(value ?? "").trim();
} }
function normalizeSessionId(value: unknown): string { function normalizeSessionId(value: unknown): string {
const text = asText(value); const text = asText(value);
if (!text || !SAFE_SESSION_ID_RE.test(text)) { if (!text || !SAFE_SESSION_ID_RE.test(text)) {
return ""; return "";
} }
return text; return text;
} }
function isPathInsideDir(filePath: string, dirPath: string): boolean { function isPathInsideDir(filePath: string, dirPath: string): boolean {
try { try {
const resolvedFile = path.resolve(filePath); const resolvedFile = path.resolve(filePath);
const resolvedDir = path.resolve(dirPath); const resolvedDir = path.resolve(dirPath);
const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile; const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile;
const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir; const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir;
return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`); return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`);
} catch { } catch {
return false; return false;
} }
} }
function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string { function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string {
const targetPath = asText(value); const targetPath = asText(value);
if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) { if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) {
return ""; return "";
} }
if (!isPathInsideDir(targetPath, packageOutputDir)) { if (!isPathInsideDir(targetPath, packageOutputDir)) {
return ""; return "";
} }
return path.resolve(targetPath); return path.resolve(targetPath);
} }
function clampNumber(value: unknown, fallback: number, min: number, max: number): number { function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
const num = Number(value); const num = Number(value);
@ -530,7 +530,15 @@ export function createStoragePaths(baseDir: string): StoragePaths {
} }
function ensureBaseDir(baseDir: string): void { 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. */ /** JSON replacer that sanitizes NaN/Infinity to null to prevent file corruption. */
@ -556,7 +564,18 @@ function readSettingsFile(filePath: string): AppSettings | null {
...parsed ...parsed
}); });
return sanitizeCredentialPersistence(merged); 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; return null;
} }
} }
@ -570,18 +589,18 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
const now = Date.now(); const now = Date.now();
const itemsById: Record<string, DownloadItem> = {}; const itemsById: Record<string, DownloadItem> = {};
const rawItems = asRecord(parsed.items) ?? {}; const rawItems = asRecord(parsed.items) ?? {};
for (const [entryId, rawItem] of Object.entries(rawItems)) { for (const [entryId, rawItem] of Object.entries(rawItems)) {
const item = asRecord(rawItem); const item = asRecord(rawItem);
if (!item) { if (!item) {
continue; continue;
} }
const id = normalizeSessionId(item.id) || normalizeSessionId(entryId); const id = normalizeSessionId(item.id) || normalizeSessionId(entryId);
const packageId = normalizeSessionId(item.packageId); const packageId = normalizeSessionId(item.packageId);
const url = asText(item.url); const url = asText(item.url);
if (!id || !packageId || !url) { if (!id || !packageId || !url) {
continue; continue;
} }
const statusRaw = asText(item.status) as DownloadStatus; const statusRaw = asText(item.status) as DownloadStatus;
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
@ -616,16 +635,16 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
} }
const packagesById: Record<string, PackageEntry> = {}; const packagesById: Record<string, PackageEntry> = {};
const rawPackages = asRecord(parsed.packages) ?? {}; const rawPackages = asRecord(parsed.packages) ?? {};
for (const [entryId, rawPkg] of Object.entries(rawPackages)) { for (const [entryId, rawPkg] of Object.entries(rawPackages)) {
const pkg = asRecord(rawPkg); const pkg = asRecord(rawPkg);
if (!pkg) { if (!pkg) {
continue; continue;
} }
const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId); const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId);
if (!id) { if (!id) {
continue; continue;
} }
const statusRaw = asText(pkg.status) as DownloadStatus; const statusRaw = asText(pkg.status) as DownloadStatus;
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued"; const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : []; const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : [];
@ -633,11 +652,11 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
id, id,
name: asText(pkg.name) || "Paket", name: asText(pkg.name) || "Paket",
outputDir: asText(pkg.outputDir), outputDir: asText(pkg.outputDir),
extractDir: asText(pkg.extractDir), extractDir: asText(pkg.extractDir),
status, status,
itemIds: rawItemIds itemIds: rawItemIds
.map((value) => normalizeSessionId(value)) .map((value) => normalizeSessionId(value))
.filter((value) => value.length > 0), .filter((value) => value.length > 0),
cancelled: Boolean(pkg.cancelled), cancelled: Boolean(pkg.cancelled),
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", 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]; delete itemsById[itemId];
} }
} }
if (orphanedItemCount > 0) { if (orphanedItemCount > 0) {
logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`); logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`);
} }
let droppedUnsafeTargetPathCount = 0; let droppedUnsafeTargetPathCount = 0;
for (const item of Object.values(itemsById)) { for (const item of Object.values(itemsById)) {
const pkg = packagesById[item.packageId]; const pkg = packagesById[item.packageId];
if (!pkg) { if (!pkg) {
continue; continue;
} }
const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir); const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir);
if (!safeTargetPath && asText(item.targetPath)) { if (!safeTargetPath && asText(item.targetPath)) {
droppedUnsafeTargetPathCount += 1; droppedUnsafeTargetPathCount += 1;
} }
item.targetPath = safeTargetPath; item.targetPath = safeTargetPath;
} }
if (droppedUnsafeTargetPathCount > 0) { if (droppedUnsafeTargetPathCount > 0) {
logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`); logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`);
} }
for (const pkg of Object.values(packagesById)) { for (const pkg of Object.values(packagesById)) {
pkg.itemIds = pkg.itemIds.filter((itemId) => { 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 rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
const seenOrder = new Set<string>(); const seenOrder = new Set<string>();
const packageOrder = rawOrder const packageOrder = rawOrder
.map((entry) => normalizeSessionId(entry)) .map((entry) => normalizeSessionId(entry))
.filter((id) => { .filter((id) => {
if (!(id in packagesById) || seenOrder.has(id)) { if (!(id in packagesById) || seenOrder.has(id)) {
return false; return false;
} }
seenOrder.add(id); seenOrder.add(id);
return true; return true;
@ -807,7 +826,12 @@ function readSessionFile(filePath: string): SessionState | null {
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`); logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
return session; return session;
} catch (error) { } 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; return null;
} }
} }