Files
scoreko-electron-dev/src/main/main.ts
T

320 lines
8.9 KiB
TypeScript

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<void> {
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<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function launch(): Promise<void> {
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();
});