import { app, BrowserWindow } from "electron"; import path from "node:path"; import { getRuntimeConfig } from "./config/runtime-config"; import { showFatalError, log } from "./errors/error-presenter"; import { createNodecgProcessManager, NodecgProcessManager } from "./nodecg/process-manager"; import { prepareUserNodecgRuntime } from "./nodecg/runtime-provisioner"; import { getRemainingDelayMs } from "./utils/timing"; import { createLoadingWindow, createMainWindow } from "./windows/window-factory"; const appConfig = getRuntimeConfig(); // Force a stable userData folder name; overridable via SCOREKO_APP_USER_DATA_DIRECTORY. app.setName(appConfig.title); app.setPath("userData", path.join(app.getPath("appData"), appConfig.userDataDirectoryName)); const isDev = !app.isPackaged; const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath; const sourceNodecgRuntimePath = path.resolve(rootPath, "lib", "nodecg"); const mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`; const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`; const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`; const hasSingleInstanceLock = app.requestSingleInstanceLock(); if (!hasSingleInstanceLock) { app.quit(); } type AppShutdownState = "running" | "stopping" | "stopped"; let mainWindow: BrowserWindow | null = null; let loadingWindow: BrowserWindow | null = null; let nodecgManager: NodecgProcessManager | null = null; let shutdownState: AppShutdownState = "running"; function focusExistingWindow(): void { const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow; if (!targetWindow || targetWindow.isDestroyed()) { return; } if (targetWindow.isMinimized()) { targetWindow.restore(); } targetWindow.show(); targetWindow.focus(); } async function launchApplication(): Promise { const nodecgRootPath = prepareUserNodecgRuntime({ sourceRuntimePath: sourceNodecgRuntimePath, userDataPath: app.getPath("userData"), appVersion: app.getVersion(), bundleName: appConfig.bundleName, log, }); nodecgManager = createNodecgProcessManager({ isDev, nodecgRootPath, nodecgBaseUrl, appConfig, log, }); // We create both windows early so startup feels instant while NodeCG is booting in the background. mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl }); loadingWindow = createLoadingWindow({ appConfig, rootPath }); await nodecgManager.startNodecgProcess(); await nodecgManager.waitForNodecgReady(Date.now()); if (!loadingWindow || loadingWindow.isDestroyed()) { return; } await loadingWindow.loadURL(loadingDashboardUrl); loadingWindow.show(); const loadingShownAt = Date.now(); if (!mainWindow) { return; } await mainWindow.loadURL(mainDashboardUrl); // Keep the loading overlay visible for a minimum amount of time to avoid abrupt flashes. const remainingLoadingDelay = getRemainingDelayMs(appConfig.loadDelayMs, loadingShownAt); if (remainingLoadingDelay > 0) { await sleep(remainingLoadingDelay); } mainWindow.show(); closeLoadingWindow(); } function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function closeLoadingWindow(): void { if (!loadingWindow || loadingWindow.isDestroyed()) { return; } loadingWindow.close(); loadingWindow = null; } function stopNodecgGracefully(): Promise { if (shutdownState === "stopped") { return Promise.resolve(); } if (shutdownState === "stopping") { return nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve(); } shutdownState = "stopping"; return (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve()).finally(() => { shutdownState = "stopped"; }); } app.on("ready", () => { if (!hasSingleInstanceLock) { return; } if (process.platform === "win32") { app.setAppUserModelId(appConfig.userModelId); } launchApplication().catch((error: unknown) => { showFatalError("No se pudo iniciar Scoreko.", error); closeLoadingWindow(); app.exit(1); }); }); app.on("second-instance", () => { focusExistingWindow(); }); app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl }); await mainWindow.loadURL(mainDashboardUrl); mainWindow.show(); } }); app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } }); app.on("before-quit", (event) => { if (shutdownState !== "running") { return; } // Block the default quit flow until we ask NodeCG to stop cleanly. event.preventDefault(); stopNodecgGracefully().finally(() => { app.quit(); }); }); app.on("will-quit", () => { if (shutdownState === "running") { void stopNodecgGracefully(); } }); process.on("exit", () => { if (shutdownState === "running") { void stopNodecgGracefully(); } }); process.on("uncaughtException", (error) => { showFatalError("Unexpected error in Electron main process.", error); }); process.on("unhandledRejection", (reason) => { showFatalError("Unhandled promise in Electron main process.", reason); });