import { app, BrowserWindow, dialog, shell } from "electron"; import { ChildProcess, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; const APP_TITLE = "Scoreko"; const DEFAULT_NODECG_PORT = process.env.NODECG_PORT ?? "9090"; const DEFAULT_BUNDLE_NAME = process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev"; const DEFAULT_DASHBOARD_ROUTE = process.env.SCOREKO_DASHBOARD_ROUTE ?? "dashboard/example/main.html?standalone=true"; const DEFAULT_LOADING_ROUTE = process.env.SCOREKO_LOADING_ROUTE ?? "dashboard/loading/main.html?standalone=true"; const LOAD_DELAY_MS = Number.parseInt(process.env.ELECTRON_LOAD_DELAY_MS ?? "2500", 10); const STARTUP_TIMEOUT_MS = Number.parseInt(process.env.NODECG_STARTUP_TIMEOUT_MS ?? "30000", 10); const USE_SYSTEM_NODE = (process.env.NODECG_USE_SYSTEM_NODE ?? "false").toLowerCase() === "true"; const NODE_BINARY = process.env.NODECG_NODE_BINARY ?? "node"; const isDev = !app.isPackaged; const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath; const nodecgPath = path.resolve(rootPath, "lib", "nodecg"); const dashboardUrl = `http://localhost:${DEFAULT_NODECG_PORT}/bundles/${DEFAULT_BUNDLE_NAME}/${DEFAULT_DASHBOARD_ROUTE}`; const loadingUrl = `http://localhost:${DEFAULT_NODECG_PORT}/bundles/${DEFAULT_BUNDLE_NAME}/${DEFAULT_LOADING_ROUTE}`; const baseUrl = `http://127.0.0.1:${DEFAULT_NODECG_PORT}`; let mainWindow: BrowserWindow | null = null; let loadingWindow: BrowserWindow | null = null; let nodecgProcess: ChildProcess | null = null; let lastNodeCGOutput = ""; function createMainWindow(): BrowserWindow { const win = new BrowserWindow({ show: false, title: APP_TITLE, width: 1440, height: 900, minWidth: 960, minHeight: 640, backgroundColor: "#0f0f0f", webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false, }, }); win.setMenuBarVisibility(false); win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url).catch((error) => { log("Error opening external url", url, error); }); return { action: "deny" }; }); win.webContents.on("will-navigate", (event, url) => { if (url !== dashboardUrl) { event.preventDefault(); shell.openExternal(url).catch((error) => { log("Error opening navigation url", url, error); }); } }); win.on("page-title-updated", (event) => { event.preventDefault(); }); return win; } function createLoadingWindow(): BrowserWindow { const win = new BrowserWindow({ show: false, frame: false, title: APP_TITLE, width: 420, height: 280, resizable: false, movable: true, minimizable: false, maximizable: false, backgroundColor: "#0f0f0f", webPreferences: { contextIsolation: true, sandbox: true, }, }); win.on("page-title-updated", (event) => { event.preventDefault(); }); return win; } function validateNodeCGInstall(): void { const indexPath = path.join(nodecgPath, "index.js"); const nodecgBootstrapPath = path.join(nodecgPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"); const bundlePath = path.join(nodecgPath, "bundles", DEFAULT_BUNDLE_NAME); if (!fs.existsSync(nodecgPath)) { throw new Error(`No existe la carpeta NodeCG: ${nodecgPath}`); } if (!fs.existsSync(indexPath)) { throw new Error(`No se encontró ${indexPath}. Copia una instalación completa de NodeCG en lib/nodecg.`); } if (!fs.existsSync(nodecgBootstrapPath)) { throw new Error( [ "NodeCG está presente pero faltan dependencias internas.", `No existe: ${nodecgBootstrapPath}`, "Solución: entra a lib/nodecg e instala dependencias:", " npm install", ].join("\n"), ); } if (!fs.existsSync(bundlePath)) { throw new Error( [ `No se encontró el bundle '${DEFAULT_BUNDLE_NAME}'.`, `Ruta esperada: ${bundlePath}`, "Copia/clona tu bundle dentro de lib/nodecg/bundles antes de ejecutar Electron.", ].join("\n"), ); } } function enrichNodeCGFailureMessage(baseMessage: string): string { if (lastNodeCGOutput.includes("Cannot find module 'bindings'")) { return [ baseMessage, "", "Detectado error: falta el módulo 'bindings' en el workspace sqlite legacy.", "Normalmente pasa cuando dependencias del workspace quedaron incompletas.", "", "Solución recomendada:", " 1) cd lib/nodecg/workspaces/database-adapter-sqlite-legacy", " 2) npm install", " 3) npm install bindings --no-save", " 4) cd ../../../../", " 5) npm run rebuild:native", ].join("\n"); } if (lastNodeCGOutput.includes("NODE_MODULE_VERSION")) { return [ baseMessage, "", "Detectado error de módulos nativos compilados para otra versión de Node (NODE_MODULE_VERSION).", USE_SYSTEM_NODE ? "Estás en modo Node del sistema: asegúrate de lanzar con Node 22 y recompilar dependencias nativas." : "Estás en modo standalone (Node interno de Electron). Reinstala/rebuild de dependencias con esta versión de Electron.", "", "Solución recomendada:", " npm run rebuild:native", " npm run rebuild:better-sqlite3:electron", ].join("\n"); } return baseMessage; } function startNodeCG(): ChildProcess { validateNodeCGInstall(); const indexPath = path.join(nodecgPath, "index.js"); const runtimeBinary = USE_SYSTEM_NODE ? NODE_BINARY : process.execPath; const runtimeName = USE_SYSTEM_NODE ? `system node (${NODE_BINARY})` : "electron internal node"; const child = spawn(runtimeBinary, [indexPath], { cwd: nodecgPath, env: { ...process.env, NODE_ENV: isDev ? "development" : "production", NODECG_PORT: DEFAULT_NODECG_PORT, ...(USE_SYSTEM_NODE ? {} : { ELECTRON_RUN_AS_NODE: "1" }), }, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", }); child.stdout?.on("data", (chunk) => { const text = String(chunk); process.stdout.write(text); lastNodeCGOutput = `${lastNodeCGOutput}${text}`.slice(-20000); }); child.stderr?.on("data", (chunk) => { const text = String(chunk); process.stderr.write(text); lastNodeCGOutput = `${lastNodeCGOutput}${text}`.slice(-20000); }); log(`NodeCG started with pid=${child.pid} using ${runtimeName}`); child.on("exit", (code, signal) => { log(`NodeCG exited code=${code} signal=${signal ?? "none"}`); nodecgProcess = null; }); return child; } async function waitForNodeCGReady(startTime: number): Promise { while (Date.now() - startTime < STARTUP_TIMEOUT_MS) { if (!nodecgProcess) { throw new Error("NodeCG terminó antes de estar listo."); } try { const response = await fetch(baseUrl, { method: "GET" }); if (response.ok || response.status === 404) { return; } } catch { // retry until timeout } await sleep(500); } throw new Error(`Timeout esperando NodeCG en ${baseUrl} (${STARTUP_TIMEOUT_MS}ms).`); } function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } async function launch(): Promise { mainWindow = createMainWindow(); loadingWindow = createLoadingWindow(); loadingWindow.show(); lastNodeCGOutput = ""; nodecgProcess = startNodeCG(); await sleep(Math.max(0, LOAD_DELAY_MS)); await waitForNodeCGReady(Date.now()); try { await loadingWindow.loadURL(loadingUrl); } catch (error) { log("No se pudo cargar la ruta de loading del bundle", loadingUrl, error); } if (!mainWindow) { return; } try { await mainWindow.loadURL(dashboardUrl); mainWindow.show(); } catch (error) { throw new Error(`No se pudo cargar el dashboard en ${dashboardUrl}. ${String(error)}`); } if (loadingWindow && !loadingWindow.isDestroyed()) { loadingWindow.close(); loadingWindow = null; } } function stopNodeCG(): void { if (!nodecgProcess || nodecgProcess.killed) { return; } log(`Stopping NodeCG pid=${nodecgProcess.pid}`); nodecgProcess.kill("SIGTERM"); } function log(...args: unknown[]): void { console.log("[scoreko-electron]", ...args); } app.on("ready", () => { launch().catch(async (error: unknown) => { console.error("Failed to launch Scoreko wrapper", error); const detail = enrichNodeCGFailureMessage(error instanceof Error ? error.message : String(error)); await dialog.showMessageBox({ type: "error", title: "No se pudo iniciar Scoreko", message: "Fallo al iniciar NodeCG", detail, }); app.exit(1); }); }); app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createMainWindow(); await mainWindow.loadURL(dashboardUrl); mainWindow.show(); } }); app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } }); app.on("before-quit", () => { stopNodeCG(); }); process.on("exit", () => { stopNodeCG(); });