diff --git a/electron/README.md b/electron/README.md new file mode 100644 index 0000000..4ac8ec3 --- /dev/null +++ b/electron/README.md @@ -0,0 +1,49 @@ +# Electron wrapper (Windows) + +Este wrapper crea una app de escritorio para Windows que lanza NodeCG sin requerir que el usuario final tenga Node.js instalado. + +## Requisitos de build (solo para quien genera el instalador) + +1. Instalar dependencias del bundle raíz: + + ```bash + pnpm install + ``` + +2. Instalar dependencias del wrapper: + + ```bash + cd electron + pnpm install + ``` + +## Desarrollo local + +Desde `electron/`: + +```bash +pnpm start +``` + +## Generar instalador `.exe` (comprimido) + +Desde `electron/`: + +```bash +pnpm dist:win +``` + +Esto genera un instalador NSIS en `electron/dist/` con compresión máxima (`compression: maximum`). + +## Qué incluye el instalador + +- Runtime de Electron (incluye Node embebido). +- Dependencia `nodecg` dentro de la app. +- El bundle `scoreko-dev` como recurso (`resources/bundle`). + +Con eso, el usuario final instala y ejecuta la app sin instalar Node.js aparte. + +## Variables opcionales + +- `NODECG_PORT` (por defecto `9090`) +- `NODECG_HOST` (por defecto `127.0.0.1`) diff --git a/electron/main.mjs b/electron/main.mjs new file mode 100644 index 0000000..ba42ebb --- /dev/null +++ b/electron/main.mjs @@ -0,0 +1,118 @@ +import { app, BrowserWindow } from 'electron'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const BUNDLE_ROOT = app.isPackaged + ? path.join(process.resourcesPath, 'bundle') + : path.resolve(__dirname, '..'); + +const NODECG_PORT = Number.parseInt(process.env.NODECG_PORT ?? '9090', 10); +const NODECG_HOST = process.env.NODECG_HOST ?? '127.0.0.1'; +const NODECG_URL = `http://${NODECG_HOST}:${NODECG_PORT}`; + +let nodecgProcess; + +function getNodecgCliPath() { + return app.isPackaged + ? path.join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'nodecg', 'bin', 'nodecg.js') + : path.join(__dirname, 'node_modules', 'nodecg', 'bin', 'nodecg.js'); +} + +function startNodecg() { + const nodecgCli = getNodecgCliPath(); + + nodecgProcess = spawn(process.execPath, [nodecgCli, 'start'], { + cwd: BUNDLE_ROOT, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + NODECG_PORT: String(NODECG_PORT), + }, + stdio: 'inherit', + }); + + nodecgProcess.on('exit', (code) => { + if (code !== 0) { + console.error(`[electron] NodeCG process exited with code ${code}`); + } + + if (!app.isQuitting) { + app.quit(); + } + }); +} + +function stopNodecg() { + if (!nodecgProcess || nodecgProcess.killed) { + return; + } + + app.isQuitting = true; + nodecgProcess.kill(); +} + +async function waitForNodecg(retries = 80, delayMs = 250) { + for (let attempt = 0; attempt < retries; attempt += 1) { + try { + const response = await fetch(NODECG_URL, { method: 'HEAD' }); + if (response.ok || response.status >= 300) { + return; + } + } catch { + // Ignore connection errors while NodeCG boots. + } + + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + } + + throw new Error(`NodeCG did not start in time at ${NODECG_URL}`); +} + +async function createWindow() { + const mainWindow = new BrowserWindow({ + width: 1366, + height: 768, + autoHideMenuBar: true, + webPreferences: { + contextIsolation: true, + sandbox: true, + }, + }); + + await mainWindow.loadURL(NODECG_URL); +} + +app.whenReady().then(async () => { + startNodecg(); + + try { + await waitForNodecg(); + await createWindow(); + } catch (error) { + console.error('[electron] Failed to start wrapper:', error); + app.quit(); + } + + app.on('activate', async () => { + if (BrowserWindow.getAllWindows().length === 0) { + await createWindow(); + } + }); +}); + +app.on('before-quit', () => { + app.isQuitting = true; + stopNodecg(); +}); + +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..582921b --- /dev/null +++ b/electron/package.json @@ -0,0 +1,69 @@ +{ + "name": "scoreko-dev-electron-wrapper", + "version": "0.2.0", + "private": true, + "description": "Electron wrapper for running the scoreko-dev NodeCG bundle on Windows.", + "main": "main.mjs", + "type": "module", + "scripts": { + "start": "electron .", + "dist:win": "electron-builder --win nsis" + }, + "dependencies": { + "electron": "40.6.1", + "nodecg": "^2.6.4" + }, + "devDependencies": { + "electron-builder": "^26.0.12" + }, + "build": { + "appId": "com.scoreko.dev", + "productName": "Scoreko Dev", + "compression": "maximum", + "asar": true, + "directories": { + "output": "dist" + }, + "files": [ + "main.mjs", + "package.json", + "node_modules/**/*" + ], + "extraResources": [ + { + "from": "../", + "to": "bundle", + "filter": [ + "package.json", + "configschema.json", + "LICENSE", + "README.md", + "schemas/**/*", + "dashboard/**/*", + "graphics/**/*", + "extension/**/*", + "src/**/*", + "node_modules/**/*", + "!electron/**/*", + "!.git/**/*" + ] + } + ], + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + } + } +} diff --git a/package.json b/package.json index b18b26c..8fdc122 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "lint": "eslint", "schema-types": "nodecg schema-types", "start": "nodecg start", - "watch": "conc -n B,E -c red,blue -k vite \"tsc -b -w --preserveWatchOutput tsconfig.extension.json\"" + "watch": "conc -n B,E -c red,blue -k vite \"tsc -b -w --preserveWatchOutput tsconfig.extension.json\"", + "electron:start": "pnpm --dir electron start", + "electron:dist": "pnpm --dir electron dist:win" }, "nodecg": { "compatibleRange": "^2.6.0",