diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae9fbd4 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57b4468 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..4b56620 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +release +lib/nodecg +node_modules +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..2dc4363 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": false, + "printWidth": 120, + "trailingComma": "all" +} diff --git a/README.md b/README.md index 809a73a..de96c39 100644 --- a/README.md +++ b/README.md @@ -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,77 +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` +- Guía de troubleshooting: `docs/troubleshooting.md` +- Mapa de arquitectura: `docs/architecture.md` +- Roadmap: `docs/refactor-roadmap.md` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b716afd --- /dev/null +++ b/docs/architecture.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`. diff --git a/docs/refactor-roadmap.md b/docs/refactor-roadmap.md new file mode 100644 index 0000000..c15445a --- /dev/null +++ b/docs/refactor-roadmap.md @@ -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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..866587b --- /dev/null +++ b/docs/troubleshooting.md @@ -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 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. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..3eae9e9 --- /dev/null +++ b/eslint.config.mjs @@ -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: "^_" }], + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index 78be278..3c7e8d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,13 @@ "devDependencies": { "@electron/rebuild": "^3.7.1", "@types/node": "^22.10.5", + "@typescript-eslint/eslint-plugin": "^8.22.0", + "@typescript-eslint/parser": "^8.22.0", "concurrently": "^9.1.2", "electron": "39.5.1", "electron-builder": "^25.1.8", + "eslint": "^9.19.0", + "prettier": "^3.4.2", "rimraf": "^6.0.1", "typescript": "^5.7.3", "wait-on": "^8.0.1" @@ -365,6 +369,209 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -426,6 +633,58 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -667,6 +926,13 @@ "@types/ms": "*" } }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -684,6 +950,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -752,6 +1025,272 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -776,6 +1315,29 @@ "dev": true, "license": "ISC" }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1502,6 +2064,16 @@ "node": ">= 0.4" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2083,6 +2655,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -2526,7 +3105,6 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, @@ -2534,6 +3112,227 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -2587,6 +3386,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -2597,6 +3403,37 @@ "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -2637,6 +3474,44 @@ "node": ">=10" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -2865,6 +3740,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2914,6 +3802,19 @@ "node": ">=10.0" } }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -3175,6 +4076,33 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3244,6 +4172,16 @@ "is-ci": "bin.js" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3254,6 +4192,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -3392,6 +4343,13 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -3493,6 +4451,36 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", @@ -3532,6 +4520,13 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", @@ -3883,6 +4878,13 @@ "dev": true, "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -4114,6 +5116,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -4164,6 +5184,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -4187,6 +5223,29 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4270,6 +5329,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -4285,6 +5357,32 @@ "node": ">=10.4.0" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proc-log": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", @@ -4482,6 +5580,16 @@ "dev": true, "license": "MIT" }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", @@ -4962,6 +6070,19 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -5046,6 +6167,23 @@ "fs-extra": "^10.0.0" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -5086,6 +6224,19 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -5093,6 +6244,19 @@ "dev": true, "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -5260,6 +6424,16 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 52f3833..a8fc85d 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,18 @@ "watch": "tsc -p tsconfig.json --watch", "dev:electron": "wait-on dist/main/main.js && electron .", "pack": "npm run build && electron-builder --dir", - "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" + "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": { "appId": "com.scoreko.desktop", @@ -48,13 +57,14 @@ "target": [ "dmg" ], - "icon": "static/icons/icon.ico" + "icon": "static/icons/icon.icns" }, "linux": { "target": [ "AppImage" ], - "icon": "static/icons" + "icon": "static/icons", + "category": "Utility" }, "win": { "target": [ @@ -86,6 +96,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" } } diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100644 index 0000000..7cf3ed0 --- /dev/null +++ b/scripts/doctor.mjs @@ -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(); diff --git a/src/main/config/runtime-config.ts b/src/main/config/runtime-config.ts new file mode 100644 index 0000000..b9f09d4 --- /dev/null +++ b/src/main/config/runtime-config.ts @@ -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); +} diff --git a/src/main/constants.ts b/src/main/constants.ts new file mode 100644 index 0000000..372b3e5 --- /dev/null +++ b/src/main/constants.ts @@ -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; diff --git a/src/main/errors/error-presenter.ts b/src/main/errors/error-presenter.ts new file mode 100644 index 0000000..9245bb1 --- /dev/null +++ b/src/main/errors/error-presenter.ts @@ -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); +} diff --git a/src/main/errors/logger.ts b/src/main/errors/logger.ts new file mode 100644 index 0000000..dce9c65 --- /dev/null +++ b/src/main/errors/logger.ts @@ -0,0 +1,34 @@ +export type LogLevel = "debug" | "info" | "warn" | "error"; + +type LogContext = Record; + +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), +}; diff --git a/src/main/main.ts b/src/main/main.ts index dd8e596..b424ca8 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,254 +1,48 @@ -import { app, BrowserWindow, BrowserWindowConstructorOptions, dialog, shell } from "electron"; -import { ChildProcess, spawn } from "node:child_process"; -import fs from "node:fs"; +import { app, BrowserWindow } from "electron"; import path from "node:path"; -type AppRuntimeConfig = { - title: string; - userModelId: string; - iconPathOverride?: string; - nodecgPort: string; - bundleName: string; - dashboardRoute: string; - loadingRoute: string; - loadDelayMs: number; - startupTimeoutMs: number; - nodecgKillTimeoutMs: number; -}; +import { getRuntimeConfig } from "./config/runtime-config"; +import { showFatalError, log } from "./errors/error-presenter"; +import { createNodecgProcessManager } from "./nodecg/process-manager"; +import { getRemainingDelayMs } from "./utils/timing"; +import { createLoadingWindow, createMainWindow } from "./windows/window-factory"; -const RUNTIME_NAME = "electron internal node"; -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 appConfig = getRuntimeConfig(); const isDev = !app.isPackaged; const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath; -const nodecgPath = path.resolve(rootPath, "lib", "nodecg"); -const dashboardUrl = `http://localhost:${runtimeConfig.nodecgPort}/bundles/${runtimeConfig.bundleName}/${runtimeConfig.dashboardRoute}`; -const loadingUrl = `http://localhost:${runtimeConfig.nodecgPort}/bundles/${runtimeConfig.bundleName}/${runtimeConfig.loadingRoute}`; -const baseUrl = `http://127.0.0.1:${runtimeConfig.nodecgPort}`; +const nodecgRootPath = path.resolve(rootPath, "lib", "nodecg"); +const mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`; +const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`; +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 loadingWindow: BrowserWindow | null = null; -let nodecgProcess: ChildProcess | null = null; -let stopNodeCGPromise: Promise | null = null; -let isQuitting = false; +let shutdownState: AppShutdownState = "running"; -function createMainWindow(): BrowserWindow { - const windowOptions = createWindowOptions({ isLoadingWindow: false }); - const window = new BrowserWindow(windowOptions); +async function launchApplication(): Promise { + mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl }); + loadingWindow = createLoadingWindow({ appConfig, rootPath }); - window.setMenuBarVisibility(false); + await nodecgManager.startNodecgProcess(); - window.webContents.setWindowOpenHandler(({ url }) => { - 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 { - 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 { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -async function launch(): Promise { - mainWindow = createMainWindow(); - loadingWindow = createLoadingWindow(); - - nodecgProcess = startNodeCG(); - - await waitForNodeCGReady(Date.now()); + await nodecgManager.waitForNodecgReady(Date.now()); if (!loadingWindow || loadingWindow.isDestroyed()) { return; } - await loadingWindow.loadURL(loadingUrl); + await loadingWindow.loadURL(loadingDashboardUrl); loadingWindow.show(); const loadingShownAt = Date.now(); @@ -257,9 +51,9 @@ async function launch(): Promise { 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) { await sleep(remainingLoadingDelay); } @@ -268,121 +62,10 @@ async function launch(): Promise { closeLoadingWindow(); } -function killNodeCGProcessTree(pid: number, signal: NodeJS.Signals): boolean { - if (process.platform === "win32") { - const force = signal === "SIGKILL" ? "/F" : ""; - 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 { - 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)); +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); }); - - 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 { @@ -394,14 +77,30 @@ function closeLoadingWindow(): void { loadingWindow = null; } -app.on("ready", () => { - app.setName(runtimeConfig.title); - - if (process.platform === "win32") { - app.setAppUserModelId(runtimeConfig.userModelId); +function stopNodecgGracefully(): Promise { + if (shutdownState === "stopped") { + return Promise.resolve(); } - 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); closeLoadingWindow(); app.exit(1); @@ -410,8 +109,8 @@ app.on("ready", () => { app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createMainWindow(); - await mainWindow.loadURL(dashboardUrl); + mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl }); + await mainWindow.loadURL(mainDashboardUrl); mainWindow.show(); } }); @@ -423,24 +122,27 @@ app.on("window-all-closed", () => { }); app.on("before-quit", (event) => { - if (isQuitting) { + if (shutdownState !== "running") { return; } event.preventDefault(); - isQuitting = true; - stopNodeCG().finally(() => { + stopNodecgGracefully().finally(() => { app.quit(); }); }); app.on("will-quit", () => { - stopNodeCG(); + if (shutdownState === "running") { + void stopNodecgGracefully(); + } }); process.on("exit", () => { - stopNodeCG(); + if (shutdownState === "running") { + void stopNodecgGracefully(); + } }); process.on("uncaughtException", (error) => { diff --git a/src/main/nodecg/process-manager.ts b/src/main/nodecg/process-manager.ts new file mode 100644 index 0000000..6c9db98 --- /dev/null +++ b/src/main/nodecg/process-manager.ts @@ -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; +}; + +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; + hasReadWriteAccess: (candidatePath: string) => boolean; +}; + +export type NodecgProcessManager = { + startNodecgProcess: () => Promise; + waitForNodecgReady: (startTime: number) => Promise; + stopNodecgProcessGracefully: () => Promise; + 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 | null = null; + let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + let lastStderrLine: string | null = null; + + const startNodecgProcess = async (): Promise => { + 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 => { + 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 => { + 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 { + 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 { + 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, +): 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 { + return new Promise((resolve) => { + setTimer(resolve, ms); + }); +} diff --git a/src/main/utils/timing.ts b/src/main/utils/timing.ts new file mode 100644 index 0000000..baf6fa3 --- /dev/null +++ b/src/main/utils/timing.ts @@ -0,0 +1,7 @@ +export function getRemainingDelayMs( + targetDelayMs: number, + startedAtMs: number, + currentTimeMs: number = Date.now(), +): number { + return Math.max(0, targetDelayMs - (currentTimeMs - startedAtMs)); +} diff --git a/src/main/windows/icon-path.ts b/src/main/windows/icon-path.ts new file mode 100644 index 0000000..97e2db9 --- /dev/null +++ b/src/main/windows/icon-path.ts @@ -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)); +} diff --git a/src/main/windows/navigation-security.ts b/src/main/windows/navigation-security.ts new file mode 100644 index 0000000..149c4ef --- /dev/null +++ b/src/main/windows/navigation-security.ts @@ -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"; +} diff --git a/src/main/windows/window-factory.ts b/src/main/windows/window-factory.ts new file mode 100644 index 0000000..565fb45 --- /dev/null +++ b/src/main/windows/window-factory.ts @@ -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): 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, + }; +} diff --git a/src/tests/icon-path.test.ts b/src/tests/icon-path.test.ts new file mode 100644 index 0000000..084be08 --- /dev/null +++ b/src/tests/icon-path.test.ts @@ -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); +}); diff --git a/src/tests/navigation-security.test.ts b/src/tests/navigation-security.test.ts new file mode 100644 index 0000000..229ca40 --- /dev/null +++ b/src/tests/navigation-security.test.ts @@ -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); +}); diff --git a/src/tests/process-manager.test.ts b/src/tests/process-manager.test.ts new file mode 100644 index 0000000..579b5fb --- /dev/null +++ b/src/tests/process-manager.test.ts @@ -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; + }, + ); +}); diff --git a/src/tests/runtime-config.test.ts b/src/tests/runtime-config.test.ts new file mode 100644 index 0000000..d2653fa --- /dev/null +++ b/src/tests/runtime-config.test.ts @@ -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"); + }); +}); diff --git a/src/tests/timing.test.ts b/src/tests/timing.test.ts new file mode 100644 index 0000000..108e0fe --- /dev/null +++ b/src/tests/timing.test.ts @@ -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); +});