mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-05 21:22:07 +00:00
Merge pull request #23 from Pandipipas/refactorizar-y-mejorar-el-codigo
docs: añadir roadmap detallado de refactor sin romper funcionalidad
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
# Runtime / app
|
||||||
|
SCOREKO_APP_TITLE=Scoreko
|
||||||
|
SCOREKO_APP_USER_MODEL_ID=com.scoreko.desktop
|
||||||
|
# SCOREKO_APP_ICON_PATH=static/icons/icon.ico
|
||||||
|
|
||||||
|
# NodeCG
|
||||||
|
NODECG_BUNDLE_NAME=scoreko-dev
|
||||||
|
NODECG_PORT=9090
|
||||||
|
SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true
|
||||||
|
SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
ELECTRON_LOAD_DELAY_MS=10000
|
||||||
|
NODECG_STARTUP_TIMEOUT_MS=30000
|
||||||
|
NODECG_KILL_TIMEOUT_MS=2500
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- work
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
release
|
||||||
|
lib/nodecg
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ Wrapper de Electron para empaquetar una instalación de NodeCG que incluya el bu
|
|||||||
|
|
||||||
## Requisitos clave
|
## Requisitos clave
|
||||||
|
|
||||||
|
- Node `>=22` (`.nvmrc` incluido).
|
||||||
- Electron fijado en `39.5.1`.
|
- Electron fijado en `39.5.1`.
|
||||||
|
|
||||||
## Qué hace
|
## Qué hace
|
||||||
@@ -33,77 +34,31 @@ scoreko-electron-dev/
|
|||||||
- `npm run dev`: modo desarrollo.
|
- `npm run dev`: modo desarrollo.
|
||||||
- `npm run build`: compila TypeScript y copia assets.
|
- `npm run build`: compila TypeScript y copia assets.
|
||||||
- `npm run start`: build y ejecución local.
|
- `npm run start`: build y ejecución local.
|
||||||
|
- `npm run test`: build + tests unitarios (`node:test`).
|
||||||
|
- `npm run doctor`: preflight de configuración y entorno (`lib/nodecg`, permisos, puerto y env vars).
|
||||||
|
- `npm run lint`: reglas mínimas de calidad con ESLint.
|
||||||
|
- `npm run format`: validación de formato con Prettier.
|
||||||
- `npm run pack`: genera app sin instalador.
|
- `npm run pack`: genera app sin instalador.
|
||||||
- `npm run dist`: genera instalador.
|
- `npm run dist`: genera instalador.
|
||||||
- `npm run rebuild:native`: rebuild nativo auxiliar en `lib/nodecg`.
|
|
||||||
|
|
||||||
## Variables de entorno útiles
|
## Variables de entorno
|
||||||
|
|
||||||
- `NODECG_BUNDLE_NAME` (default: `scoreko-dev`)
|
La **fuente única de defaults** está en `.env.example`.
|
||||||
- `NODECG_PORT` (default: `9090`)
|
|
||||||
- `SCOREKO_DASHBOARD_ROUTE` (default: `dashboard/example/main.html?standalone=true`)
|
|
||||||
- `SCOREKO_LOADING_ROUTE` (default: `dashboard/loading/main.html?standalone=true`)
|
|
||||||
- `SCOREKO_APP_TITLE` (default: `Scoreko`)
|
|
||||||
- `SCOREKO_APP_USER_MODEL_ID` (default: `com.scoreko.desktop`)
|
|
||||||
- `SCOREKO_APP_ICON_PATH` (default: búsqueda automática en `static/icons/icon.ico` y `static/icons/icon.png`)
|
|
||||||
- `ELECTRON_LOAD_DELAY_MS` (default: `10000`)
|
|
||||||
- `NODECG_STARTUP_TIMEOUT_MS` (default: `30000`)
|
|
||||||
- `NODECG_KILL_TIMEOUT_MS` (default: `2500`)
|
|
||||||
|
|
||||||
## Assets de íconos incluidos
|
1. Copia `.env.example` a `.env` (o exporta variables en tu shell/CI).
|
||||||
|
2. Ajusta sólo lo necesario.
|
||||||
|
3. Ejecuta `npm run doctor` para validar configuración antes de arrancar.
|
||||||
|
|
||||||
Se incluye `static/icons/` con placeholder editable:
|
## Build multi-plataforma (iconos)
|
||||||
|
|
||||||
- `static/icons/icon.svg`
|
- `build.win.icon`: `static/icons/icon.ico`
|
||||||
|
- `build.linux.icon`: `static/icons`
|
||||||
|
- `build.mac.icon`: `static/icons/icon.icns`
|
||||||
|
|
||||||
> En este repo no se versionan binarios. Para distribución final agrega localmente `static/icons/icon.ico` (Windows) y/o `static/icons/icon.png` (Linux/macOS runtime).
|
> El `.icns` se referencia en la configuración de build y debe existir localmente para empaquetar macOS.
|
||||||
|
|
||||||
## Personalización (Windows / Electron)
|
## Troubleshooting y arquitectura
|
||||||
|
|
||||||
### 1) Título de la app
|
- Guía de troubleshooting: `docs/troubleshooting.md`
|
||||||
|
- Mapa de arquitectura: `docs/architecture.md`
|
||||||
- **Runtime (ventanas):** `SCOREKO_APP_TITLE`.
|
- Roadmap: `docs/refactor-roadmap.md`
|
||||||
- **Build (instalador y ejecutable):** `build.productName`.
|
|
||||||
|
|
||||||
### 2) Ícono en barra de tareas y esquina superior
|
|
||||||
|
|
||||||
Electron toma automáticamente el primer archivo existente en este orden:
|
|
||||||
|
|
||||||
1. `SCOREKO_APP_ICON_PATH` (si lo defines)
|
|
||||||
2. `static/icons/icon.ico`
|
|
||||||
3. `static/icons/icon.png`
|
|
||||||
4. `static/icon.ico`
|
|
||||||
5. `static/icon.png`
|
|
||||||
|
|
||||||
### 3) Ícono del `.exe` y accesos directos
|
|
||||||
|
|
||||||
Quedó configurado en `package.json` con `build.win.icon` + `build.nsis.installerIcon`/`uninstallerIcon`.
|
|
||||||
|
|
||||||
### 4) Nombre/autor del popup de Firewall de Windows
|
|
||||||
|
|
||||||
Ese diálogo usa metadata del ejecutable firmado:
|
|
||||||
|
|
||||||
- Nombre de app: normalmente `build.productName` y metadata del binario.
|
|
||||||
- Publisher/autor: certificado de firma de código (si no firmas puede verse `Unknown publisher`).
|
|
||||||
|
|
||||||
Campos clave a revisar:
|
|
||||||
|
|
||||||
- `description`
|
|
||||||
- `author`
|
|
||||||
- `build.productName`
|
|
||||||
- `build.win.executableName`
|
|
||||||
- `build.appId`
|
|
||||||
|
|
||||||
Además, firma el `.exe` con certificado (`CSC_LINK` / `CSC_KEY_PASSWORD` en `electron-builder`).
|
|
||||||
|
|
||||||
## Checklist de personalización extra
|
|
||||||
|
|
||||||
- `build.appId`
|
|
||||||
- `build.artifactName`
|
|
||||||
- `build.win.executableName`
|
|
||||||
- `build.win.icon`
|
|
||||||
- `build.nsis` (nombre del setup, íconos, shortcut)
|
|
||||||
- `build.mac.icon`
|
|
||||||
- `build.linux.icon`
|
|
||||||
- `SCOREKO_APP_USER_MODEL_ID`
|
|
||||||
- `SCOREKO_APP_ICON_PATH`
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Arquitectura del proceso principal
|
||||||
|
|
||||||
|
## Flujo de arranque
|
||||||
|
|
||||||
|
1. `src/main/main.ts` carga `appConfig` desde `config/runtime-config.ts`.
|
||||||
|
2. Crea ventanas (`windows/window-factory.ts`).
|
||||||
|
3. Arranca NodeCG con `nodecg/process-manager.ts`.
|
||||||
|
4. Espera readiness HTTP y muestra loading -> dashboard principal.
|
||||||
|
5. En cierre, ejecuta un único flujo de stop graceful para evitar procesos huérfanos.
|
||||||
|
|
||||||
|
## Módulos principales
|
||||||
|
|
||||||
|
- `config/runtime-config.ts`: lectura/validación de env vars.
|
||||||
|
- `nodecg/process-manager.ts`: start, readiness y stop de NodeCG, validaciones de instalación/permisos/puerto.
|
||||||
|
- `windows/window-factory.ts`: creación de ventanas y política de navegación.
|
||||||
|
- `windows/navigation-security.ts`: allowlist de navegación interna y esquemas externos seguros.
|
||||||
|
- `errors/error-presenter.ts`: presentación de errores fatales.
|
||||||
|
- `errors/logger.ts`: logging estructurado (`info/warn/error/debug`).
|
||||||
|
|
||||||
|
## Principios
|
||||||
|
|
||||||
|
- Refactors mecánicos primero.
|
||||||
|
- Hardening incremental con fallback conservador.
|
||||||
|
- Validación automática por `typecheck`, `build`, `test`, `doctor`, `lint`.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# Roadmap de refactor, limpieza y mejoras (sin romper funcionalidad)
|
||||||
|
|
||||||
|
Este documento detalla una propuesta de mejoras para `scoreko-electron-dev` priorizando **cero regresiones**. Cada iniciativa incluye objetivo, acciones concretas y estrategia de implementación segura.
|
||||||
|
|
||||||
|
## 1) Arquitectura y organización del código
|
||||||
|
|
||||||
|
### 1.1 Separar `main.ts` por responsabilidades
|
||||||
|
|
||||||
|
- **Problema actual:** `src/main/main.ts` concentra configuración, UI, lifecycle, gestión de procesos, manejo de errores y utilidades.
|
||||||
|
- **Acciones:**
|
||||||
|
- Crear `src/main/config/runtime-config.ts` para parseo de env vars.
|
||||||
|
- Crear `src/main/nodecg/process-manager.ts` para `start/stop/wait-ready`.
|
||||||
|
- Crear `src/main/windows/window-factory.ts` para creación de ventanas.
|
||||||
|
- Crear `src/main/errors/error-presenter.ts` para logging + `dialog.showErrorBox`.
|
||||||
|
- **Riesgo:** bajo, si se conserva API interna por pasos.
|
||||||
|
- **Implementación segura:** mover funciones sin cambiar lógica, cubrir con tests unitarios antes de modificar comportamiento.
|
||||||
|
|
||||||
|
### 1.2 Consolidar constantes de runtime
|
||||||
|
|
||||||
|
- **Problema actual:** constantes de URLs y tamaños están dispersas.
|
||||||
|
- **Acciones:** agrupar en `src/main/constants.ts` y tiparlas con `as const`.
|
||||||
|
- **Beneficio:** facilita ajuste de defaults y revisiones.
|
||||||
|
|
||||||
|
### 1.3 Normalizar naming interno
|
||||||
|
|
||||||
|
- **Mejoras propuestas:**
|
||||||
|
- `runtimeConfig` → `appConfig`.
|
||||||
|
- `nodecgPath` → `nodecgRootPath`.
|
||||||
|
- `RUNTIME_NAME` → `NODE_RUNTIME_NAME`.
|
||||||
|
- **Objetivo:** nombres más semánticos para mantenimiento.
|
||||||
|
|
||||||
|
## 2) Robustez de proceso NodeCG
|
||||||
|
|
||||||
|
### 2.1 Endurecer validaciones de instalación
|
||||||
|
|
||||||
|
- **Mejoras:**
|
||||||
|
- Validar permisos de lectura/escritura en `lib/nodecg`.
|
||||||
|
- Validar si el puerto está ocupado antes de lanzar.
|
||||||
|
- Incluir diagnóstico más accionable en mensajes de error (comando sugerido exacto).
|
||||||
|
- **Implementación segura:** agregar validaciones opt-in primero con logs, luego convertir a hard-fail.
|
||||||
|
|
||||||
|
### 2.2 Mejorar el check de “ready”
|
||||||
|
|
||||||
|
- **Problema:** readiness actual con `GET /` + `response.ok || 404` puede dar falsos positivos.
|
||||||
|
- **Acciones:**
|
||||||
|
- Intentar endpoint más explícito de NodeCG si existe.
|
||||||
|
- Si no existe, añadir secuencia de checks con retries exponenciales + jitter.
|
||||||
|
- **Compatibilidad:** mantener fallback al check actual inicialmente.
|
||||||
|
|
||||||
|
### 2.3 Control del shutdown más determinista
|
||||||
|
|
||||||
|
- **Acciones:**
|
||||||
|
- Añadir estado explícito (`starting/running/stopping/stopped`).
|
||||||
|
- Evitar dobles señales en hooks `before-quit`, `will-quit`, `process.exit`.
|
||||||
|
- Registrar duración de parada para diagnósticos.
|
||||||
|
|
||||||
|
## 3) Ventanas Electron y UX de carga
|
||||||
|
|
||||||
|
### 3.1 Rework de loading flow
|
||||||
|
|
||||||
|
- **Mejoras:**
|
||||||
|
- Evitar cargar la ventana principal demasiado pronto si falla dashboard.
|
||||||
|
- Añadir timeout específico para `mainWindow.loadURL`.
|
||||||
|
- Incluir fallback de pantalla de error amigable dentro de Electron.
|
||||||
|
|
||||||
|
### 3.2 Seguridad de navegación
|
||||||
|
|
||||||
|
- **Acciones:**
|
||||||
|
- Validar que `setWindowOpenHandler` y `will-navigate` permitan solo dominio esperado (`localhost:PORT`).
|
||||||
|
- Rechazar esquemas inseguros (`file:`, `javascript:`).
|
||||||
|
- **Beneficio:** hardening del proceso principal.
|
||||||
|
|
||||||
|
### 3.3 Ajustes de resolución
|
||||||
|
|
||||||
|
- **Mejora:** no fijar `minWidth/minHeight` a 1920x1080 en todos los escenarios.
|
||||||
|
- **Propuesta segura:** usar valores por env var con defaults actuales para mantener compatibilidad.
|
||||||
|
|
||||||
|
## 4) Configuración y variables de entorno
|
||||||
|
|
||||||
|
### 4.1 Validación tipada de env vars
|
||||||
|
|
||||||
|
- **Acciones:**
|
||||||
|
- Introducir esquema (`zod` o validación manual centralizada).
|
||||||
|
- Rechazar puertos fuera de rango.
|
||||||
|
- Marcar strings vacíos inválidos en rutas críticas.
|
||||||
|
|
||||||
|
### 4.2 Documentación ejecutable
|
||||||
|
|
||||||
|
- **Acciones:**
|
||||||
|
- Añadir `.env.example` con todos los defaults.
|
||||||
|
- Script de validación `npm run doctor` para detectar configuración inválida.
|
||||||
|
|
||||||
|
### 4.3 Unificar defaults entre código y README
|
||||||
|
|
||||||
|
- **Problema:** existe posible drift entre doc y runtime.
|
||||||
|
- **Acción:** generar bloque de README automáticamente desde una fuente única de config.
|
||||||
|
|
||||||
|
## 5) Build, packaging y distribución
|
||||||
|
|
||||||
|
### 5.1 Revisar iconos por plataforma
|
||||||
|
|
||||||
|
- **Problema:** en `build.mac.icon` se usa `.ico` (no ideal para macOS).
|
||||||
|
- **Acciones:**
|
||||||
|
- Usar `.icns` en macOS.
|
||||||
|
- Mantener `.ico` en Windows y set icon set correcto para Linux.
|
||||||
|
- **Estrategia segura:** fallback conservando lo actual hasta tener assets definitivos.
|
||||||
|
|
||||||
|
### 5.2 Pipeline reproducible
|
||||||
|
|
||||||
|
- **Acciones:**
|
||||||
|
- Asegurar lockfile limpio y versión de Node fijada con `.nvmrc`.
|
||||||
|
- Añadir CI mínima (`typecheck`, build, smoke test).
|
||||||
|
|
||||||
|
### 5.3 Reducción de tamaño de artefacto
|
||||||
|
|
||||||
|
- **Acciones:**
|
||||||
|
- Revisar qué se copia en `extraResources`.
|
||||||
|
- Excluir archivos no necesarios (logs, tests, cachés) en empaquetado.
|
||||||
|
|
||||||
|
## 6) Calidad de código y testing
|
||||||
|
|
||||||
|
### 6.1 Añadir linting/formatting
|
||||||
|
|
||||||
|
- **Acciones:**
|
||||||
|
- Configurar ESLint + Prettier.
|
||||||
|
- Reglas mínimas: imports ordenados, no variables no usadas, complejidad controlada.
|
||||||
|
|
||||||
|
### 6.2 Unit tests para utilidades críticas
|
||||||
|
|
||||||
|
- **Cobertura objetivo inicial:**
|
||||||
|
- `parseEnvInt`, `getEnv`, `getOptionalEnv`.
|
||||||
|
- Resolución de icon path.
|
||||||
|
- Cálculo de delays y timeouts.
|
||||||
|
|
||||||
|
### 6.3 Integration smoke tests
|
||||||
|
|
||||||
|
- **Acción:** test que verifique arranque controlado de Electron main con mocks (sin UI real).
|
||||||
|
- **Objetivo:** detectar regresiones de lifecycle y cierre de NodeCG.
|
||||||
|
|
||||||
|
## 7) Observabilidad y diagnóstico
|
||||||
|
|
||||||
|
### 7.1 Logger estructurado
|
||||||
|
|
||||||
|
- **Acción:** reemplazar `console.log` por logger con niveles (`info/warn/error/debug`) y contexto.
|
||||||
|
- **Beneficio:** depuración más rápida en producción.
|
||||||
|
|
||||||
|
### 7.2 Error codes y troubleshooting
|
||||||
|
|
||||||
|
- **Acciones:**
|
||||||
|
- Estandarizar errores con códigos (`E_NODECG_NOT_FOUND`, etc.).
|
||||||
|
- Añadir sección “Troubleshooting” en README con causas/soluciones.
|
||||||
|
|
||||||
|
## 8) Limpieza técnica (deuda)
|
||||||
|
|
||||||
|
### 8.1 Eliminar lógica duplicada de cierre
|
||||||
|
|
||||||
|
- **Problema:** varios handlers llaman `stopNodeCG()`.
|
||||||
|
- **Acción:** centralizar estrategia de parada en un solo coordinador.
|
||||||
|
|
||||||
|
### 8.2 Extraer utilidades de proceso SO
|
||||||
|
|
||||||
|
- **Acción:** separar lógica Windows (`taskkill`) y POSIX (`process.kill`) en módulos específicos.
|
||||||
|
|
||||||
|
### 8.3 Revisar `shell: true` en spawn
|
||||||
|
|
||||||
|
- **Motivo:** reducir superficie y comportamiento inesperado.
|
||||||
|
- **Plan seguro:** introducir feature-flag para comparar comportamiento antes de retirar.
|
||||||
|
|
||||||
|
## 9) Renombrados sugeridos (bajo riesgo)
|
||||||
|
|
||||||
|
- `loadingRoute` → `loadingDashboardRoute`.
|
||||||
|
- `dashboardRoute` → `mainDashboardRoute`.
|
||||||
|
- `baseUrl` → `nodecgBaseUrl`.
|
||||||
|
- `launch()` → `launchApplication()`.
|
||||||
|
- `stopNodeCG()` → `stopNodecgProcessGracefully()`.
|
||||||
|
|
||||||
|
> Aplicar renombrados con cambios atómicos y tests para evitar breakages.
|
||||||
|
|
||||||
|
## 10) Qué crearía nuevo
|
||||||
|
|
||||||
|
- `docs/architecture.md` (mapa de módulos y flujo de inicio).
|
||||||
|
- `docs/troubleshooting.md` (errores comunes).
|
||||||
|
- `.env.example` (variables documentadas y defaults).
|
||||||
|
- `scripts/doctor.mjs` (chequeo preflight).
|
||||||
|
- `tests/main/*.test.ts` (suite base de utilidades y lifecycle).
|
||||||
|
|
||||||
|
## 11) Qué eliminaría (si no se usa)
|
||||||
|
|
||||||
|
- Configuraciones o scripts redundantes de rebuild nativo cuando no sean necesarios para el bundle actual.
|
||||||
|
- Referencias legacy de rutas de iconos duplicadas si ya hay estrategia única.
|
||||||
|
|
||||||
|
> Eliminar solo tras comprobar uso real en CI y build local.
|
||||||
|
|
||||||
|
## 12) Plan de ejecución por fases (sin romper nada)
|
||||||
|
|
||||||
|
1. **Fase 0 – Baseline:** typecheck + build + smoke manual.
|
||||||
|
2. **Fase 1 – Refactor mecánico:** mover código a módulos sin cambiar comportamiento.
|
||||||
|
3. **Fase 2 – Tests:** cubrir utilidades y lifecycle.
|
||||||
|
4. **Fase 3 – Hardening:** validación de config, seguridad de navegación y shutdown.
|
||||||
|
5. **Fase 4 – Packaging:** iconos por plataforma y CI.
|
||||||
|
6. **Fase 5 – Limpieza final:** renombrados y eliminación de deuda residual.
|
||||||
|
|
||||||
|
## 13) Criterios de aceptación por cambio
|
||||||
|
|
||||||
|
- `npm run typecheck` en verde.
|
||||||
|
- `npm run build` en verde.
|
||||||
|
- Arranque correcto mostrando loading y luego dashboard.
|
||||||
|
- Cierre correcto sin procesos huérfanos de NodeCG.
|
||||||
|
- Sin cambios visibles no intencionales en UX.
|
||||||
|
|
||||||
|
## 14) Priorización recomendada
|
||||||
|
|
||||||
|
- **Alta:** separación de módulos, tests base, hardening de shutdown y readiness.
|
||||||
|
- **Media:** validación tipada de env vars, logging estructurado, CI.
|
||||||
|
- **Baja:** renombrados cosméticos y optimización de tamaño de paquete.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## `No existe la carpeta NodeCG`
|
||||||
|
|
||||||
|
- Verifica que exista `lib/nodecg`.
|
||||||
|
- Asegúrate de que el proyecto contiene una instalación completa de NodeCG.
|
||||||
|
|
||||||
|
## `Sin permisos de lectura/escritura sobre NodeCG`
|
||||||
|
|
||||||
|
- Ajusta permisos de `lib/nodecg` para el usuario que ejecuta Electron.
|
||||||
|
- En Linux/macOS: `chmod -R u+rw lib/nodecg` (según tu política local).
|
||||||
|
|
||||||
|
## `El puerto <PORT> ya está en uso`
|
||||||
|
|
||||||
|
- Libera el puerto o define `NODECG_PORT` en `.env`.
|
||||||
|
- Usa `npm run doctor` para validar disponibilidad antes de arrancar.
|
||||||
|
|
||||||
|
## `Timeout esperando NodeCG`
|
||||||
|
|
||||||
|
- Revisa logs de NodeCG en la salida estándar.
|
||||||
|
- Incrementa `NODECG_STARTUP_TIMEOUT_MS` si el entorno es lento.
|
||||||
|
- Verifica dependencias de NodeCG (`cd lib/nodecg && npm install`).
|
||||||
|
|
||||||
|
## Build macOS falla por icono
|
||||||
|
|
||||||
|
- La configuración espera `static/icons/icon.icns`.
|
||||||
|
- Crea ese archivo antes de ejecutar empaquetado para macOS.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||||
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ["dist/**", "release/**", "lib/**"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
project: "./tsconfig.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": tseslint,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.js", "**/*.mjs"],
|
||||||
|
rules: {
|
||||||
|
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
Generated
+1175
-1
File diff suppressed because it is too large
Load Diff
+19
-5
@@ -18,9 +18,18 @@
|
|||||||
"watch": "tsc -p tsconfig.json --watch",
|
"watch": "tsc -p tsconfig.json --watch",
|
||||||
"dev:electron": "wait-on dist/main/main.js && electron .",
|
"dev:electron": "wait-on dist/main/main.js && electron .",
|
||||||
"pack": "npm run build && electron-builder --dir",
|
"pack": "npm run build && electron-builder --dir",
|
||||||
"dist": "npm run build && electron-builder",
|
|
||||||
"rebuild:native": "node scripts/rebuild-nodecg-native.mjs",
|
"rebuild:native": "node scripts/rebuild-nodecg-native.mjs",
|
||||||
"rebuild:better-sqlite3": "electron-rebuild --version 39.5.1 --module-dir lib/nodecg/workspaces/database-adapter-sqlite-legacy --only better-sqlite3 -f"
|
"rebuild:better-sqlite3": "electron-rebuild --version 39.5.1 --module-dir lib/nodecg/workspaces/database-adapter-sqlite-legacy --only better-sqlite3 -f",
|
||||||
|
"test": "npm run build && node --test dist/tests/**/*.test.js",
|
||||||
|
"doctor": "node scripts/doctor.mjs",
|
||||||
|
"lint": "eslint . --ext .ts,.js,.mjs",
|
||||||
|
"lint:fix": "npm run lint -- --fix",
|
||||||
|
"format": "prettier --check .",
|
||||||
|
"format:write": "prettier --write .",
|
||||||
|
"dist:win": "npm run build && electron-builder --win",
|
||||||
|
"dist:linux": "npm run build && electron-builder --linux AppImage",
|
||||||
|
"dist:all": "npm run build && electron-builder --win --linux --mac",
|
||||||
|
"dist:mac": "npm run build && electron-builder --mac"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.scoreko.desktop",
|
"appId": "com.scoreko.desktop",
|
||||||
@@ -48,13 +57,14 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"dmg"
|
"dmg"
|
||||||
],
|
],
|
||||||
"icon": "static/icons/icon.ico"
|
"icon": "static/icons/icon.icns"
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": [
|
"target": [
|
||||||
"AppImage"
|
"AppImage"
|
||||||
],
|
],
|
||||||
"icon": "static/icons"
|
"icon": "static/icons",
|
||||||
|
"category": "Utility"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
@@ -86,6 +96,10 @@
|
|||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^25.1.8",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"wait-on": "^8.0.1"
|
"wait-on": "^8.0.1",
|
||||||
|
"eslint": "^9.19.0",
|
||||||
|
"@typescript-eslint/parser": "^8.22.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||||
|
"prettier": "^3.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const nodecgRootPath = path.resolve(cwd, "lib", "nodecg");
|
||||||
|
|
||||||
|
const checks = [];
|
||||||
|
|
||||||
|
function addCheck(ok, title, details) {
|
||||||
|
checks.push({ ok, title, details });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePort(name, fallback) {
|
||||||
|
const raw = process.env[name] ?? fallback;
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
||||||
|
addCheck(false, `${name} inválido`, `Debe ser un entero entre 1 y 65535. Valor recibido: '${raw}'.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
addCheck(true, `${name} válido`, `${parsed}`);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIntInRange(name, fallback, min, max) {
|
||||||
|
const raw = process.env[name] ?? String(fallback);
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
||||||
|
addCheck(false, `${name} inválido`, `Debe ser un entero entre ${min} y ${max}. Valor recibido: '${raw}'.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addCheck(true, `${name} válido`, `${parsed}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkNodecgInstall() {
|
||||||
|
const indexPath = path.join(nodecgRootPath, "index.js");
|
||||||
|
const bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim();
|
||||||
|
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
|
||||||
|
|
||||||
|
addCheck(fs.existsSync(nodecgRootPath), "NodeCG root", nodecgRootPath);
|
||||||
|
addCheck(fs.existsSync(indexPath), "NodeCG index.js", indexPath);
|
||||||
|
addCheck(fs.existsSync(bundlePath), `Bundle '${bundleName}'`, bundlePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.accessSync(nodecgRootPath, fs.constants.R_OK | fs.constants.W_OK);
|
||||||
|
addCheck(true, "Permisos lib/nodecg", "Lectura/escritura OK");
|
||||||
|
} catch {
|
||||||
|
addCheck(false, "Permisos lib/nodecg", "Sin permisos de lectura/escritura en lib/nodecg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPortAvailability(port) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
|
||||||
|
server.once("error", () => {
|
||||||
|
addCheck(false, `Puerto ${port}`, "Está ocupado. Libéralo o cambia NODECG_PORT.");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, "127.0.0.1", () => {
|
||||||
|
server.close(() => {
|
||||||
|
addCheck(true, `Puerto ${port}`, "Disponible");
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const port = parsePort("NODECG_PORT", "9090");
|
||||||
|
parseIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000);
|
||||||
|
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000);
|
||||||
|
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000);
|
||||||
|
checkNodecgInstall();
|
||||||
|
|
||||||
|
if (port) {
|
||||||
|
await checkPortAvailability(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
const icon = check.ok ? "✅" : "❌";
|
||||||
|
console.log(`${icon} ${check.title}: ${check.details}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFailures = checks.some((check) => !check.ok);
|
||||||
|
if (hasFailures) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nDoctor finalizado: configuración válida.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
export type AppRuntimeConfig = {
|
||||||
|
title: string;
|
||||||
|
userModelId: string;
|
||||||
|
iconPathOverride?: string;
|
||||||
|
nodecgPort: string;
|
||||||
|
bundleName: string;
|
||||||
|
mainDashboardRoute: string;
|
||||||
|
loadingDashboardRoute: string;
|
||||||
|
loadDelayMs: number;
|
||||||
|
startupTimeoutMs: number;
|
||||||
|
nodecgKillTimeoutMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_TCP_PORT = 1;
|
||||||
|
const MAX_TCP_PORT = 65535;
|
||||||
|
|
||||||
|
export function getRuntimeConfig(): AppRuntimeConfig {
|
||||||
|
return {
|
||||||
|
title: getEnv("SCOREKO_APP_TITLE", "Scoreko"),
|
||||||
|
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"),
|
||||||
|
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
|
||||||
|
nodecgPort: parseEnvPort("NODECG_PORT", "9090"),
|
||||||
|
bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"),
|
||||||
|
mainDashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"),
|
||||||
|
loadingDashboardRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"),
|
||||||
|
loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000),
|
||||||
|
startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000),
|
||||||
|
nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOptionalEnv(name: string): string | undefined {
|
||||||
|
const value = process.env[name]?.trim();
|
||||||
|
return value && value.length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnv(name: string, fallback: string): string {
|
||||||
|
return getOptionalEnv(name) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEnvInt(name: string, fallback: number): number {
|
||||||
|
const rawValue = process.env[name];
|
||||||
|
if (!rawValue) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number.parseInt(rawValue, 10);
|
||||||
|
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number {
|
||||||
|
const rawValue = process.env[name];
|
||||||
|
if (!rawValue) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number.parseInt(rawValue, 10);
|
||||||
|
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
|
||||||
|
throw new Error(`La variable ${name} debe ser un entero entre ${min} y ${max}. Valor recibido: '${rawValue}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEnvPort(name: string, fallback: string): string {
|
||||||
|
const rawValue = getEnv(name, fallback);
|
||||||
|
const parsedValue = Number.parseInt(rawValue, 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsedValue) || parsedValue < MIN_TCP_PORT || parsedValue > MAX_TCP_PORT) {
|
||||||
|
throw new Error(
|
||||||
|
`La variable ${name} debe ser un puerto TCP válido (${MIN_TCP_PORT}-${MAX_TCP_PORT}). Valor recibido: '${rawValue}'.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(parsedValue);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const NODE_RUNTIME_NAME = "electron internal node";
|
||||||
|
export const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f";
|
||||||
|
|
||||||
|
export const DEFAULT_WINDOW_SIZE = {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
minWidth: 1920,
|
||||||
|
minHeight: 1080,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const LOADING_WINDOW_SIZE = {
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { app, dialog } from "electron";
|
||||||
|
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
export function log(...args: unknown[]): void {
|
||||||
|
logger.info("runtime", { args });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const stack = error.stack?.trim();
|
||||||
|
return stack && stack.length > 0 ? stack : error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showFatalError(message: string, error?: unknown): void {
|
||||||
|
const formattedError = error ? formatErrorMessage(error) : undefined;
|
||||||
|
const details = formattedError ? `${message}\n\n${formattedError}` : message;
|
||||||
|
|
||||||
|
logger.error("fatal-startup-error", {
|
||||||
|
message,
|
||||||
|
error: formattedError,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app.isReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.showErrorBox("Scoreko - Error al iniciar", details);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
type LogContext = Record<string, unknown>;
|
||||||
|
|
||||||
|
function write(level: LogLevel, message: string, context?: LogContext): void {
|
||||||
|
const payload = {
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
source: "scoreko-electron",
|
||||||
|
message,
|
||||||
|
...(context ? { context } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const line = JSON.stringify(payload);
|
||||||
|
|
||||||
|
if (level === "error") {
|
||||||
|
console.error(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === "warn") {
|
||||||
|
console.warn(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
debug: (message: string, context?: LogContext): void => write("debug", message, context),
|
||||||
|
info: (message: string, context?: LogContext): void => write("info", message, context),
|
||||||
|
warn: (message: string, context?: LogContext): void => write("warn", message, context),
|
||||||
|
error: (message: string, context?: LogContext): void => write("error", message, context),
|
||||||
|
};
|
||||||
+65
-363
@@ -1,254 +1,48 @@
|
|||||||
import { app, BrowserWindow, BrowserWindowConstructorOptions, dialog, shell } from "electron";
|
import { app, BrowserWindow } from "electron";
|
||||||
import { ChildProcess, spawn } from "node:child_process";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
type AppRuntimeConfig = {
|
import { getRuntimeConfig } from "./config/runtime-config";
|
||||||
title: string;
|
import { showFatalError, log } from "./errors/error-presenter";
|
||||||
userModelId: string;
|
import { createNodecgProcessManager } from "./nodecg/process-manager";
|
||||||
iconPathOverride?: string;
|
import { getRemainingDelayMs } from "./utils/timing";
|
||||||
nodecgPort: string;
|
import { createLoadingWindow, createMainWindow } from "./windows/window-factory";
|
||||||
bundleName: string;
|
|
||||||
dashboardRoute: string;
|
|
||||||
loadingRoute: string;
|
|
||||||
loadDelayMs: number;
|
|
||||||
startupTimeoutMs: number;
|
|
||||||
nodecgKillTimeoutMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const RUNTIME_NAME = "electron internal node";
|
const appConfig = getRuntimeConfig();
|
||||||
const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f";
|
|
||||||
const DEFAULT_WINDOW_SIZE = {
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
minWidth: 1920,
|
|
||||||
minHeight: 1080,
|
|
||||||
};
|
|
||||||
const LOADING_WINDOW_SIZE = {
|
|
||||||
width: 300,
|
|
||||||
height: 300,
|
|
||||||
};
|
|
||||||
|
|
||||||
const runtimeConfig: AppRuntimeConfig = {
|
|
||||||
title: getEnv("SCOREKO_APP_TITLE", "Scoreko"),
|
|
||||||
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"),
|
|
||||||
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
|
|
||||||
nodecgPort: getEnv("NODECG_PORT", "9090"),
|
|
||||||
bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"),
|
|
||||||
dashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"),
|
|
||||||
loadingRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"),
|
|
||||||
loadDelayMs: parseEnvInt("ELECTRON_LOAD_DELAY_MS", 10000),
|
|
||||||
startupTimeoutMs: parseEnvInt("NODECG_STARTUP_TIMEOUT_MS", 30000),
|
|
||||||
nodecgKillTimeoutMs: parseEnvInt("NODECG_KILL_TIMEOUT_MS", 2500),
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath;
|
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath;
|
||||||
const nodecgPath = path.resolve(rootPath, "lib", "nodecg");
|
const nodecgRootPath = path.resolve(rootPath, "lib", "nodecg");
|
||||||
const dashboardUrl = `http://localhost:${runtimeConfig.nodecgPort}/bundles/${runtimeConfig.bundleName}/${runtimeConfig.dashboardRoute}`;
|
const mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`;
|
||||||
const loadingUrl = `http://localhost:${runtimeConfig.nodecgPort}/bundles/${runtimeConfig.bundleName}/${runtimeConfig.loadingRoute}`;
|
const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`;
|
||||||
const baseUrl = `http://127.0.0.1:${runtimeConfig.nodecgPort}`;
|
const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`;
|
||||||
|
|
||||||
|
const nodecgManager = createNodecgProcessManager({
|
||||||
|
isDev,
|
||||||
|
nodecgRootPath,
|
||||||
|
nodecgBaseUrl,
|
||||||
|
appConfig,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
|
||||||
|
type AppShutdownState = "running" | "stopping" | "stopped";
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let loadingWindow: BrowserWindow | null = null;
|
let loadingWindow: BrowserWindow | null = null;
|
||||||
let nodecgProcess: ChildProcess | null = null;
|
let shutdownState: AppShutdownState = "running";
|
||||||
let stopNodeCGPromise: Promise<void> | null = null;
|
|
||||||
let isQuitting = false;
|
|
||||||
|
|
||||||
function createMainWindow(): BrowserWindow {
|
async function launchApplication(): Promise<void> {
|
||||||
const windowOptions = createWindowOptions({ isLoadingWindow: false });
|
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
|
||||||
const window = new BrowserWindow(windowOptions);
|
loadingWindow = createLoadingWindow({ appConfig, rootPath });
|
||||||
|
|
||||||
window.setMenuBarVisibility(false);
|
await nodecgManager.startNodecgProcess();
|
||||||
|
|
||||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
await nodecgManager.waitForNodecgReady(Date.now());
|
||||||
void shell.openExternal(url);
|
|
||||||
return { action: "deny" };
|
|
||||||
});
|
|
||||||
|
|
||||||
window.webContents.on("will-navigate", (event, url) => {
|
|
||||||
if (url !== dashboardUrl) {
|
|
||||||
event.preventDefault();
|
|
||||||
void shell.openExternal(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.on("page-title-updated", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLoadingWindow(): BrowserWindow {
|
|
||||||
const window = new BrowserWindow(createWindowOptions({ isLoadingWindow: true }));
|
|
||||||
|
|
||||||
window.on("page-title-updated", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWindowOptions({ isLoadingWindow }: { isLoadingWindow: boolean }): BrowserWindowConstructorOptions {
|
|
||||||
const iconPath = resolveAppIconPath();
|
|
||||||
|
|
||||||
const baseOptions: BrowserWindowConstructorOptions = {
|
|
||||||
show: false,
|
|
||||||
title: runtimeConfig.title,
|
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
|
||||||
backgroundColor: DEFAULT_WINDOW_BACKGROUND,
|
|
||||||
webPreferences: {
|
|
||||||
contextIsolation: true,
|
|
||||||
sandbox: true,
|
|
||||||
...(isLoadingWindow ? {} : { nodeIntegration: false }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoadingWindow) {
|
|
||||||
return {
|
|
||||||
...baseOptions,
|
|
||||||
frame: false,
|
|
||||||
width: LOADING_WINDOW_SIZE.width,
|
|
||||||
height: LOADING_WINDOW_SIZE.height,
|
|
||||||
resizable: false,
|
|
||||||
movable: true,
|
|
||||||
minimizable: false,
|
|
||||||
maximizable: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...baseOptions,
|
|
||||||
width: DEFAULT_WINDOW_SIZE.width,
|
|
||||||
height: DEFAULT_WINDOW_SIZE.height,
|
|
||||||
minWidth: DEFAULT_WINDOW_SIZE.minWidth,
|
|
||||||
minHeight: DEFAULT_WINDOW_SIZE.minHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAppIconPath(): string | undefined {
|
|
||||||
const iconCandidates = [
|
|
||||||
runtimeConfig.iconPathOverride,
|
|
||||||
path.join(rootPath, "static", "icons", "icon.ico"),
|
|
||||||
path.join(rootPath, "static", "icons", "icon.png"),
|
|
||||||
path.join(rootPath, "static", "icon.ico"),
|
|
||||||
path.join(rootPath, "static", "icon.png"),
|
|
||||||
].filter((candidate): candidate is string => Boolean(candidate));
|
|
||||||
|
|
||||||
return iconCandidates.find((candidate) => fs.existsSync(candidate));
|
|
||||||
}
|
|
||||||
|
|
||||||
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", runtimeConfig.bundleName);
|
|
||||||
|
|
||||||
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 '${runtimeConfig.bundleName}'.`,
|
|
||||||
`Ruta esperada: ${bundlePath}`,
|
|
||||||
"Copia/clona tu bundle dentro de lib/nodecg/bundles antes de ejecutar Electron.",
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startNodeCG(): ChildProcess {
|
|
||||||
validateNodeCGInstall();
|
|
||||||
|
|
||||||
const indexPath = path.join(nodecgPath, "index.js");
|
|
||||||
const child = spawn(process.execPath, [indexPath], {
|
|
||||||
cwd: nodecgPath,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
NODE_ENV: isDev ? "development" : "production",
|
|
||||||
NODECG_PORT: runtimeConfig.nodecgPort,
|
|
||||||
ELECTRON_RUN_AS_NODE: "1",
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
detached: process.platform !== "win32",
|
|
||||||
shell: process.platform === "win32",
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stdout?.on("data", (chunk) => {
|
|
||||||
process.stdout.write(String(chunk));
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr?.on("data", (chunk) => {
|
|
||||||
process.stderr.write(String(chunk));
|
|
||||||
});
|
|
||||||
|
|
||||||
log(`NodeCG started with pid=${child.pid} using ${RUNTIME_NAME}`);
|
|
||||||
|
|
||||||
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 < runtimeConfig.startupTimeoutMs) {
|
|
||||||
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} (${runtimeConfig.startupTimeoutMs}ms).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launch(): Promise<void> {
|
|
||||||
mainWindow = createMainWindow();
|
|
||||||
loadingWindow = createLoadingWindow();
|
|
||||||
|
|
||||||
nodecgProcess = startNodeCG();
|
|
||||||
|
|
||||||
await waitForNodeCGReady(Date.now());
|
|
||||||
|
|
||||||
if (!loadingWindow || loadingWindow.isDestroyed()) {
|
if (!loadingWindow || loadingWindow.isDestroyed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadingWindow.loadURL(loadingUrl);
|
await loadingWindow.loadURL(loadingDashboardUrl);
|
||||||
loadingWindow.show();
|
loadingWindow.show();
|
||||||
|
|
||||||
const loadingShownAt = Date.now();
|
const loadingShownAt = Date.now();
|
||||||
@@ -257,9 +51,9 @@ async function launch(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await mainWindow.loadURL(dashboardUrl);
|
await mainWindow.loadURL(mainDashboardUrl);
|
||||||
|
|
||||||
const remainingLoadingDelay = Math.max(0, runtimeConfig.loadDelayMs - (Date.now() - loadingShownAt));
|
const remainingLoadingDelay = getRemainingDelayMs(appConfig.loadDelayMs, loadingShownAt);
|
||||||
if (remainingLoadingDelay > 0) {
|
if (remainingLoadingDelay > 0) {
|
||||||
await sleep(remainingLoadingDelay);
|
await sleep(remainingLoadingDelay);
|
||||||
}
|
}
|
||||||
@@ -268,121 +62,10 @@ async function launch(): Promise<void> {
|
|||||||
closeLoadingWindow();
|
closeLoadingWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
function killNodeCGProcessTree(pid: number, signal: NodeJS.Signals): boolean {
|
function sleep(ms: number): Promise<void> {
|
||||||
if (process.platform === "win32") {
|
return new Promise((resolve) => {
|
||||||
const force = signal === "SIGKILL" ? "/F" : "";
|
setTimeout(resolve, ms);
|
||||||
const killer = spawn("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
|
|
||||||
stdio: "ignore",
|
|
||||||
shell: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
killer.on("error", (error) => {
|
|
||||||
log(`taskkill error for pid=${pid}`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
process.kill(-pid, signal);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
process.kill(pid, signal);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopNodeCG(): Promise<void> {
|
|
||||||
if (stopNodeCGPromise) {
|
|
||||||
return stopNodeCGPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nodecgProcess || nodecgProcess.killed) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
const processToStop = nodecgProcess;
|
|
||||||
const pid = processToStop.pid;
|
|
||||||
|
|
||||||
if (typeof pid !== "number") {
|
|
||||||
log("NodeCG pid unavailable, skipping graceful stop");
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`Stopping NodeCG pid=${pid}`);
|
|
||||||
killNodeCGProcessTree(pid, "SIGTERM");
|
|
||||||
|
|
||||||
stopNodeCGPromise = new Promise((resolve) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (nodecgProcess === processToStop) {
|
|
||||||
nodecgProcess = null;
|
|
||||||
}
|
|
||||||
stopNodeCGPromise = null;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
processToStop.once("exit", () => {
|
|
||||||
complete();
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
|
||||||
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
|
||||||
killNodeCGProcessTree(pid, "SIGKILL");
|
|
||||||
}
|
|
||||||
}, Math.max(0, runtimeConfig.nodecgKillTimeoutMs));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return stopNodeCGPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
function log(...args: unknown[]): void {
|
|
||||||
console.log("[scoreko-electron]", ...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatErrorMessage(error: unknown): string {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
const stack = error.stack?.trim();
|
|
||||||
return stack && stack.length > 0 ? stack : error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFatalError(message: string, error?: unknown): void {
|
|
||||||
const formattedError = error ? formatErrorMessage(error) : undefined;
|
|
||||||
const details = formattedError ? `${message}\n\n${formattedError}` : message;
|
|
||||||
|
|
||||||
console.error(details);
|
|
||||||
|
|
||||||
if (!app.isReady()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.showErrorBox("Scoreko - Error al iniciar", details);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOptionalEnv(name: string): string | undefined {
|
|
||||||
const value = process.env[name]?.trim();
|
|
||||||
return value && value.length > 0 ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEnv(name: string, fallback: string): string {
|
|
||||||
return getOptionalEnv(name) ?? fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvInt(name: string, fallback: number): number {
|
|
||||||
const rawValue = process.env[name];
|
|
||||||
if (!rawValue) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedValue = Number.parseInt(rawValue, 10);
|
|
||||||
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLoadingWindow(): void {
|
function closeLoadingWindow(): void {
|
||||||
@@ -394,14 +77,30 @@ function closeLoadingWindow(): void {
|
|||||||
loadingWindow = null;
|
loadingWindow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on("ready", () => {
|
function stopNodecgGracefully(): Promise<void> {
|
||||||
app.setName(runtimeConfig.title);
|
if (shutdownState === "stopped") {
|
||||||
|
return Promise.resolve();
|
||||||
if (process.platform === "win32") {
|
|
||||||
app.setAppUserModelId(runtimeConfig.userModelId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
launch().catch(async (error: unknown) => {
|
if (shutdownState === "stopping") {
|
||||||
|
return nodecgManager.stopNodecgProcessGracefully();
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdownState = "stopping";
|
||||||
|
|
||||||
|
return nodecgManager.stopNodecgProcessGracefully().finally(() => {
|
||||||
|
shutdownState = "stopped";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on("ready", () => {
|
||||||
|
app.setName(appConfig.title);
|
||||||
|
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
app.setAppUserModelId(appConfig.userModelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
launchApplication().catch((error: unknown) => {
|
||||||
showFatalError("No se pudo iniciar Scoreko.", error);
|
showFatalError("No se pudo iniciar Scoreko.", error);
|
||||||
closeLoadingWindow();
|
closeLoadingWindow();
|
||||||
app.exit(1);
|
app.exit(1);
|
||||||
@@ -410,8 +109,8 @@ app.on("ready", () => {
|
|||||||
|
|
||||||
app.on("activate", async () => {
|
app.on("activate", async () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
mainWindow = createMainWindow();
|
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
|
||||||
await mainWindow.loadURL(dashboardUrl);
|
await mainWindow.loadURL(mainDashboardUrl);
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -423,24 +122,27 @@ app.on("window-all-closed", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on("before-quit", (event) => {
|
app.on("before-quit", (event) => {
|
||||||
if (isQuitting) {
|
if (shutdownState !== "running") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
isQuitting = true;
|
|
||||||
|
|
||||||
stopNodeCG().finally(() => {
|
stopNodecgGracefully().finally(() => {
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("will-quit", () => {
|
app.on("will-quit", () => {
|
||||||
stopNodeCG();
|
if (shutdownState === "running") {
|
||||||
|
void stopNodecgGracefully();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("exit", () => {
|
process.on("exit", () => {
|
||||||
stopNodeCG();
|
if (shutdownState === "running") {
|
||||||
|
void stopNodecgGracefully();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { ChildProcess, spawn, SpawnOptions } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
|
import { NODE_RUNTIME_NAME } from "../constants";
|
||||||
|
|
||||||
|
type NodecgProcessManagerConfig = {
|
||||||
|
isDev: boolean;
|
||||||
|
nodecgRootPath: string;
|
||||||
|
nodecgBaseUrl: string;
|
||||||
|
appConfig: AppRuntimeConfig;
|
||||||
|
log: (...args: unknown[]) => void;
|
||||||
|
deps?: Partial<NodecgProcessManagerDeps>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NodecgProcessManagerDeps = {
|
||||||
|
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
||||||
|
pathExists: (candidatePath: string) => boolean;
|
||||||
|
fetchUrl: typeof fetch;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
execPath: string;
|
||||||
|
env: NodeJS.ProcessEnv;
|
||||||
|
killProcess: (pid: number, signal: NodeJS.Signals) => void;
|
||||||
|
setTimer: (handler: () => void, timeoutMs: number) => unknown;
|
||||||
|
stdoutWrite: (chunk: string) => void;
|
||||||
|
stderrWrite: (chunk: string) => void;
|
||||||
|
probePortAvailable: (port: number) => Promise<boolean>;
|
||||||
|
hasReadWriteAccess: (candidatePath: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodecgProcessManager = {
|
||||||
|
startNodecgProcess: () => Promise<ChildProcess>;
|
||||||
|
waitForNodecgReady: (startTime: number) => Promise<void>;
|
||||||
|
stopNodecgProcessGracefully: () => Promise<void>;
|
||||||
|
getProcess: () => ChildProcess | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createNodecgProcessManager({
|
||||||
|
isDev,
|
||||||
|
nodecgRootPath,
|
||||||
|
nodecgBaseUrl,
|
||||||
|
appConfig,
|
||||||
|
log,
|
||||||
|
deps,
|
||||||
|
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
||||||
|
const resolvedDeps = resolveDeps(deps);
|
||||||
|
|
||||||
|
let nodecgProcess: ChildProcess | null = null;
|
||||||
|
let stopNodecgPromise: Promise<void> | null = null;
|
||||||
|
let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||||
|
let lastStderrLine: string | null = null;
|
||||||
|
|
||||||
|
const startNodecgProcess = async (): Promise<ChildProcess> => {
|
||||||
|
validateNodecgInstall(
|
||||||
|
nodecgRootPath,
|
||||||
|
appConfig.bundleName,
|
||||||
|
resolvedDeps.pathExists,
|
||||||
|
resolvedDeps.hasReadWriteAccess,
|
||||||
|
);
|
||||||
|
|
||||||
|
const portAsNumber = Number.parseInt(appConfig.nodecgPort, 10);
|
||||||
|
const isPortAvailable = await resolvedDeps.probePortAvailable(portAsNumber);
|
||||||
|
if (!isPortAvailable) {
|
||||||
|
throw new Error(
|
||||||
|
`El puerto ${appConfig.nodecgPort} ya está en uso. Cierra el proceso que lo ocupa o configura NODECG_PORT antes de iniciar.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexPath = path.join(nodecgRootPath, "index.js");
|
||||||
|
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
|
||||||
|
cwd: nodecgRootPath,
|
||||||
|
env: {
|
||||||
|
...resolvedDeps.env,
|
||||||
|
NODE_ENV: isDev ? "development" : "production",
|
||||||
|
NODECG_PORT: appConfig.nodecgPort,
|
||||||
|
ELECTRON_RUN_AS_NODE: "1",
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: resolvedDeps.platform !== "win32",
|
||||||
|
shell: resolvedDeps.platform === "win32",
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk) => {
|
||||||
|
resolvedDeps.stdoutWrite(String(chunk));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
const line = String(chunk);
|
||||||
|
lastStderrLine = line.trim().length > 0 ? line.trim() : lastStderrLine;
|
||||||
|
resolvedDeps.stderrWrite(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
|
||||||
|
lastExit = { code, signal };
|
||||||
|
nodecgProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
lastExit = null;
|
||||||
|
lastStderrLine = null;
|
||||||
|
nodecgProcess = child;
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForNodecgReady = async (startTime: number): Promise<void> => {
|
||||||
|
while (Date.now() - startTime < appConfig.startupTimeoutMs) {
|
||||||
|
if (!nodecgProcess) {
|
||||||
|
const exitDetails = lastExit
|
||||||
|
? `Última salida registrada: code=${lastExit.code ?? "null"}, signal=${lastExit.signal ?? "none"}.`
|
||||||
|
: "No se registró código de salida del proceso NodeCG.";
|
||||||
|
const stderrDetails = lastStderrLine ? `Último stderr: ${lastStderrLine}` : "Sin salida stderr capturada.";
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"NodeCG terminó antes de estar listo.",
|
||||||
|
exitDetails,
|
||||||
|
stderrDetails,
|
||||||
|
`Ruta NodeCG: ${nodecgRootPath}`,
|
||||||
|
"Revisa que lib/nodecg tenga dependencias instaladas y que el bundle exista.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await resolvedDeps.fetchUrl(nodecgBaseUrl, { method: "GET" });
|
||||||
|
if (response.ok || response.status === 404) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry until timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(500, resolvedDeps.setTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timeout esperando NodeCG en ${nodecgBaseUrl} (${appConfig.startupTimeoutMs}ms).`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopNodecgProcessGracefully = (): Promise<void> => {
|
||||||
|
if (stopNodecgPromise) {
|
||||||
|
return stopNodecgPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodecgProcess || nodecgProcess.killed) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const processToStop = nodecgProcess;
|
||||||
|
const pid = processToStop.pid;
|
||||||
|
|
||||||
|
if (typeof pid !== "number") {
|
||||||
|
log("NodeCG pid unavailable, skipping graceful stop");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Stopping NodeCG pid=${pid}`);
|
||||||
|
killNodecgProcessTree(pid, "SIGTERM", log, resolvedDeps);
|
||||||
|
|
||||||
|
stopNodecgPromise = new Promise((resolve) => {
|
||||||
|
const complete = () => {
|
||||||
|
if (nodecgProcess === processToStop) {
|
||||||
|
nodecgProcess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopNodecgPromise = null;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
processToStop.once("exit", () => {
|
||||||
|
complete();
|
||||||
|
});
|
||||||
|
|
||||||
|
resolvedDeps.setTimer(
|
||||||
|
() => {
|
||||||
|
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
||||||
|
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
||||||
|
killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Math.max(0, appConfig.nodecgKillTimeoutMs),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return stopNodecgPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
startNodecgProcess,
|
||||||
|
waitForNodecgReady,
|
||||||
|
stopNodecgProcessGracefully,
|
||||||
|
getProcess: () => nodecgProcess,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDeps(deps?: Partial<NodecgProcessManagerDeps>): NodecgProcessManagerDeps {
|
||||||
|
return {
|
||||||
|
spawnProcess: deps?.spawnProcess ?? spawn,
|
||||||
|
pathExists: deps?.pathExists ?? fs.existsSync,
|
||||||
|
fetchUrl: deps?.fetchUrl ?? fetch,
|
||||||
|
platform: deps?.platform ?? process.platform,
|
||||||
|
execPath: deps?.execPath ?? process.execPath,
|
||||||
|
env: deps?.env ?? process.env,
|
||||||
|
killProcess: deps?.killProcess ?? process.kill,
|
||||||
|
setTimer: deps?.setTimer ?? setTimeout,
|
||||||
|
stdoutWrite: deps?.stdoutWrite ?? ((chunk) => process.stdout.write(chunk)),
|
||||||
|
stderrWrite: deps?.stderrWrite ?? ((chunk) => process.stderr.write(chunk)),
|
||||||
|
probePortAvailable: deps?.probePortAvailable ?? probePortAvailable,
|
||||||
|
hasReadWriteAccess: deps?.hasReadWriteAccess ?? hasReadWriteAccess,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNodecgInstall(
|
||||||
|
nodecgRootPath: string,
|
||||||
|
bundleName: string,
|
||||||
|
pathExists: (candidatePath: string) => boolean,
|
||||||
|
hasReadWriteAccessToPath: (candidatePath: string) => boolean,
|
||||||
|
): void {
|
||||||
|
const indexPath = path.join(nodecgRootPath, "index.js");
|
||||||
|
const nodecgBootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
||||||
|
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
|
||||||
|
|
||||||
|
if (!pathExists(nodecgRootPath)) {
|
||||||
|
throw new Error(`No existe la carpeta NodeCG: ${nodecgRootPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasReadWriteAccessToPath(nodecgRootPath)) {
|
||||||
|
throw new Error(`Sin permisos de lectura/escritura sobre NodeCG: ${nodecgRootPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pathExists(indexPath)) {
|
||||||
|
throw new Error(`No se encontró ${indexPath}. Copia una instalación completa de NodeCG en lib/nodecg.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pathExists(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 (!pathExists(bundlePath)) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`No se encontró el bundle '${bundleName}'.`,
|
||||||
|
`Ruta esperada: ${bundlePath}`,
|
||||||
|
"Copia/clona tu bundle dentro de lib/nodecg/bundles antes de ejecutar Electron.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReadWriteAccess(candidatePath: string): boolean {
|
||||||
|
try {
|
||||||
|
fs.accessSync(candidatePath, fs.constants.R_OK | fs.constants.W_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function probePortAvailable(port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
const complete = (isAvailable: boolean): void => {
|
||||||
|
if (resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved = true;
|
||||||
|
socket.destroy();
|
||||||
|
resolve(isAvailable);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.setTimeout(1000);
|
||||||
|
|
||||||
|
socket.once("connect", () => {
|
||||||
|
complete(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once("timeout", () => {
|
||||||
|
complete(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.once("error", (error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === "ECONNREFUSED" || error.code === "EHOSTUNREACH") {
|
||||||
|
complete(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
complete(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function killNodecgProcessTree(
|
||||||
|
pid: number,
|
||||||
|
signal: NodeJS.Signals,
|
||||||
|
log: (...args: unknown[]) => void,
|
||||||
|
deps: Pick<NodecgProcessManagerDeps, "platform" | "spawnProcess" | "killProcess">,
|
||||||
|
): boolean {
|
||||||
|
if (deps.platform === "win32") {
|
||||||
|
const force = signal === "SIGKILL" ? "/F" : "";
|
||||||
|
const killer = deps.spawnProcess("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
|
||||||
|
stdio: "ignore",
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
killer.on("error", (error) => {
|
||||||
|
log(`taskkill error for pid=${pid}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
deps.killProcess(-pid, signal);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
deps.killProcess(pid, signal);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimer(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function getRemainingDelayMs(
|
||||||
|
targetDelayMs: number,
|
||||||
|
startedAtMs: number,
|
||||||
|
currentTimeMs: number = Date.now(),
|
||||||
|
): number {
|
||||||
|
return Math.max(0, targetDelayMs - (currentTimeMs - startedAtMs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
|
|
||||||
|
export function resolveAppIconPath(
|
||||||
|
appConfig: AppRuntimeConfig,
|
||||||
|
rootPath: string,
|
||||||
|
pathExists: (candidatePath: string) => boolean = fs.existsSync,
|
||||||
|
): string | undefined {
|
||||||
|
const iconCandidates = [
|
||||||
|
appConfig.iconPathOverride,
|
||||||
|
path.join(rootPath, "static", "icons", "icon.ico"),
|
||||||
|
path.join(rootPath, "static", "icons", "icon.png"),
|
||||||
|
path.join(rootPath, "static", "icon.ico"),
|
||||||
|
path.join(rootPath, "static", "icon.png"),
|
||||||
|
].filter((candidate): candidate is string => Boolean(candidate));
|
||||||
|
|
||||||
|
return iconCandidates.find((candidate) => pathExists(candidate));
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
|
||||||
|
|
||||||
|
export function shouldAllowInternalNavigation(targetUrl: string, dashboardUrl: string): boolean {
|
||||||
|
try {
|
||||||
|
const target = new URL(targetUrl);
|
||||||
|
const dashboard = new URL(dashboardUrl);
|
||||||
|
|
||||||
|
if (!isSafeProtocol(target.protocol)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoopbackHost(target.hostname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.port !== dashboard.port) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.pathname.startsWith("/bundles/");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldOpenExternalNavigation(targetUrl: string): boolean {
|
||||||
|
try {
|
||||||
|
const target = new URL(targetUrl);
|
||||||
|
return SAFE_EXTERNAL_PROTOCOLS.has(target.protocol);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeProtocol(protocol: string): boolean {
|
||||||
|
return protocol === "http:" || protocol === "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(hostname: string): boolean {
|
||||||
|
return hostname === "localhost" || hostname === "127.0.0.1";
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron";
|
||||||
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
|
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
|
||||||
|
import { resolveAppIconPath } from "./icon-path";
|
||||||
|
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-security";
|
||||||
|
|
||||||
|
type WindowFactoryDependencies = {
|
||||||
|
appConfig: AppRuntimeConfig;
|
||||||
|
rootPath: string;
|
||||||
|
mainDashboardUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: WindowFactoryDependencies): BrowserWindow {
|
||||||
|
const windowOptions = createWindowOptions({ appConfig, rootPath, isLoadingWindow: false });
|
||||||
|
const window = new BrowserWindow(windowOptions);
|
||||||
|
|
||||||
|
window.setMenuBarVisibility(false);
|
||||||
|
|
||||||
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (shouldOpenExternalNavigation(url)) {
|
||||||
|
void shell.openExternal(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
|
||||||
|
window.webContents.on("will-navigate", (event, url) => {
|
||||||
|
if (shouldAllowInternalNavigation(url, mainDashboardUrl)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (shouldOpenExternalNavigation(url)) {
|
||||||
|
void shell.openExternal(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.on("page-title-updated", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLoadingWindow({
|
||||||
|
appConfig,
|
||||||
|
rootPath,
|
||||||
|
}: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
|
||||||
|
const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true }));
|
||||||
|
|
||||||
|
window.on("page-title-updated", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindowOptions({
|
||||||
|
appConfig,
|
||||||
|
rootPath,
|
||||||
|
isLoadingWindow,
|
||||||
|
}: {
|
||||||
|
appConfig: AppRuntimeConfig;
|
||||||
|
rootPath: string;
|
||||||
|
isLoadingWindow: boolean;
|
||||||
|
}): BrowserWindowConstructorOptions {
|
||||||
|
const iconPath = resolveAppIconPath(appConfig, rootPath);
|
||||||
|
|
||||||
|
const baseOptions: BrowserWindowConstructorOptions = {
|
||||||
|
show: false,
|
||||||
|
title: appConfig.title,
|
||||||
|
...(iconPath ? { icon: iconPath } : {}),
|
||||||
|
backgroundColor: DEFAULT_WINDOW_BACKGROUND,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
...(isLoadingWindow ? {} : { nodeIntegration: false }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingWindow) {
|
||||||
|
return {
|
||||||
|
...baseOptions,
|
||||||
|
frame: false,
|
||||||
|
width: LOADING_WINDOW_SIZE.width,
|
||||||
|
height: LOADING_WINDOW_SIZE.height,
|
||||||
|
resizable: false,
|
||||||
|
movable: true,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseOptions,
|
||||||
|
width: DEFAULT_WINDOW_SIZE.width,
|
||||||
|
height: DEFAULT_WINDOW_SIZE.height,
|
||||||
|
minWidth: DEFAULT_WINDOW_SIZE.minWidth,
|
||||||
|
minHeight: DEFAULT_WINDOW_SIZE.minHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { AppRuntimeConfig } from "../main/config/runtime-config";
|
||||||
|
import { resolveAppIconPath } from "../main/windows/icon-path";
|
||||||
|
|
||||||
|
function getBaseConfig(): AppRuntimeConfig {
|
||||||
|
return {
|
||||||
|
title: "Scoreko",
|
||||||
|
userModelId: "com.scoreko.desktop",
|
||||||
|
nodecgPort: "9090",
|
||||||
|
bundleName: "scoreko-dev",
|
||||||
|
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
|
||||||
|
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
|
||||||
|
loadDelayMs: 10000,
|
||||||
|
startupTimeoutMs: 30000,
|
||||||
|
nodecgKillTimeoutMs: 2500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("resolveAppIconPath prioriza iconPathOverride cuando existe", () => {
|
||||||
|
const appConfig: AppRuntimeConfig = {
|
||||||
|
...getBaseConfig(),
|
||||||
|
iconPathOverride: "/custom/icon.ico",
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconPath = resolveAppIconPath(appConfig, "/app", (candidate) => candidate === "/custom/icon.ico");
|
||||||
|
|
||||||
|
assert.equal(iconPath, "/custom/icon.ico");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveAppIconPath cae al primer icono por defecto existente", () => {
|
||||||
|
const appConfig = getBaseConfig();
|
||||||
|
const expectedIconPath = path.join("/app", "static", "icons", "icon.png");
|
||||||
|
|
||||||
|
const iconPath = resolveAppIconPath(appConfig, "/app", (candidate) => candidate === expectedIconPath);
|
||||||
|
|
||||||
|
assert.equal(iconPath, expectedIconPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveAppIconPath devuelve undefined cuando no hay iconos", () => {
|
||||||
|
const appConfig = getBaseConfig();
|
||||||
|
|
||||||
|
const iconPath = resolveAppIconPath(appConfig, "/app", () => false);
|
||||||
|
|
||||||
|
assert.equal(iconPath, undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation-security";
|
||||||
|
|
||||||
|
const dashboardUrl = "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html";
|
||||||
|
|
||||||
|
test("shouldAllowInternalNavigation permite navegación interna esperada", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldAllowInternalNavigation("http://127.0.0.1:9090/bundles/scoreko-dev/dashboard/page.html", dashboardUrl),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldAllowInternalNavigation rechaza host no permitido", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldAllowInternalNavigation("http://evil.local:9090/bundles/scoreko-dev/dashboard/page.html", dashboardUrl),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldAllowInternalNavigation rechaza puerto distinto", () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldAllowInternalNavigation("http://localhost:8080/bundles/scoreko-dev/dashboard/page.html", dashboardUrl),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldAllowInternalNavigation rechaza esquemas inseguros", () => {
|
||||||
|
assert.equal(shouldAllowInternalNavigation("javascript:alert(1)", dashboardUrl), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldOpenExternalNavigation permite protocolos externos seguros", () => {
|
||||||
|
assert.equal(shouldOpenExternalNavigation("https://scoreko.com/docs"), true);
|
||||||
|
assert.equal(shouldOpenExternalNavigation("mailto:test@scoreko.com"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shouldOpenExternalNavigation rechaza protocolos inseguros", () => {
|
||||||
|
assert.equal(shouldOpenExternalNavigation("file:///etc/passwd"), false);
|
||||||
|
assert.equal(shouldOpenExternalNavigation("javascript:alert(1)"), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { AppRuntimeConfig } from "../main/config/runtime-config";
|
||||||
|
import { createNodecgProcessManager } from "../main/nodecg/process-manager";
|
||||||
|
|
||||||
|
class MockChildProcess extends EventEmitter {
|
||||||
|
pid: number | undefined;
|
||||||
|
killed = false;
|
||||||
|
exitCode: number | null = null;
|
||||||
|
signalCode: NodeJS.Signals | null = null;
|
||||||
|
|
||||||
|
constructor(pid: number) {
|
||||||
|
super();
|
||||||
|
this.pid = pid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseConfig(): AppRuntimeConfig {
|
||||||
|
return {
|
||||||
|
title: "Scoreko",
|
||||||
|
userModelId: "com.scoreko.desktop",
|
||||||
|
nodecgPort: "9090",
|
||||||
|
bundleName: "scoreko-dev",
|
||||||
|
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
|
||||||
|
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
|
||||||
|
loadDelayMs: 10000,
|
||||||
|
startupTimeoutMs: 100,
|
||||||
|
nodecgKillTimeoutMs: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("startNodeCG valida instalación de NodeCG antes de arrancar", async () => {
|
||||||
|
const manager = createNodecgProcessManager({
|
||||||
|
isDev: true,
|
||||||
|
nodecgRootPath: "/fake/nodecg",
|
||||||
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
|
appConfig: getBaseConfig(),
|
||||||
|
log: () => undefined,
|
||||||
|
deps: {
|
||||||
|
pathExists: () => false,
|
||||||
|
spawnProcess: () => {
|
||||||
|
throw new Error("no debe intentar arrancar si la validación falla");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(async () => {
|
||||||
|
await manager.startNodecgProcess();
|
||||||
|
}, /No existe la carpeta NodeCG/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("startNodeCG falla si no hay permisos de lectura/escritura", async () => {
|
||||||
|
const manager = createNodecgProcessManager({
|
||||||
|
isDev: true,
|
||||||
|
nodecgRootPath: "/fake/nodecg",
|
||||||
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
|
appConfig: getBaseConfig(),
|
||||||
|
log: () => undefined,
|
||||||
|
deps: {
|
||||||
|
pathExists: () => true,
|
||||||
|
hasReadWriteAccess: () => false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(async () => {
|
||||||
|
await manager.startNodecgProcess();
|
||||||
|
}, /Sin permisos de lectura\/escritura/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waitForNodeCGReady resuelve cuando el endpoint responde 404", async () => {
|
||||||
|
const child = new MockChildProcess(4321);
|
||||||
|
const manager = createNodecgProcessManager({
|
||||||
|
isDev: true,
|
||||||
|
nodecgRootPath: "/fake/nodecg",
|
||||||
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
|
appConfig: getBaseConfig(),
|
||||||
|
log: () => undefined,
|
||||||
|
deps: {
|
||||||
|
platform: "linux",
|
||||||
|
pathExists: () => true,
|
||||||
|
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||||
|
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||||
|
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
||||||
|
handler();
|
||||||
|
return 0 as never;
|
||||||
|
},
|
||||||
|
stdoutWrite: () => undefined,
|
||||||
|
stderrWrite: () => undefined,
|
||||||
|
probePortAvailable: async () => true,
|
||||||
|
hasReadWriteAccess: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startNodecgProcess();
|
||||||
|
await assert.doesNotReject(async () => {
|
||||||
|
await manager.waitForNodecgReady(Date.now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stopNodeCG envía SIGTERM y luego SIGKILL si el proceso no sale", async () => {
|
||||||
|
const child = new MockChildProcess(9999);
|
||||||
|
const timers: Array<() => void> = [];
|
||||||
|
const killSignals: Array<{ pid: number; signal: NodeJS.Signals }> = [];
|
||||||
|
|
||||||
|
const manager = createNodecgProcessManager({
|
||||||
|
isDev: true,
|
||||||
|
nodecgRootPath: "/fake/nodecg",
|
||||||
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
|
appConfig: getBaseConfig(),
|
||||||
|
log: () => undefined,
|
||||||
|
deps: {
|
||||||
|
platform: "linux",
|
||||||
|
pathExists: () => true,
|
||||||
|
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||||
|
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||||
|
killProcess: (pid, signal) => {
|
||||||
|
killSignals.push({ pid, signal });
|
||||||
|
},
|
||||||
|
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
||||||
|
timers.push(() => handler());
|
||||||
|
return 0 as never;
|
||||||
|
},
|
||||||
|
stdoutWrite: () => undefined,
|
||||||
|
stderrWrite: () => undefined,
|
||||||
|
probePortAvailable: async () => true,
|
||||||
|
hasReadWriteAccess: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startNodecgProcess();
|
||||||
|
const stopPromise = manager.stopNodecgProcessGracefully();
|
||||||
|
|
||||||
|
assert.deepEqual(killSignals, [{ pid: -9999, signal: "SIGTERM" }]);
|
||||||
|
|
||||||
|
timers.forEach((runTimer) => {
|
||||||
|
runTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(killSignals, [
|
||||||
|
{ pid: -9999, signal: "SIGTERM" },
|
||||||
|
{ pid: -9999, signal: "SIGKILL" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
child.emit("exit", 0, null);
|
||||||
|
await stopPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stopNodeCG reutiliza la misma promesa cuando se invoca en paralelo", async () => {
|
||||||
|
const child = new MockChildProcess(5555);
|
||||||
|
|
||||||
|
const manager = createNodecgProcessManager({
|
||||||
|
isDev: true,
|
||||||
|
nodecgRootPath: "/fake/nodecg",
|
||||||
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
|
appConfig: getBaseConfig(),
|
||||||
|
log: () => undefined,
|
||||||
|
deps: {
|
||||||
|
pathExists: () => true,
|
||||||
|
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||||
|
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||||
|
killProcess: () => undefined,
|
||||||
|
setTimer: () => 0,
|
||||||
|
stdoutWrite: () => undefined,
|
||||||
|
stderrWrite: () => undefined,
|
||||||
|
probePortAvailable: async () => true,
|
||||||
|
hasReadWriteAccess: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startNodecgProcess();
|
||||||
|
const firstStop = manager.stopNodecgProcessGracefully();
|
||||||
|
const secondStop = manager.stopNodecgProcessGracefully();
|
||||||
|
|
||||||
|
assert.equal(firstStop, secondStop);
|
||||||
|
|
||||||
|
child.emit("exit", 0, null);
|
||||||
|
await firstStop;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stopNodeCG normaliza timeout negativo a cero", async () => {
|
||||||
|
const child = new MockChildProcess(7777);
|
||||||
|
const timeouts: number[] = [];
|
||||||
|
|
||||||
|
const manager = createNodecgProcessManager({
|
||||||
|
isDev: true,
|
||||||
|
nodecgRootPath: "/fake/nodecg",
|
||||||
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
|
appConfig: {
|
||||||
|
...getBaseConfig(),
|
||||||
|
nodecgKillTimeoutMs: -10,
|
||||||
|
},
|
||||||
|
log: () => undefined,
|
||||||
|
deps: {
|
||||||
|
pathExists: () => true,
|
||||||
|
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||||
|
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||||
|
killProcess: () => undefined,
|
||||||
|
setTimer: (handler, timeoutMs) => {
|
||||||
|
timeouts.push(timeoutMs);
|
||||||
|
handler();
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
stdoutWrite: () => undefined,
|
||||||
|
stderrWrite: () => undefined,
|
||||||
|
probePortAvailable: async () => true,
|
||||||
|
hasReadWriteAccess: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startNodecgProcess();
|
||||||
|
const stopPromise = manager.stopNodecgProcessGracefully();
|
||||||
|
|
||||||
|
assert.ok(timeouts.includes(0));
|
||||||
|
|
||||||
|
child.emit("exit", 0, null);
|
||||||
|
await stopPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("startNodeCG falla si el puerto ya está ocupado", async () => {
|
||||||
|
const manager = createNodecgProcessManager({
|
||||||
|
isDev: true,
|
||||||
|
nodecgRootPath: "/fake/nodecg",
|
||||||
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
|
appConfig: getBaseConfig(),
|
||||||
|
log: () => undefined,
|
||||||
|
deps: {
|
||||||
|
pathExists: () => true,
|
||||||
|
hasReadWriteAccess: () => true,
|
||||||
|
probePortAvailable: async () => false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(async () => {
|
||||||
|
await manager.startNodecgProcess();
|
||||||
|
}, /ya está en uso/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waitForNodeCGReady expone diagnóstico cuando NodeCG sale antes de readiness", async () => {
|
||||||
|
const child = new MockChildProcess(4242);
|
||||||
|
const manager = createNodecgProcessManager({
|
||||||
|
isDev: true,
|
||||||
|
nodecgRootPath: "/fake/nodecg",
|
||||||
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
|
appConfig: getBaseConfig(),
|
||||||
|
log: () => undefined,
|
||||||
|
deps: {
|
||||||
|
pathExists: () => true,
|
||||||
|
platform: "linux",
|
||||||
|
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||||
|
fetchUrl: async () => {
|
||||||
|
child.emit("exit", 1, null);
|
||||||
|
throw new Error("still starting");
|
||||||
|
},
|
||||||
|
setTimer: (handler) => {
|
||||||
|
handler();
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
stdoutWrite: () => undefined,
|
||||||
|
stderrWrite: () => undefined,
|
||||||
|
probePortAvailable: async () => true,
|
||||||
|
hasReadWriteAccess: () => true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startNodecgProcess();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await manager.waitForNodecgReady(Date.now());
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof Error);
|
||||||
|
assert.match(error.message, /NodeCG terminó antes de estar listo/);
|
||||||
|
assert.match(error.message, /Última salida registrada/);
|
||||||
|
assert.match(error.message, /Ruta NodeCG: \/fake\/nodecg/);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { getEnv, getOptionalEnv, parseEnvInt, parseEnvIntInRange, parseEnvPort } from "../main/config/runtime-config";
|
||||||
|
|
||||||
|
function withEnv(name: string, value: string | undefined, run: () => void): void {
|
||||||
|
const previousValue = process.env[name];
|
||||||
|
|
||||||
|
if (value === undefined) {
|
||||||
|
delete process.env[name];
|
||||||
|
} else {
|
||||||
|
process.env[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
if (previousValue === undefined) {
|
||||||
|
delete process.env[name];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env[name] = previousValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("getOptionalEnv devuelve undefined para variable ausente", () => {
|
||||||
|
withEnv("TEST_OPTIONAL_ENV", undefined, () => {
|
||||||
|
assert.equal(getOptionalEnv("TEST_OPTIONAL_ENV"), undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getOptionalEnv recorta espacios y devuelve valor", () => {
|
||||||
|
withEnv("TEST_OPTIONAL_ENV", " scoreko ", () => {
|
||||||
|
assert.equal(getOptionalEnv("TEST_OPTIONAL_ENV"), "scoreko");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getEnv devuelve fallback para valor vacío", () => {
|
||||||
|
withEnv("TEST_ENV", " ", () => {
|
||||||
|
assert.equal(getEnv("TEST_ENV", "fallback"), "fallback");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getEnv devuelve el valor cuando existe", () => {
|
||||||
|
withEnv("TEST_ENV", "valor", () => {
|
||||||
|
assert.equal(getEnv("TEST_ENV", "fallback"), "valor");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseEnvInt devuelve fallback para valores inválidos", () => {
|
||||||
|
withEnv("TEST_ENV_INT", "abc", () => {
|
||||||
|
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseEnvInt parsea enteros válidos", () => {
|
||||||
|
withEnv("TEST_ENV_INT", "4500", () => {
|
||||||
|
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 4500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseEnvIntInRange hace hard-fail para valores fuera de rango", () => {
|
||||||
|
withEnv("TEST_ENV_INT_RANGE", "999", () => {
|
||||||
|
assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /debe ser un entero/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseEnvIntInRange acepta valor válido", () => {
|
||||||
|
withEnv("TEST_ENV_INT_RANGE", "42", () => {
|
||||||
|
assert.equal(parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), 42);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseEnvPort valida rango TCP", () => {
|
||||||
|
withEnv("TEST_ENV_PORT", "70000", () => {
|
||||||
|
assert.throws(() => parseEnvPort("TEST_ENV_PORT", "9090"), /puerto TCP válido/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseEnvPort normaliza el puerto válido", () => {
|
||||||
|
withEnv("TEST_ENV_PORT", "009090", () => {
|
||||||
|
assert.equal(parseEnvPort("TEST_ENV_PORT", "9090"), "9090");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { getRemainingDelayMs } from "../main/utils/timing";
|
||||||
|
|
||||||
|
test("getRemainingDelayMs devuelve el tiempo restante cuando aún no se cumple", () => {
|
||||||
|
const remaining = getRemainingDelayMs(10000, 1000, 4000);
|
||||||
|
assert.equal(remaining, 7000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getRemainingDelayMs devuelve 0 si ya pasó el delay", () => {
|
||||||
|
const remaining = getRemainingDelayMs(1000, 1000, 5000);
|
||||||
|
assert.equal(remaining, 0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user