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:
parent
f44a321e74
commit
d4b98ad172
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user