import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, nativeTheme, powerMonitor, safeStorage, shell, Tray, type IpcMainEvent, type IpcMainInvokeEvent } from "electron"; import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, RendererSettingsUpdate, UpdateInstallProgress } from "../shared/types"; import { AppController } from "./app-controller"; import { IPC_CHANNELS } from "../shared/ipc"; import { getLogFilePath, logger } from "./logger"; import { getRecentErrors } from "./error-ring"; import { sendNotification } from "./notify"; import { APP_NAME } from "./constants"; import { extractHttpLinksFromText } from "./utils"; import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor"; import { revealHistoryEntry } from "./history-reveal"; import { DEV_SERVER_URL } from "./dev-server-url"; import { resolveAppIconPath } from "./app-icon"; import { configureCredentialProtector } from "./credential-protection"; import { isMdd2Backup } from "./backup-crypto"; import { validateAccountCommand, validateAccountCredentialCheckInput, validateAccountSecretRequest } from "./account-commands"; import { createRendererSettings } from "./renderer-state"; import { validateRendererSettingsUpdate } from "./renderer-settings"; import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security"; import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; import { validateRealDebridLoginRequest } from "../shared/preload-api"; import { migrateProductUserDataDirectory } from "./storage"; import { forceDarkNativeTheme } from "./native-theme"; import { validateClipboardWriteText } from "./clipboard-write"; import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler"; import { validateCollectorContainerPreparationRequest, validateCollectorEnrichmentRequest, validateCollectorPersistenceState, validateCollectorTextPreparationRequest } from "../shared/collector"; import { getProxyAuthentication } from "./network-proxy"; forceDarkNativeTheme(nativeTheme); function validateString(value: unknown, name: string): string { if (typeof value !== "string") { throw new Error(`${name} muss ein String sein`); } return value; } function validatePlainObject(value: unknown, name: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${name} muss ein Objekt sein`); } return value as Record; } const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024; const RENAME_PACKAGE_MAX_CHARS = 240; const RESETTABLE_PROVIDER_KEYS = new Set([ "realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "deepbrid", "ddownload", "onefichier", "debridlink", "linksnappy" ]); if (app.isPackaged && !process.argv.some((arg) => arg === "--user-data-dir" || arg.startsWith("--user-data-dir="))) { app.setPath("userData", migrateProductUserDataDirectory(app.getPath("appData"))); } function validateStringArray(value: unknown, name: string): string[] { if (!Array.isArray(value) || !value.every(v => typeof v === "string")) { throw new Error(`${name} muss ein String-Array sein`); } return value as string[]; } const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { app.exit(0); process.exit(0); } process.on("uncaughtException", (error) => { logger.error(`Uncaught Exception: ${String(error?.stack || error)}`); }); process.on("unhandledRejection", (reason) => { const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason); logger.error(`Unhandled Rejection: ${detail}`); }); // Node-Warnungen (z.B. MaxListenersExceeded, DeprecationWarning) sind ein // Frühindikator für Leaks/Fehlnutzung in einem langlaufenden Server-Prozess. process.on("warning", (warning) => { logger.warn(`Node-Warnung: ${warning.name}: ${warning.message}${warning.stack ? ` | ${warning.stack.replace(/\s*\n\s*/g, " ⏎ ")}` : ""}`); }); let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; let clipboardTimer: ReturnType | null = null; let updateQuitTimer: ReturnType | null = null; let scheduledStartTimer: ReturnType | null = null; let dailyStartScheduler: DailyStartScheduler | null = null; let lastClipboardText = ""; let controller: AppController; let applicationStarted = false; let appShutdownStarted = false; let pendingBackupImport: Buffer | null = null; const CLIPBOARD_MAX_TEXT_CHARS = 50_000; export const APP_SHUTDOWN_TIMEOUT_MS = 10_000; export const APP_QUIT_CONFIRM_TIMEOUT_MS = 2_000; function reconcileDailyStart(source: string): void { void dailyStartScheduler?.reconcile().catch((error) => { logger.warn(`Täglicher Start konnte nach ${source} nicht abgeglichen werden: ${String(error)}`); }); } function handlePowerSuspend(): void { reconcileDailyStart("Suspend"); } function handlePowerResume(): void { reconcileDailyStart("Resume"); } export function cleanupSchedulerLifecycle( scheduler: Pick | null, legacyTimer: ReturnType | null ): void { scheduler?.end(); if (legacyTimer !== null) { clearTimeout(legacyTimer); } powerMonitor.removeListener("suspend", handlePowerSuspend); powerMonitor.removeListener("resume", handlePowerResume); } export interface BeforeQuitHandlerOptions { cleanup: () => void; shutdown: () => Promise; continueQuit: () => void; forceQuit: () => void; onError: (error: unknown) => void; onTimeout: () => void; timeoutMs: number; } export function createBeforeQuitHandler(options: BeforeQuitHandlerOptions): (event: { preventDefault: () => void }) => void { let shutdownStarted = false; let quitAllowed = false; let shutdownTimer: ReturnType | null = null; const finish = (forced: boolean): void => { if (quitAllowed) { return; } quitAllowed = true; if (shutdownTimer !== null) { clearTimeout(shutdownTimer); shutdownTimer = null; } if (forced) { try { options.onTimeout(); } finally { options.forceQuit(); } } else { options.continueQuit(); } }; return (event) => { if (quitAllowed) { return; } event.preventDefault(); if (shutdownStarted) { return; } shutdownStarted = true; shutdownTimer = setTimeout(() => finish(true), options.timeoutMs); let shutdown: Promise; try { options.cleanup(); shutdown = options.shutdown(); } catch (error) { shutdown = Promise.reject(error); } void shutdown.catch((error) => { options.onError(error); }).finally(() => { finish(false); }); }; } export interface MainWindowActivationTarget { isDestroyed: () => boolean; isMinimized: () => boolean; restore: () => void; show: () => void; focus: () => void; } export function restoreOrCreateMainWindow( currentWindow: T | null, create: () => T, bind: (window: T) => void ): { window: T; created: boolean } { let window = currentWindow; let created = false; if (!window || window.isDestroyed()) { window = create(); bind(window); created = true; } if (window.isMinimized()) { window.restore(); } window.show(); window.focus(); return { window, created }; } export interface MainWindowClosedHandlerOptions { isCurrentWindow: () => boolean; clearCurrentWindow: () => void; isShutdownStarted: () => boolean; quit: () => void; platform?: NodeJS.Platform; } export function createMainWindowClosedHandler(options: MainWindowClosedHandlerOptions): () => void { return () => { if (!options.isCurrentWindow()) { return; } options.clearCurrentWindow(); if ((options.platform ?? process.platform) !== "darwin" && !options.isShutdownStarted()) { options.quit(); } }; } export interface MainWindowCloseHandlerOptions { isShutdownStarted: () => boolean; shouldMinimizeToTray: () => boolean; hide: () => void; } export function createMainWindowCloseHandler(options: MainWindowCloseHandlerOptions): (event: { preventDefault: () => void }) => void { return (event) => { if (options.isShutdownStarted()) { return; } if (options.shouldMinimizeToTray()) { event.preventDefault(); options.hide(); } }; } export interface ConfirmedQuitHandlerOptions { requestQuit: () => void; forceQuit: () => void; onQuit: (listener: () => void) => void; onTimeout: () => void; timeoutMs: number; } export function createConfirmedQuitHandler(options: ConfirmedQuitHandlerOptions): () => void { let requested = false; return () => { if (requested) { return; } requested = true; let completed = false; const timer = setTimeout(() => { if (completed) { return; } completed = true; try { options.onTimeout(); } finally { options.forceQuit(); } }, options.timeoutMs); options.onQuit(() => { if (completed) { return; } completed = true; clearTimeout(timer); }); options.requestQuit(); }; } export function createRelaunchScheduler(relaunch: () => void): () => boolean { let scheduled = false; return () => { if (scheduled) { return false; } scheduled = true; relaunch(); return true; }; } const scheduleApplicationRelaunch = createRelaunchScheduler(() => app.relaunch()); function isDevMode(): boolean { return process.env.NODE_ENV === "development"; } function validateHistoryEntryIds(value: unknown): string[] { const entryIds = validateStringArray(value, "entryIds"); if (entryIds.length > 100_000 || entryIds.some((entryId) => entryId.length === 0 || entryId.length > 4_096)) { throw new Error("entryIds ist ungültig"); } return [...new Set(entryIds)]; } function getRendererFileUrl(): string { return pathToFileURL(path.join(app.getAppPath(), "build", "renderer", "index.html")).toString(); } function getTrustedRendererUrl(): string { return isDevMode() ? DEV_SERVER_URL : getRendererFileUrl(); } function getTrustedIpcOptions(): TrustedIpcOptions { return { isPackaged: !isDevMode(), devServerUrl: DEV_SERVER_URL, appPath: app.getAppPath() }; } function handleTrusted(channel: string, listener: (event: IpcMainInvokeEvent, ...args: TArgs) => unknown): void { ipcMain.handle(channel, (event, ...args) => { assertTrustedIpcSender(event, getTrustedIpcOptions()); return listener(event, ...(args as TArgs)); }); } function onTrusted(channel: string, listener: (event: IpcMainEvent, ...args: TArgs) => void): void { ipcMain.on(channel, (event, ...args) => { assertTrustedIpcSender(event, getTrustedIpcOptions()); listener(event, ...(args as TArgs)); }); } export function saveCollectorStateFromSyncIpc( target: Pick, event: Pick, value: unknown ): void { try { target.saveCollectorStateSync(validateCollectorPersistenceState(value)); event.returnValue = true; } catch (error) { logger.warn(`Linksammler-Abschlussspeicherung fehlgeschlagen: ${String(error)}`); event.returnValue = false; } } export function getCollectorStateFromSyncIpc( target: Pick, event: Pick ): void { try { event.returnValue = validateCollectorPersistenceState(target.getCollectorState()); } catch (error) { logger.warn(`Synchrones Laden des Linksammlers fehlgeschlagen: ${String(error)}`); event.returnValue = null; } } // Single owner of the scheduled-start timer. startOnPast: a past time entered // interactively starts right away; at boot a stale past time is cleared instead // (an unattended auto-start at boot would race autoResumeOnStart's conflict gate). function armScheduledStart(schedMs: number, opts: { startOnPast: boolean }): void { if (scheduledStartTimer !== null) { clearTimeout(scheduledStartTimer); scheduledStartTimer = null; } if (!schedMs || schedMs <= 0) { return; } const delay = schedMs - Date.now(); if (delay <= 0) { if (opts.startOnPast) { void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`)); } else { logger.warn(`Geplanter Start (${new Date(schedMs).toLocaleString()}) lag beim App-Start in der Vergangenheit — verworfen`); } controller.updateSettings({ scheduledStartEpochMs: 0 }); return; } scheduledStartTimer = setTimeout(() => { scheduledStartTimer = null; void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`)); controller.updateSettings({ scheduledStartEpochMs: 0 }); }, delay); logger.info(`Geplanter Start gearmt: ${new Date(schedMs).toLocaleString()}`); } function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1920, height: 1080, minWidth: 1120, minHeight: 760, backgroundColor: "#070b14", title: `${APP_NAME} - v${controller.getVersion()}`, icon: resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath), webPreferences: createMainWindowWebPreferences(path.join(__dirname, "../preload/preload.js")) }); window.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { logger.error(`Renderer-Laden fehlgeschlagen: id=${window.id} code=${errorCode} mainFrame=${isMainFrame} url=${validatedURL} error=${errorDescription}`); }); applyMainWindowSecurity(window, { rendererUrl: getTrustedRendererUrl(), externalHosts: MAIN_WINDOW_EXTERNAL_HOSTS }); if (!isDevMode()) { window.webContents.session.webRequest.onHeadersReceived((details, callback) => { callback({ responseHeaders: { ...details.responseHeaders, "Content-Security-Policy": [ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://ddownload.com https://ddl.to https://debrid-link.com" ] } }); }); } window.setMenuBarVisibility(false); window.setAutoHideMenuBar(true); if (isDevMode()) { void window.loadURL(DEV_SERVER_URL).catch((error) => { logger.error(`Renderer-Start fehlgeschlagen: ${String(error?.stack || error)}`); }); } else { void window.loadFile(path.join(app.getAppPath(), "build", "renderer", "index.html")).catch((error) => { logger.error(`Renderer-Start fehlgeschlagen: ${String(error?.stack || error)}`); }); } return window; } let rendererReloadTimes: number[] = []; const RENDERER_RELOAD_WINDOW_MS = 5 * 60 * 1000; const RENDERER_RELOAD_MAX = 3; // Circuit breaker: recover from a one-off renderer crash by reloading, but stop // after a few crashes in a short window so a reproducible crash can't spin into a // reload loop that pegs an unattended server. function allowRendererReload(): boolean { const now = Date.now(); rendererReloadTimes = rendererReloadTimes.filter((t) => now - t < RENDERER_RELOAD_WINDOW_MS); if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) { return false; } rendererReloadTimes.push(now); return true; } function bindMainWindowLifecycle(window: BrowserWindow): void { const requestQuit = (): void => { if (appShutdownStarted) { return; } appShutdownStarted = true; logger.info("Hauptfenster geschlossen – Anwendung wird beendet"); app.quit(); }; window.on("close", createMainWindowCloseHandler({ isShutdownStarted: () => appShutdownStarted, shouldMinimizeToTray: () => controller.getSettings().minimizeToTray && tray !== null, hide: () => window.hide() })); window.on("closed", createMainWindowClosedHandler({ isCurrentWindow: () => mainWindow === window, clearCurrentWindow: () => { mainWindow = null; }, isShutdownStarted: () => appShutdownStarted, quit: requestQuit })); window.webContents.on("render-process-gone", (_event, details) => { logger.error(`Renderer-Prozess beendet: reason=${details.reason} exitCode=${details.exitCode ?? "?"}`); if (details.reason === "clean-exit" || window.isDestroyed()) { return; } if (allowRendererReload()) { logger.warn("Renderer wird automatisch neu geladen (Wiederherstellung nach Absturz)"); try { window.webContents.reload(); } catch (error) { logger.error(`Renderer-Reload fehlgeschlagen: ${String(error)}`); } } else { logger.error(`Renderer-Absturz: Auto-Reload gestoppt (mehr als ${RENDERER_RELOAD_MAX} Abstürze in ${RENDERER_RELOAD_WINDOW_MS / 60000} Min) - manueller Neustart nötig`); } }); // Nur protokollieren, niemals killen/neu laden: "unresponsive" feuert auch // während legitimer langer Sync-Arbeit (große JSON-Serialisierung) und erholt // sich meist von selbst. Eingreifen würde einen Schluckauf zum Ausfall machen. window.webContents.on("unresponsive", () => { logger.warn("Renderer reagiert nicht (unresponsive) - evtl. langer Sync-Task, warte auf Erholung"); }); window.webContents.on("responsive", () => { logger.info("Renderer wieder reaktionsfähig (responsive)"); }); } function createTray(): void { if (tray) { return; } const iconPath = resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath); try { tray = new Tray(iconPath); } catch (error) { 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); const contextMenu = Menu.buildFromTemplate([ { label: "Anzeigen", click: () => { mainWindow?.show(); mainWindow?.focus(); } }, { type: "separator" }, { label: "Start", click: () => { void controller.start().catch((err) => logger.warn(`Tray Start Fehler: ${String(err)}`)); } }, { label: "Stop", click: () => { controller.stop(); } }, { type: "separator" }, { label: "Beenden", click: () => { app.quit(); } } ]); tray.setContextMenu(contextMenu); tray.on("double-click", () => { mainWindow?.show(); mainWindow?.focus(); }); } function destroyTray(): void { if (tray) { tray.destroy(); tray = null; } } function extractLinksFromText(text: string): string[] { return extractHttpLinksFromText(text); } function normalizeClipboardText(text: string): string { const truncateUnicodeSafe = (value: string, maxChars: number): string => { if (value.length <= maxChars) { return value; } const points = Array.from(value); if (points.length <= maxChars) { return value; } return points.slice(0, maxChars).join(""); }; const normalized = String(text || ""); if (normalized.length <= CLIPBOARD_MAX_TEXT_CHARS) { return normalized; } const truncated = truncateUnicodeSafe(normalized, CLIPBOARD_MAX_TEXT_CHARS); const lastBreak = Math.max( truncated.lastIndexOf("\n"), truncated.lastIndexOf("\r"), truncated.lastIndexOf("\t"), truncated.lastIndexOf(" ") ); if (lastBreak >= Math.floor(CLIPBOARD_MAX_TEXT_CHARS * 0.7)) { return truncated.slice(0, lastBreak); } return truncated; } function startClipboardWatcher(): void { if (clipboardTimer) { return; } lastClipboardText = normalizeClipboardText(clipboard.readText()); clipboardTimer = setInterval(() => { let text: string; try { text = normalizeClipboardText(clipboard.readText()); } catch { return; } if (text === lastClipboardText || !text.trim()) { return; } lastClipboardText = text; const links = extractLinksFromText(text); if (links.length > 0 && mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(IPC_CHANNELS.CLIPBOARD_DETECTED, links); } }, 2000); } function stopClipboardWatcher(): void { if (clipboardTimer) { clearInterval(clipboardTimer); clipboardTimer = null; } } function updateClipboardWatcher(): void { const settings = controller.getSettings(); if (settings.clipboardWatch) { startClipboardWatcher(); } else { stopClipboardWatcher(); } } function updateTray(): void { const settings = controller.getSettings(); if (settings.minimizeToTray) { createTray(); } else { destroyTray(); } } function registerIpcHandlers(): void { handleTrusted(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot()); handleTrusted(IPC_CHANNELS.GET_VERSION, () => controller.getVersion()); handleTrusted(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates()); handleTrusted(IPC_CHANNELS.INSTALL_UPDATE, async () => { const result = await controller.installUpdate((progress: UpdateInstallProgress) => { if (!mainWindow || mainWindow.isDestroyed()) { return; } mainWindow.webContents.send(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, progress); }); if (result.started) { updateQuitTimer = setTimeout(() => { app.quit(); }, 250); } return result; }); handleTrusted(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => { return openAllowedExternalUrl(String(rawUrl || "").trim(), MAIN_WINDOW_EXTERNAL_HOSTS); }); handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => { const currentSettings = controller.getSettings(); const validated = validateRendererSettingsUpdate(partial ?? {}, currentSettings); const result = controller.updateSettings(prepareDailyStartSettingsPatch(validated, currentSettings) as Partial); updateClipboardWatcher(); updateTray(); armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true }); if (hasDailyStartRulePatch(validated)) { await dailyStartScheduler?.reconcile(); } else { reconcileDailyStart("Einstellungsänderung"); } return createRendererSettings(controller.getSettings()); }); handleTrusted(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => { const validatedProvider = validateString(provider, "provider") as DebridProvider; if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) { throw new Error("provider ist ungültig"); } return createRendererSettings(controller.resetProviderDailyUsage(validatedProvider)); }); handleTrusted(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => { const validatedKeyId = validateString(keyId, "keyId").trim(); if (!validatedKeyId) { throw new Error("keyId ist ungültig"); } return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId)); }); handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => { const command = validateAccountCommand(rawCommand); if (command.action !== "create") throw new Error("Account-Payload ist ungültig"); const result = await controller.executeAccountCommand(command); reconcileDailyStart("Accountänderung"); return result; }); handleTrusted(IPC_CHANNELS.REPLACE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => { const command = validateAccountCommand(rawCommand); if (command.action !== "replace") throw new Error("Account-Payload ist ungültig"); const result = await controller.executeAccountCommand(command); reconcileDailyStart("Accountänderung"); return result; }); handleTrusted(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => { const command = validateAccountCommand(rawCommand); if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig"); const result = await controller.executeAccountCommand(command); reconcileDailyStart("Accountänderung"); return result; }); handleTrusted(IPC_CHANNELS.DELETE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => { const command = validateAccountCommand(rawCommand); if (command.action !== "delete") throw new Error("Account-Payload ist ungültig"); const result = await controller.executeAccountCommand(command); reconcileDailyStart("Accountänderung"); return result; }); handleTrusted(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawRequest: unknown) => { return controller.revealAccountSecret(validateAccountSecretRequest(rawRequest)); }); handleTrusted(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST, () => controller.getArchivePasswordList()); handleTrusted(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { validatePlainObject(payload ?? {}, "payload"); validateString(payload?.rawText, "rawText"); if (payload.packageName !== undefined) { validateString(payload.packageName, "packageName"); } if (payload.duplicatePolicy !== undefined && payload.duplicatePolicy !== "keep" && payload.duplicatePolicy !== "skip" && payload.duplicatePolicy !== "overwrite") { throw new Error("duplicatePolicy muss 'keep', 'skip' oder 'overwrite' sein"); } return controller.addLinks(payload); }); handleTrusted(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { const validPaths = validateStringArray(filePaths ?? [], "filePaths"); const safePaths = validPaths.filter((p) => path.isAbsolute(p)); return controller.addContainers(safePaths); }); handleTrusted(IPC_CHANNELS.PREPARE_COLLECTOR_TEXT, (_event: IpcMainInvokeEvent, value: unknown) => { return controller.prepareCollectorText(validateCollectorTextPreparationRequest(value)); }); handleTrusted(IPC_CHANNELS.PREPARE_COLLECTOR_CONTAINERS, (_event: IpcMainInvokeEvent, filePaths: unknown, addedAt: unknown) => { const request = validateCollectorContainerPreparationRequest({ filePaths, addedAt }); return controller.prepareCollectorContainers(request.filePaths, request.addedAt); }); handleTrusted(IPC_CHANNELS.ENRICH_COLLECTOR_PACKAGES, (event: IpcMainInvokeEvent, value: unknown) => { const request = validateCollectorEnrichmentRequest(value); return controller.enrichCollectorPackages(request, (result) => { if (!event.sender.isDestroyed()) { event.sender.send(IPC_CHANNELS.COLLECTOR_ENRICHMENT_PROGRESS, { requestId: request.requestId, result }); } }); }); handleTrusted(IPC_CHANNELS.GET_COLLECTOR_STATE, () => controller.getCollectorState()); onTrusted(IPC_CHANNELS.GET_COLLECTOR_STATE_SYNC, (event: IpcMainEvent) => { getCollectorStateFromSyncIpc(controller, event); }); handleTrusted(IPC_CHANNELS.SAVE_COLLECTOR_STATE, (_event: IpcMainInvokeEvent, value: unknown) => { return controller.saveCollectorState(validateCollectorPersistenceState(value)); }); onTrusted(IPC_CHANNELS.SAVE_COLLECTOR_STATE_SYNC, (event: IpcMainEvent, value: unknown) => { saveCollectorStateFromSyncIpc(controller, event, value); }); handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { validateString(packageId, "packageId"); validateString(policy, "policy"); if (policy !== "keep" && policy !== "skip" && policy !== "overwrite") { throw new Error("policy muss 'keep', 'skip' oder 'overwrite' sein"); } return controller.resolveStartConflict(packageId, policy); }); handleTrusted(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll()); handleTrusted(IPC_CHANNELS.START, () => { if (scheduledStartTimer !== null) { clearTimeout(scheduledStartTimer); scheduledStartTimer = null; controller.updateSettings({ scheduledStartEpochMs: 0 }); } return controller.start(); }); handleTrusted(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { validateStringArray(packageIds ?? [], "packageIds"); return controller.startPackages(packageIds ?? []); }); handleTrusted(IPC_CHANNELS.START_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { validateStringArray(itemIds ?? [], "itemIds"); return controller.startItems(itemIds ?? []); }); handleTrusted(IPC_CHANNELS.STOP, () => controller.stop()); handleTrusted(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause()); handleTrusted(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.cancelPackage(packageId); }); handleTrusted(IPC_CHANNELS.REMOVE_OFFLINE_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { return controller.removeOfflinePackages(validateStringArray(packageIds, "packageIds")); }); handleTrusted(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => { validateString(packageId, "packageId"); validateString(newName, "newName"); if (newName.length > RENAME_PACKAGE_MAX_CHARS) { throw new Error(`newName zu lang (max ${RENAME_PACKAGE_MAX_CHARS} Zeichen)`); } return controller.renamePackage(packageId, newName); }); handleTrusted(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { validateStringArray(packageIds, "packageIds"); return controller.reorderPackages(packageIds); }); handleTrusted(IPC_CHANNELS.REMOVE_ITEM, (_event: IpcMainInvokeEvent, itemId: string) => { validateString(itemId, "itemId"); return controller.removeItem(itemId); }); handleTrusted(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.togglePackage(packageId); }); handleTrusted(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => { const validPackageIds = validateStringArray(packageIds ?? [], "packageIds"); const exported = controller.exportPackageSelection(validPackageIds); if (exported.packageCount === 0 || exported.linkCount === 0) { return { saved: false, packageCount: 0, linkCount: 0 }; } const options = { defaultPath: exported.defaultFileName, filters: [{ name: "Link Export", extensions: ["txt"] }] }; const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); if (result.canceled || !result.filePath) { return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount }; } await fs.promises.writeFile(result.filePath, exported.text, "utf8"); return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; }); handleTrusted(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => { const validItemIds = validateStringArray(itemIds ?? [], "itemIds"); const exported = controller.exportItemSelection(validItemIds); if (exported.packageCount === 0 || exported.linkCount === 0) { return { saved: false, packageCount: 0, linkCount: 0 }; } const options = { defaultPath: exported.defaultFileName, filters: [{ name: "Link Export", extensions: ["txt"] }] }; const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); if (result.canceled || !result.filePath) { return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount }; } await fs.promises.writeFile(result.filePath, exported.text, "utf8"); return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; }); handleTrusted(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.retryExtraction(packageId); }); handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.extractNow(packageId); }); handleTrusted(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.resetPackage(packageId); }); handleTrusted(IPC_CHANNELS.SET_PACKAGE_PRIORITY, (_event: IpcMainInvokeEvent, packageId: string, priority: string) => { validateString(packageId, "packageId"); validateString(priority, "priority"); if (priority !== "high" && priority !== "normal" && priority !== "low") { throw new Error("priority muss 'high', 'normal' oder 'low' sein"); } return controller.setPackagePriority(packageId, priority); }); handleTrusted(IPC_CHANNELS.SKIP_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { validateStringArray(itemIds ?? [], "itemIds"); return controller.skipItems(itemIds ?? []); }); handleTrusted(IPC_CHANNELS.RESET_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { validateStringArray(itemIds ?? [], "itemIds"); return controller.resetItems(itemIds ?? []); }); handleTrusted(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory()); handleTrusted(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory()); handleTrusted(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => { validateString(entryId, "entryId"); return controller.removeHistoryEntry(entryId); }); handleTrusted(IPC_CHANNELS.REMOVE_HISTORY_ENTRIES, (_event: IpcMainInvokeEvent, entryIds: unknown) => { return controller.removeHistoryEntries(validateHistoryEntryIds(entryIds)); }); handleTrusted(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: unknown) => { return revealHistoryEntry({ entryId }, { loadHistory: () => controller.getHistory(), stat: (directory) => fs.promises.stat(directory), openPath: (directory) => shell.openPath(directory) }); }); handleTrusted(IPC_CHANNELS.EXPORT_QUEUE, async () => { const options = { defaultPath: `rd-queue-export.json`, filters: [{ name: "Queue Export", extensions: ["json"] }] }; const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); if (result.canceled || !result.filePath) { return { saved: false }; } const json = controller.exportQueue(); await fs.promises.writeFile(result.filePath, json, "utf8"); return { saved: true }; }); handleTrusted(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => { validateString(json, "json"); const bytes = Buffer.byteLength(json, "utf8"); if (bytes > IMPORT_QUEUE_MAX_BYTES) { throw new Error(`Queue-Import zu groß (max ${IMPORT_QUEUE_MAX_BYTES} Bytes)`); } return controller.importQueue(json); }); handleTrusted(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => { const settings = controller.getSettings(); const next = !settings.clipboardWatch; controller.updateSettings({ clipboardWatch: next }); updateClipboardWatcher(); return next; }); handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => { const text = validateClipboardWriteText(rawText); const bytes = Buffer.byteLength(text, "utf8"); try { clipboard.writeText(text); return true; } catch (error) { logger.warn(`Zwischenablage-Schreibfehler: bytes=${bytes}, error=${String(error)}`); throw error; } }); handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => { const options = { properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory"> }; const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); return result.canceled ? null : result.filePaths[0] || null; }); handleTrusted(IPC_CHANNELS.PICK_PROXY_LIST, async () => { const options = { properties: ["openFile"] as Array<"openFile">, filters: [ { name: "Proxy-Listen", extensions: ["txt", "list"] }, { name: "Alle Dateien", extensions: ["*"] } ] }; const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); return result.canceled ? null : result.filePaths[0] || null; }); handleTrusted(IPC_CHANNELS.PICK_CONTAINERS, async () => { const options = { properties: ["openFile", "multiSelections"] as Array<"openFile" | "multiSelections">, filters: [ { name: "Container", extensions: ["dlc"] }, { name: "Alle Dateien", extensions: ["*"] } ] }; const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); return result.canceled ? [] : result.filePaths; }); handleTrusted(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); handleTrusted(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats()); handleTrusted(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats()); handleTrusted(IPC_CHANNELS.RESTART, () => { scheduleApplicationRelaunch(); app.quit(); }); handleTrusted(IPC_CHANNELS.QUIT, () => { app.quit(); }); handleTrusted(IPC_CHANNELS.EXPORT_BACKUP, async (_event: IpcMainInvokeEvent, rawPassphrase: unknown) => { const passphrase = validateString(rawPassphrase, "passphrase"); const options = { defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`, filters: [{ name: "MDD Backup", extensions: ["mdd"] }] }; const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); if (result.canceled || !result.filePath) { return { saved: false }; } const encrypted = controller.exportBackup(passphrase); await fs.promises.writeFile(result.filePath, encrypted); return { saved: true }; }); handleTrusted(IPC_CHANNELS.EXPORT_ONLINE_BACKUP, async () => controller.exportOnlineBackup()); handleTrusted(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, async (_event: IpcMainInvokeEvent, rawKey: unknown) => { const key = validateString(rawKey, "key").trim(); if (key.length > 128) { throw new Error("Online-Sicherungsschlüssel ist ungültig"); } return controller.importOnlineBackup(key); }); handleTrusted(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 = await controller.exportSupportBundle(); await fs.promises.writeFile(result.filePath, exported.buffer); return { saved: true, filePath: result.filePath }; }); 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); } }); handleTrusted(IPC_CHANNELS.OPEN_RENAME_LOG, async () => { const logPath = controller.getRenameLogPath(); if (logPath) { await shell.openPath(logPath); } }); handleTrusted(IPC_CHANNELS.OPEN_SESSION_LOG, async () => { const logPath = controller.getSessionLogPath(); if (logPath) { await shell.openPath(logPath); } }); handleTrusted(IPC_CHANNELS.OPEN_TRACE_LOG, async () => { const logPath = controller.getTraceLogPath(); if (logPath) { await shell.openPath(logPath); } }); handleTrusted(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); const logPath = controller.getPackageLogPath(packageId); if (logPath) { await shell.openPath(logPath); } }); handleTrusted(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck()); handleTrusted(IPC_CHANNELS.GET_RECENT_ERRORS, async () => getRecentErrors()); handleTrusted(IPC_CHANNELS.TEST_NOTIFY, async (_event: IpcMainInvokeEvent, url: string, mention: string) => { validateString(url, "url"); return sendNotification(url, { title: "🔔 Test-Benachrichtigung", message: "Webhook funktioniert — Benachrichtigungen kommen hier an.", mention: typeof mention === "string" ? mention : "" }); }); handleTrusted(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig()); handleTrusted(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => { if (typeof enabled !== "boolean") { throw new Error("enabled muss ein Boolean sein"); } if (note !== undefined) { validateString(note, "note"); } if (durationMinutes !== undefined && (!Number.isFinite(durationMinutes) || durationMinutes <= 0)) { throw new Error("durationMinutes muss eine positive Zahl sein"); } return controller.setTraceEnabled(enabled, note, durationMinutes ? durationMinutes * 60 * 1000 : undefined); }); handleTrusted(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => { const rotated = controller.rotateDebugToken(); return { path: rotated.path }; }); handleTrusted(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => { return controller.getRemoteDiagnostics(); }); handleTrusted(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => { if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) { throw new Error("hostMode muss 'local' oder 'network' sein"); } const allowlist = Array.isArray(input.allowlist) ? input.allowlist.map((entry) => String(entry)) : []; return controller.enableRemoteDiagnostics({ hostMode: input.hostMode, publicHost: String(input.publicHost || ""), port: input.port ? Number(input.port) : undefined, allowlist, name: input.name ? String(input.name) : undefined, rotateToken: Boolean(input.rotateToken) }); }); handleTrusted(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => { return controller.disableRemoteDiagnostics(); }); handleTrusted(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => { return controller.rotateRemoteDiagnosticsToken(); }); handleTrusted(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { validateString(itemId, "itemId"); const logPath = controller.getItemLogPath(itemId); if (logPath) { await shell.openPath(logPath); } }); handleTrusted(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async (_event: IpcMainInvokeEvent, rawRequest: unknown) => { await controller.openRealDebridLoginWindow(validateRealDebridLoginRequest(rawRequest)); }); handleTrusted(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => { await controller.openAllDebridLoginWindow(); }); handleTrusted(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES, async () => { const options = { properties: ["openFile"] as Array<"openFile">, filters: [ { name: "Cookie-Datei", extensions: ["txt"] }, { name: "Alle Dateien", extensions: ["*"] } ] }; const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); if (result.canceled || result.filePaths.length === 0) { return 0; } return controller.importBestDebridCookies(result.filePaths[0]); }); handleTrusted(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO, async () => { return controller.getAllDebridHostInfo(); }); handleTrusted(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => { return controller.getDebridLinkHostLimits(); }); handleTrusted(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async (_event, rawScope: unknown) => { const scope = rawScope === undefined ? "active" : validateString(rawScope, "scope"); if (scope !== "active" && scope !== "all") { throw new Error("scope ist ungültig"); } return controller.checkDebridAccounts(scope); }); handleTrusted(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, async (_event, rawInput: unknown) => { return controller.checkAccountCredentials(validateAccountCredentialCheckInput(rawInput)); }); handleTrusted(IPC_CHANNELS.SELECT_BACKUP_IMPORT, async () => { pendingBackupImport = null; const options = { properties: ["openFile"] as Array<"openFile">, filters: [ { name: "MDD Backup", extensions: ["mdd"] }, { name: "Legacy Backup (JSON)", extensions: ["json"] }, { name: "Alle Dateien", extensions: ["*"] } ] }; const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); if (result.canceled || result.filePaths.length === 0) { return { selected: false, requiresPassphrase: false, message: "Abgebrochen" }; } const filePath = result.filePaths[0]; const stat = await fs.promises.stat(filePath); const BACKUP_MAX_BYTES = 50 * 1024 * 1024; if (stat.size > BACKUP_MAX_BYTES) { return { selected: false, requiresPassphrase: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` }; } const data = await fs.promises.readFile(filePath); pendingBackupImport = data; return { selected: true, requiresPassphrase: isMdd2Backup(data) }; }); handleTrusted(IPC_CHANNELS.CANCEL_BACKUP_IMPORT, () => { pendingBackupImport = null; }); handleTrusted(IPC_CHANNELS.IMPORT_BACKUP, async (_event: IpcMainInvokeEvent, rawPassphrase?: unknown) => { const data = pendingBackupImport; pendingBackupImport = null; if (!data) { return { restored: false, relaunch: false, message: "Keine Backup-Datei ausgewählt" }; } const passphrase = typeof rawPassphrase === "string" ? rawPassphrase : undefined; const importResult = await controller.importBackup(data, passphrase); // Only a full restore (queue swapped) needs the auto-relaunch. A settings- // only import applied live — relaunching would be pointless and would drop // the running queue. if (importResult.restored && importResult.relaunch) { setTimeout(() => { scheduleApplicationRelaunch(); app.quit(); }, 1500); } return importResult; }); onTrusted(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => { try { logger.error(formatRendererErrorReport(rawReport)); } catch (error) { logger.error(`[Renderer] Fehlerbericht konnte nicht verarbeitet werden: ${String(error)}`); } }); controller.onState = (snapshot) => { if (!mainWindow || mainWindow.isDestroyed()) { return; } mainWindow.webContents.send(IPC_CHANNELS.STATE_UPDATE, snapshot); }; controller.onHistoryEntryAdded = (entry) => { if (!mainWindow || mainWindow.isDestroyed()) { return; } mainWindow.webContents.send(IPC_CHANNELS.HISTORY_ENTRY_ADDED, entry); }; } function formatRendererErrorReport(rawReport: unknown): string { const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record; const str = (value: unknown): string => (typeof value === "string" ? value : ""); const num = (value: unknown): string => (typeof value === "number" && Number.isFinite(value) ? String(value) : ""); const kind = str(report.kind) || "error"; const message = (str(report.message) || "(ohne Nachricht)").slice(0, 2000); const source = str(report.source); const line = num(report.line); const column = num(report.column); const stack = str(report.stack).slice(0, 4000); const componentStack = str(report.componentStack).slice(0, 4000); const parts: string[] = [`[Renderer:${kind}] ${message}`]; if (source) { parts.push(`@ ${source}${line ? `:${line}${column ? `:${column}` : ""}` : ""}`); } if (stack) { parts.push(`| stack: ${stack.replace(/\s*\n\s*/g, " ⏎ ")}`); } if (componentStack) { parts.push(`| react: ${componentStack.replace(/\s*\n\s*/g, " ⏎ ")}`); } return parts.join(" "); } app.on("login", (event, _webContents, _requestDetails, authInfo, callback) => { const credentials = getProxyAuthentication(authInfo); if (!credentials) return; event.preventDefault(); callback(credentials.username, credentials.password); }); app.on("child-process-gone", (_event, details) => { const killed = details.reason !== "clean-exit" && details.reason !== "killed"; const line = `Subprozess beendet: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? "?"}${details.name ? ` name=${details.name}` : ""}${details.serviceName ? ` service=${details.serviceName}` : ""}`; if (killed) { logger.error(line); } else { logger.warn(line); } }); app.on("second-instance", () => { if (!applicationStarted) { return; } if (appShutdownStarted) { if (scheduleApplicationRelaunch()) { logger.info("Zweite Instanz während des Beendens erkannt – Neustart wird eingeplant"); } return; } const result = restoreOrCreateMainWindow(mainWindow, createWindow, bindMainWindowLifecycle); mainWindow = result.window; logger.info(result.created ? "Zweite Instanz erkannt – Hauptfenster neu erstellt" : "Zweite Instanz erkannt – Hauptfenster wiederhergestellt"); }); app.whenReady().then(() => { configureCredentialProtector(safeStorage); controller = new AppController(); dailyStartScheduler = new DailyStartScheduler(controller); cleanupStaleSubstDrives(); registerIpcHandlers(); mainWindow = createWindow(); bindMainWindowLifecycle(mainWindow); applicationStarted = true; updateClipboardWatcher(); updateTray(); // A scheduled start persists in the settings but its timer lived only in this // process — without re-arming it here, any restart (auto-update, reboot, // crash) silently swallowed the planned run. armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false }); dailyStartScheduler.begin((error) => { logger.warn(`Täglicher Start konnte nicht abgeglichen werden: ${String(error)}`); }); powerMonitor.on("suspend", handlePowerSuspend); powerMonitor.on("resume", handlePowerResume); app.on("activate", () => { if (!applicationStarted || appShutdownStarted) { return; } const result = restoreOrCreateMainWindow(mainWindow, createWindow, bindMainWindowLifecycle); mainWindow = result.window; }); }).catch((error) => { logger.error(`App-Start fehlgeschlagen: ${String(error?.stack || error)}`); console.error("App startup failed:", error); app.quit(); }); app.on("window-all-closed", () => { logger.warn("Alle Hauptfenster geschlossen"); if (process.platform !== "darwin") { app.quit(); } }); const confirmApplicationQuit = createConfirmedQuitHandler({ requestQuit: () => app.quit(), forceQuit: () => { shutdownDaemon(); app.exit(0); }, onQuit: (listener) => app.once("quit", listener), onTimeout: () => { logger.error(`Fenster blockieren das Beenden seit ${APP_QUIT_CONFIRM_TIMEOUT_MS} ms – Prozess wird beendet`); }, timeoutMs: APP_QUIT_CONFIRM_TIMEOUT_MS }); app.on("before-quit", createBeforeQuitHandler({ cleanup: () => { appShutdownStarted = true; if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; } cleanupSchedulerLifecycle(dailyStartScheduler, scheduledStartTimer); scheduledStartTimer = null; stopClipboardWatcher(); destroyTray(); }, shutdown: async () => { try { if (controller) { await controller.shutdown(); } } finally { shutdownDaemon(); } }, continueQuit: confirmApplicationQuit, forceQuit: () => { shutdownDaemon(); app.exit(0); }, onError: (error) => { logger.error(`Fehler beim Shutdown: ${String(error)}`); }, onTimeout: () => { logger.error(`Shutdown nach ${APP_SHUTDOWN_TIMEOUT_MS} ms erzwungen`); }, timeoutMs: APP_SHUTDOWN_TIMEOUT_MS }));