mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-05 21:22:07 +00:00
feat: complete pending roadmap items with doctor, hardening, and code quality
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,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
|
||||
|
||||
- Node `>=22` (`.nvmrc` incluido).
|
||||
- Electron fijado en `39.5.1`.
|
||||
|
||||
## Qué hace
|
||||
@@ -33,81 +34,31 @@ scoreko-electron-dev/
|
||||
- `npm run dev`: modo desarrollo.
|
||||
- `npm run build`: compila TypeScript y copia assets.
|
||||
- `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 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`)
|
||||
- `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`)
|
||||
La **fuente única de defaults** está en `.env.example`.
|
||||
|
||||
## 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
|
||||
|
||||
- **Runtime (ventanas):** `SCOREKO_APP_TITLE`.
|
||||
- **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`
|
||||
|
||||
## Roadmap de refactor recomendado
|
||||
|
||||
Si quieres una propuesta detallada de refactorización, limpieza y mejoras sin romper funcionalidad, revisa: `docs/refactor-roadmap.md`.
|
||||
- Guía de troubleshooting: `docs/troubleshooting.md`
|
||||
- Mapa de arquitectura: `docs/architecture.md`
|
||||
- Roadmap: `docs/refactor-roadmap.md`
|
||||
|
||||
@@ -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`.
|
||||
@@ -5,6 +5,7 @@ Este documento detalla una propuesta de mejoras para `scoreko-electron-dev` prio
|
||||
## 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.
|
||||
@@ -15,11 +16,13 @@ Este documento detalla una propuesta de mejoras para `scoreko-electron-dev` prio
|
||||
- **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`.
|
||||
@@ -29,6 +32,7 @@ Este documento detalla una propuesta de mejoras para `scoreko-electron-dev` prio
|
||||
## 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.
|
||||
@@ -36,6 +40,7 @@ Este documento detalla una propuesta de mejoras para `scoreko-electron-dev` prio
|
||||
- **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.
|
||||
@@ -43,6 +48,7 @@ Este documento detalla una propuesta de mejoras para `scoreko-electron-dev` prio
|
||||
- **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`.
|
||||
@@ -51,41 +57,48 @@ Este documento detalla una propuesta de mejoras para `scoreko-electron-dev` prio
|
||||
## 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.
|
||||
@@ -93,11 +106,13 @@ Este documento detalla una propuesta de mejoras para `scoreko-electron-dev` prio
|
||||
- **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.
|
||||
@@ -105,27 +120,32 @@ Este documento detalla una propuesta de mejoras para `scoreko-electron-dev` prio
|
||||
## 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.
|
||||
@@ -133,13 +153,16 @@ Este documento detalla una propuesta de mejoras para `scoreko-electron-dev` prio
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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
+12
-3
@@ -21,7 +21,12 @@
|
||||
"dist": "npm run build && electron-builder",
|
||||
"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",
|
||||
"test": "npm run build && node --test dist/tests/**/*.test.js"
|
||||
"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 ."
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.scoreko.desktop",
|
||||
@@ -49,7 +54,7 @@
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"icon": "static/icons/icon.png"
|
||||
"icon": "static/icons/icon.icns"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
@@ -87,6 +92,10 @@
|
||||
"electron-builder": "^25.1.8",
|
||||
"rimraf": "^6.0.1",
|
||||
"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();
|
||||
@@ -67,7 +67,9 @@ export function parseEnvPort(name: string, fallback: string): string {
|
||||
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}'.`);
|
||||
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);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { app, dialog } from "electron";
|
||||
|
||||
import { logger } from "./logger";
|
||||
|
||||
export function log(...args: unknown[]): void {
|
||||
console.log("[scoreko-electron]", ...args);
|
||||
logger.info("runtime", { args });
|
||||
}
|
||||
|
||||
export function formatErrorMessage(error: unknown): string {
|
||||
@@ -17,7 +19,10 @@ export function showFatalError(message: string, error?: unknown): void {
|
||||
const formattedError = error ? formatErrorMessage(error) : undefined;
|
||||
const details = formattedError ? `${message}\n\n${formattedError}` : message;
|
||||
|
||||
console.error(details);
|
||||
logger.error("fatal-startup-error", {
|
||||
message,
|
||||
error: formattedError,
|
||||
});
|
||||
|
||||
if (!app.isReady()) {
|
||||
return;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
+1
-1
@@ -34,7 +34,7 @@ async function launchApplication(): Promise<void> {
|
||||
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
|
||||
loadingWindow = createLoadingWindow({ appConfig, rootPath });
|
||||
|
||||
nodecgManager.startNodecgProcess();
|
||||
await nodecgManager.startNodecgProcess();
|
||||
|
||||
await nodecgManager.waitForNodecgReady(Date.now());
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -25,10 +26,12 @@ type NodecgProcessManagerDeps = {
|
||||
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: () => ChildProcess;
|
||||
startNodecgProcess: () => Promise<ChildProcess>;
|
||||
waitForNodecgReady: (startTime: number) => Promise<void>;
|
||||
stopNodecgProcessGracefully: () => Promise<void>;
|
||||
getProcess: () => ChildProcess | null;
|
||||
@@ -47,8 +50,21 @@ export function createNodecgProcessManager({
|
||||
let nodecgProcess: ChildProcess | null = null;
|
||||
let stopNodecgPromise: Promise<void> | null = null;
|
||||
|
||||
const startNodecgProcess = (): ChildProcess => {
|
||||
validateNodecgInstall(nodecgRootPath, appConfig.bundleName, resolvedDeps.pathExists);
|
||||
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], {
|
||||
@@ -138,12 +154,15 @@ export function createNodecgProcessManager({
|
||||
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));
|
||||
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;
|
||||
@@ -169,10 +188,17 @@ function resolveDeps(deps?: Partial<NodecgProcessManagerDeps>): NodecgProcessMan
|
||||
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): void {
|
||||
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);
|
||||
@@ -181,6 +207,10 @@ function validateNodecgInstall(nodecgRootPath: string, bundleName: string, pathE
|
||||
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.`);
|
||||
}
|
||||
@@ -207,6 +237,31 @@ function validateNodecgInstall(nodecgRootPath: string, bundleName: string, pathE
|
||||
}
|
||||
}
|
||||
|
||||
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 server = net.createServer();
|
||||
|
||||
server.once("error", () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => {
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function killNodecgProcessTree(
|
||||
pid: number,
|
||||
signal: NodeJS.Signals,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function getRemainingDelayMs(targetDelayMs: number, startedAtMs: number, currentTimeMs: number = Date.now()): number {
|
||||
export function getRemainingDelayMs(
|
||||
targetDelayMs: number,
|
||||
startedAtMs: number,
|
||||
currentTimeMs: number = Date.now(),
|
||||
): number {
|
||||
return Math.max(0, targetDelayMs - (currentTimeMs - startedAtMs));
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind
|
||||
return window;
|
||||
}
|
||||
|
||||
export function createLoadingWindow({ appConfig, rootPath }: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
|
||||
export function createLoadingWindow({
|
||||
appConfig,
|
||||
rootPath,
|
||||
}: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
|
||||
const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true }));
|
||||
|
||||
window.on("page-title-updated", (event) => {
|
||||
|
||||
@@ -13,11 +13,17 @@ test("shouldAllowInternalNavigation permite navegación interna esperada", () =>
|
||||
});
|
||||
|
||||
test("shouldAllowInternalNavigation rechaza host no permitido", () => {
|
||||
assert.equal(shouldAllowInternalNavigation("http://evil.local:9090/bundles/scoreko-dev/dashboard/page.html", dashboardUrl), false);
|
||||
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);
|
||||
assert.equal(
|
||||
shouldAllowInternalNavigation("http://localhost:8080/bundles/scoreko-dev/dashboard/page.html", dashboardUrl),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldAllowInternalNavigation rechaza esquemas inseguros", () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ function getBaseConfig(): AppRuntimeConfig {
|
||||
};
|
||||
}
|
||||
|
||||
test("startNodeCG valida instalación de NodeCG antes de arrancar", () => {
|
||||
test("startNodeCG valida instalación de NodeCG antes de arrancar", async () => {
|
||||
const manager = createNodecgProcessManager({
|
||||
isDev: true,
|
||||
nodecgRootPath: "/fake/nodecg",
|
||||
@@ -46,11 +46,29 @@ test("startNodeCG valida instalación de NodeCG antes de arrancar", () => {
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(() => {
|
||||
manager.startNodecgProcess();
|
||||
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({
|
||||
@@ -62,17 +80,19 @@ test("waitForNodeCGReady resuelve cuando el endpoint responde 404", async () =>
|
||||
deps: {
|
||||
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) => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
manager.startNodecgProcess();
|
||||
await manager.startNodecgProcess();
|
||||
await assert.doesNotReject(async () => {
|
||||
await manager.waitForNodecgReady(Date.now());
|
||||
});
|
||||
@@ -92,20 +112,22 @@ test("stopNodeCG envía SIGTERM y luego SIGKILL si el proceso no sale", async ()
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 } as Response),
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: (pid, signal) => {
|
||||
killSignals.push({ pid, signal });
|
||||
},
|
||||
setTimer: ((handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
||||
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
||||
timers.push(() => handler());
|
||||
return 0 as never;
|
||||
}),
|
||||
},
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
probePortAvailable: async () => true,
|
||||
hasReadWriteAccess: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
manager.startNodecgProcess();
|
||||
await manager.startNodecgProcess();
|
||||
const stopPromise = manager.stopNodecgProcessGracefully();
|
||||
|
||||
assert.deepEqual(killSignals, [{ pid: -9999, signal: "SIGTERM" }]);
|
||||
@@ -123,7 +145,6 @@ test("stopNodeCG envía SIGTERM y luego SIGKILL si el proceso no sale", async ()
|
||||
await stopPromise;
|
||||
});
|
||||
|
||||
|
||||
test("stopNodeCG reutiliza la misma promesa cuando se invoca en paralelo", async () => {
|
||||
const child = new MockChildProcess(5555);
|
||||
|
||||
@@ -136,15 +157,17 @@ test("stopNodeCG reutiliza la misma promesa cuando se invoca en paralelo", async
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 } as Response),
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: () => undefined,
|
||||
setTimer: () => 0,
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
probePortAvailable: async () => true,
|
||||
hasReadWriteAccess: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
manager.startNodecgProcess();
|
||||
await manager.startNodecgProcess();
|
||||
const firstStop = manager.stopNodecgProcessGracefully();
|
||||
const secondStop = manager.stopNodecgProcessGracefully();
|
||||
|
||||
@@ -170,7 +193,7 @@ test("stopNodeCG normaliza timeout negativo a cero", async () => {
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 } as Response),
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: () => undefined,
|
||||
setTimer: (handler, timeoutMs) => {
|
||||
timeouts.push(timeoutMs);
|
||||
@@ -179,10 +202,12 @@ test("stopNodeCG normaliza timeout negativo a cero", async () => {
|
||||
},
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
probePortAvailable: async () => true,
|
||||
hasReadWriteAccess: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
manager.startNodecgProcess();
|
||||
await manager.startNodecgProcess();
|
||||
const stopPromise = manager.stopNodecgProcessGracefully();
|
||||
|
||||
assert.ok(timeouts.includes(0));
|
||||
@@ -190,3 +215,22 @@ test("stopNodeCG normaliza timeout negativo a cero", async () => {
|
||||
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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user