diff --git a/electron/README.md b/electron/README.md new file mode 100644 index 0000000..67205f6 --- /dev/null +++ b/electron/README.md @@ -0,0 +1,119 @@ +# Electron wrapper para Scoreko + +Este wrapper permite abrir el bundle de NodeCG como una app de escritorio usando Electron. + +## Requisitos + +- Node.js 22+ +- Electron wrapper fijado en `39.5.1` (usa Node.js `22.22.0`). +- Dependencias del proyecto raíz ya instaladas +- `node index.js` funcional en la raíz de NodeCG + +## Estructura esperada + +Este bundle suele vivir en `bundles/scoreko-dev`. El wrapper asume por defecto que la raíz de NodeCG está en `../../` respecto a esta carpeta. + +Si tu estructura es distinta, podés usar la variable `NODECG_ROOT`. + +## Instalación + +Desde la raíz del repositorio: + +```bash +cd electron +npm install +``` + +## Uso rápido + +> Nota: los scripts usan `cross-env` para que funcionen igual en Windows, macOS y Linux. + +```bash +cd electron +npm start +``` + +`npm start` arranca NodeCG con Node del sistema (`NODE_BINARY`, por defecto `node`) y después abre la ventana de Electron. Este es el modo recomendado. + +Para desarrollo, usá: + +```bash +npm run start:dev +``` + +`start:dev` también usa Node del sistema y deja variables preparadas para desarrollo. + +## Variables de entorno + +- `NODECG_ROOT`: ruta a la raíz de NodeCG (relativa al bundle o absoluta). +- `NODECG_PORT`: puerto de NodeCG (por defecto `9090`). +- `ELECTRON_START_URL`: URL que abre la ventana (por defecto `http://localhost:9090/bundles/scoreko-dev/dashboard/example/main.html?standalone=true`). +- `NODE_BINARY`: ejecutable de Node.js para arrancar NodeCG (por defecto `node`). +- `NODECG_USE_ELECTRON_NODE`: si vale `1`, NodeCG arranca con el runtime de Node embebido en Electron (`process.execPath`). + +Ejemplo: + +```bash +NODECG_ROOT=../.. NODECG_PORT=9090 ELECTRON_START_URL=http://localhost:9090/bundles/scoreko-dev/dashboard/example/main.html?standalone=true npm start +``` + +## Modo desarrollo recomendado + +Script de desarrollo: + +```bash +npm run start:dev +``` + +Modo alternativo (experimental) si querés forzar Node embebido de Electron: + +```bash +npm run start:electron-node +``` + +Si vas a usar el modo Electron-Node y te aparece `NODE_MODULE_VERSION`, recompilá `better-sqlite3` contra Electron 39.5.1: + +```bash +npm run rebuild:better-sqlite3:electron +``` + +También podés hacerlo y arrancar en un paso: + +```bash +npm run start:electron-node:rebuild +``` + +## Troubleshooting: `NODE_MODULE_VERSION` (better-sqlite3) + +Si ves un error como: + +```txt +was compiled against a different Node.js version ... +NODE_MODULE_VERSION 127 ... requires NODE_MODULE_VERSION 136 +``` + +significa que NodeCG se está ejecutando con un runtime distinto al que compiló los módulos nativos (`better-sqlite3`). + +- `127` corresponde a Node.js 22 (normalmente tu instalación de Node). +- El otro número (`136`, `140`, etc.) representa la ABI del runtime de Node embebido en la versión de Electron que estés usando. + +Recomendación práctica: usá Node del sistema (`start`/`start:dev`) para NodeCG. Si forzás `start:electron-node`, tenés que recompilar nativos (como `better-sqlite3`) contra la ABI de Electron. + +Pasos recomendados en Windows: + +```bash +# desde la raíz de NodeCG (no dentro de electron) +cd ~/Desktop/nodecg +rm -rf node_modules package-lock.json +npm install + +# opcional: forzar recompilación de nativos para tu Node actual +npm rebuild better-sqlite3 +``` + +Y si tenés varias instalaciones de Node: + +```bash +cd bundles/scoreko-dev/electron +NODE_BINARY="C:/Program Files/nodejs/node.exe" npm start +``` diff --git a/electron/main.mjs b/electron/main.mjs new file mode 100644 index 0000000..ed0eb8b --- /dev/null +++ b/electron/main.mjs @@ -0,0 +1,129 @@ +import { app, BrowserWindow, dialog } from 'electron'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { get } from 'node:http'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const bundleRoot = resolve(__dirname, '..'); + +const nodecgRoot = process.env.NODECG_ROOT + ? resolve(bundleRoot, process.env.NODECG_ROOT) + : resolve(bundleRoot, '..', '..'); + +const startUrl = process.env.ELECTRON_START_URL ?? 'http://localhost:9090/bundles/scoreko-dev/dashboard/example/main.html?standalone=true'; +const nodecgPort = Number(process.env.NODECG_PORT ?? 9090); +const nodeBinary = process.env.NODE_BINARY ?? 'node'; +const useElectronNodeForNodeCG = process.env.NODECG_USE_ELECTRON_NODE === '1'; + +/** @type {import('node:child_process').ChildProcess | undefined} */ +let nodecgProcess; + +function waitForServer(url, timeoutMs = 30_000) { + const started = Date.now(); + + return new Promise((resolvePromise, rejectPromise) => { + const attempt = () => { + const request = get(url, (response) => { + response.resume(); + + if (response.statusCode && response.statusCode >= 200 && response.statusCode < 500) { + resolvePromise(); + return; + } + + if (Date.now() - started > timeoutMs) { + rejectPromise(new Error('NodeCG no respondió a tiempo.')); + return; + } + + setTimeout(attempt, 500); + }); + + request.on('error', () => { + if (Date.now() - started > timeoutMs) { + rejectPromise(new Error('No fue posible conectar a NodeCG.')); + return; + } + + setTimeout(attempt, 500); + }); + }; + + attempt(); + }); +} + +function startNodeCG() { + const runtimeBinary = useElectronNodeForNodeCG ? process.execPath : nodeBinary; + + nodecgProcess = spawn(runtimeBinary, ['index.js'], { + cwd: nodecgRoot, + stdio: 'inherit', + env: { + ...process.env, + NODE_ENV: process.env.NODE_ENV ?? 'production', + PORT: String(nodecgPort) + } + }); + + nodecgProcess.on('exit', (code) => { + if (!app.isQuiting) { + dialog.showErrorBox( + 'NodeCG finalizado', + `El proceso de NodeCG terminó inesperadamente con código ${code ?? 'desconocido'}.` + ); + app.quit(); + } + }); + + nodecgProcess.on('error', (error) => { + dialog.showErrorBox( + 'No se pudo iniciar NodeCG', + `No se pudo ejecutar \"${runtimeBinary}\". ${useElectronNodeForNodeCG ? 'Desactivá NODECG_USE_ELECTRON_NODE o revisá Electron.' : 'Definí NODE_BINARY con la ruta de Node.js.'}\n\nDetalle: ${error.message}` + ); + app.quit(); + }); +} + +async function createWindow() { + const win = new BrowserWindow({ + width: 1920, + height: 1080, + autoHideMenuBar: true, + backgroundColor: '#000000', + webPreferences: { + contextIsolation: true, + sandbox: true + } + }); + + await win.loadURL(startUrl); +} + +app.on('before-quit', () => { + app.isQuiting = true; + + if (nodecgProcess && !nodecgProcess.killed) { + nodecgProcess.kill('SIGTERM'); + } +}); + +app.whenReady().then(async () => { + startNodeCG(); + + try { + await waitForServer(`http://127.0.0.1:${nodecgPort}`); + await createWindow(); + } catch (error) { + dialog.showErrorBox('No se pudo iniciar', error instanceof Error ? error.message : String(error)); + app.quit(); + } +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); diff --git a/electron/package.json b/electron/package.json new file mode 100644 index 0000000..9ea88f9 --- /dev/null +++ b/electron/package.json @@ -0,0 +1,19 @@ +{ + "name": "scoreko-dev-electron-wrapper", + "version": "1.0.0", + "private": true, + "description": "Electron wrapper para ejecutar Scoreko como app de escritorio.", + "type": "module", + "main": "main.mjs", + "scripts": { + "start": "cross-env electron .", + "start:dev": "cross-env NODECG_ROOT=../.. ELECTRON_START_URL=http://localhost:9090/bundles/scoreko-dev/dashboard/example/main.html?standalone=true electron .", + "start:electron-node": "cross-env NODECG_USE_ELECTRON_NODE=1 electron .", + "rebuild:better-sqlite3:electron": "npm --prefix ../.. rebuild better-sqlite3 --runtime=electron --target=39.5.1 --dist-url=https://electronjs.org/headers", + "start:electron-node:rebuild": "npm run rebuild:better-sqlite3:electron && npm run start:electron-node" + }, + "devDependencies": { + "electron": "39.5.1", + "cross-env": "^7.0.3" + } +}