diff --git a/README.md b/README.md index f4954e1..f27404c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ scoreko-electron-dev/ ├─ lib/ │ └─ nodecg/ │ ├─ index.js +│ ├─ node_modules/ │ └─ bundles/ │ └─ scoreko-dev/ # clonado/copiado desde tu repo scoreko-dev ├─ src/main/main.ts @@ -27,19 +28,51 @@ scoreko-electron-dev/ 1. Copia o clona tu instalación de NodeCG en `lib/nodecg`. 2. Copia tu bundle `scoreko-dev` a `lib/nodecg/bundles/scoreko-dev`. -3. Instala dependencias de NodeCG dentro de `lib/nodecg` (si aplica). +3. **Instala dependencias de NodeCG dentro de `lib/nodecg`**. 4. Instala dependencias del wrapper: ```bash npm install +cd lib/nodecg +npm install +cd ../.. ``` +> Si no haces `npm install` dentro de `lib/nodecg`, verás errores como `Cannot find module ... node_modules/nodecg/dist/server/bootstrap.js`. + + +## Error típico: NODE_MODULE_VERSION (Node 22 vs Electron Node 20) + +Si ves un error como `better-sqlite3 ... NODE_MODULE_VERSION`, tienes módulos nativos compilados para una versión distinta de Node. + +1. Entra a `lib/nodecg`. +2. Reinstala/recompila dependencias nativas: + +```bash +cd lib/nodecg +npm install +npm rebuild better-sqlite3 --update-binary +``` + +3. Si persiste, elimina `node_modules` del workspace que falla y vuelve a instalar. + ## Variables de entorno opcionales +Estas variables se leen en `src/main/main.ts`. + - `NODECG_PORT` (default: `9090`) - `NODECG_BUNDLE_NAME` (default: `scoreko-dev`) - `SCOREKO_DASHBOARD_ROUTE` (default: `dashboard/index.html`) -- `ELECTRON_LOAD_DELAY_MS` (default: `5000`) +- `ELECTRON_LOAD_DELAY_MS` (default: `2500`) +- `NODECG_STARTUP_TIMEOUT_MS` (default: `30000`) + +Ejemplo (PowerShell): + +```powershell +$env:NODECG_PORT="9191" +$env:NODECG_BUNDLE_NAME="scoreko-dev" +npm run dev +``` ## Scripts diff --git a/src/main/main.ts b/src/main/main.ts index 98d233f..cdf51b7 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,13 +1,14 @@ -import { app, BrowserWindow, shell } from "electron"; -import { fork, ChildProcess } from "node:child_process"; -import path from "node:path"; +import { app, BrowserWindow, dialog, shell } from "electron"; +import { ChildProcess, fork } 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/index.html"; -const LOAD_DELAY_MS = Number.parseInt(process.env.ELECTRON_LOAD_DELAY_MS ?? "5000", 10); +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 isDev = !app.isPackaged; const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath; @@ -15,10 +16,12 @@ const nodecgPath = path.resolve(rootPath, "lib", "nodecg"); const loadingPath = path.resolve(rootPath, "static", "loading.html"); const dashboardUrl = `http://localhost:${DEFAULT_NODECG_PORT}/bundles/${DEFAULT_BUNDLE_NAME}/${DEFAULT_DASHBOARD_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({ @@ -87,14 +90,65 @@ function createLoadingWindow(): BrowserWindow { return win; } -function startNodeCG(): ChildProcess { +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(indexPath)) { - throw new Error(`NodeCG entrypoint not found: ${indexPath}`); + if (!fs.existsSync(nodecgPath)) { + throw new Error(`No existe la carpeta NodeCG: ${nodecgPath}`); } - const child = fork(indexPath, { + 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("NODE_MODULE_VERSION")) { + return [ + baseMessage, + "", + "Detectado error de módulos nativos compilados para otra versión de Node/Electron (NODE_MODULE_VERSION).", + "Esto suele pasar cuando NodeCG/bundles fueron instalados con Node 22 pero Electron corre con Node 20 (o viceversa).", + "", + "Solución recomendada (en lib/nodecg):", + " 1) borrar node_modules y lockfile del workspace que falla (si aplica)", + " 2) npm install", + " 3) npm rebuild better-sqlite3 --update-binary", + "", + "También puedes alinear versiones: usar una versión de Electron cuyo Node interno coincida con el usado para compilar addons.", + ].join("\n"); + } + + return baseMessage; +} + +function startNodeCG(): ChildProcess { + validateNodeCGInstall(); + + const child = fork(path.join(nodecgPath, "index.js"), { cwd: nodecgPath, env: { ...process.env, @@ -102,7 +156,19 @@ function startNodeCG(): ChildProcess { NODE_ENV: isDev ? "development" : "production", NODECG_PORT: DEFAULT_NODECG_PORT, }, - stdio: "inherit", + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + + 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}`); @@ -115,6 +181,33 @@ function startNodeCG(): ChildProcess { 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(); @@ -122,21 +215,27 @@ async function launch(): Promise { await loadingWindow.loadFile(loadingPath); loadingWindow.show(); + lastNodeCGOutput = ""; nodecgProcess = startNodeCG(); - setTimeout(async () => { - if (!mainWindow) { - return; - } + await sleep(Math.max(0, LOAD_DELAY_MS)); + await waitForNodeCGReady(Date.now()); + 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; - } - }, LOAD_DELAY_MS); + if (loadingWindow && !loadingWindow.isDestroyed()) { + loadingWindow.close(); + loadingWindow = null; + } } function stopNodeCG(): void { @@ -153,8 +252,18 @@ function log(...args: unknown[]): void { } app.on("ready", () => { - launch().catch((error: unknown) => { + 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); }); });