Files
Multi-Debrid-Downloader/src/main/main.ts
T

1397 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${name} muss ein Objekt sein`);
}
return value as Record<string, unknown>;
}
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
const RENAME_PACKAGE_MAX_CHARS = 240;
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
"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<typeof setInterval> | null = null;
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
let scheduledStartTimer: ReturnType<typeof setTimeout> | 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<DailyStartScheduler, "end"> | null,
legacyTimer: ReturnType<typeof setTimeout> | null
): void {
scheduler?.end();
if (legacyTimer !== null) {
clearTimeout(legacyTimer);
}
powerMonitor.removeListener("suspend", handlePowerSuspend);
powerMonitor.removeListener("resume", handlePowerResume);
}
export interface BeforeQuitHandlerOptions {
cleanup: () => void;
shutdown: () => Promise<void>;
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<typeof setTimeout> | 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<void>;
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<T extends MainWindowActivationTarget>(
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<TArgs extends unknown[]>(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<TArgs extends unknown[]>(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<AppController, "saveCollectorStateSync">,
event: Pick<IpcMainEvent, "returnValue">,
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<AppController, "getCollectorState">,
event: Pick<IpcMainEvent, "returnValue">
): 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<AppSettings>);
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<string, unknown>;
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
}));