mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f756feca6 | |||
| ca74a23d19 | |||
| 8e6b79ca68 | |||
| ce59c5db89 | |||
| 92e2da1758 | |||
| 42a298925b | |||
| 33665ed896 |
+14
-10
@@ -1,24 +1,28 @@
|
||||
# Runtime / app
|
||||
# SCOREKO Configuration File Template
|
||||
# Copy this file to '.env' in the application root and edit as needed.
|
||||
|
||||
# Application Information (Required)
|
||||
SCOREKO_APP_TITLE=Scoreko
|
||||
SCOREKO_APP_USER_MODEL_ID=com.scoreko.desktop
|
||||
SCOREKO_APP_USER_DATA_DIRECTORY=scoreko
|
||||
# SCOREKO_APP_ICON_PATH=static/icons/icon.ico
|
||||
SCOREKO_APP_ICON_PATH=static/icons/icon.ico
|
||||
|
||||
# NodeCG
|
||||
# NodeCG Managed Runtime Configuration (Required)
|
||||
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
|
||||
# Timing & Lifecycles (Required)
|
||||
ELECTRON_LOAD_DELAY_MS=10000
|
||||
NODECG_STARTUP_TIMEOUT_MS=30000
|
||||
NODECG_STARTUP_TIMEOUT_MS=120000
|
||||
NODECG_KILL_TIMEOUT_MS=2500
|
||||
|
||||
# Updates
|
||||
# Automated Updates Configuration (Required)
|
||||
SCOREKO_UPDATES_ENABLED=true
|
||||
# SCOREKO_UPDATE_API_URL=http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest
|
||||
# SCOREKO_UPDATE_RELEASE_PAGE_URL=http://gitea.local/OWNER/REPO/releases
|
||||
SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$
|
||||
SCOREKO_UPDATE_CHECK_DELAY_MS=5000
|
||||
# SCOREKO_UPDATE_CONFIG_PATH=static/updates.json
|
||||
|
||||
# Optional Update Release Source (Only required if SCOREKO_UPDATES_ENABLED is true)
|
||||
SCOREKO_UPDATE_API_URL=http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest
|
||||
SCOREKO_UPDATE_RELEASE_PAGE_URL=http://gitea.local/OWNER/REPO/releases
|
||||
SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$
|
||||
|
||||
@@ -7,3 +7,4 @@ lib
|
||||
.localappdata
|
||||
.npm-cache
|
||||
.npm-runtime-cache
|
||||
.env
|
||||
@@ -5,11 +5,10 @@
|
||||
1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`.
|
||||
2. Installs or refreshes the packaged NodeCG runtime in user data when needed (`nodecg/runtime-provisioner.ts`).
|
||||
3. Creates windows (`windows/window-factory.ts`).
|
||||
4. In packaged builds, relaunches once after a fresh runtime install so NodeCG starts from a settled user-data runtime.
|
||||
5. Starts NodeCG with `nodecg/process-manager.ts`.
|
||||
6. Waits for HTTP readiness and shows loading -> main dashboard.
|
||||
7. Checks the configured Gitea latest-release endpoint for optional updates.
|
||||
8. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
|
||||
4. Starts NodeCG with `nodecg/process-manager.ts`.
|
||||
5. Waits for HTTP readiness and shows loading -> main dashboard.
|
||||
6. Checks the configured Gitea latest-release endpoint for optional updates.
|
||||
7. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
|
||||
|
||||
## Main modules
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Final Cleanup Summary
|
||||
|
||||
## Scope
|
||||
|
||||
Executed the final global cleanup pass using `docs/refactor` as the source of truth.
|
||||
|
||||
Source documents reviewed before code changes:
|
||||
|
||||
- `docs/refactor/ARCHITECTURE_AUDIT.md`
|
||||
- `docs/refactor/ARCHITECTURE_RULES.md`
|
||||
- `docs/refactor/TARGET_ARCHITECTURE.md`
|
||||
- `docs/refactor/MIGRATION_PLAN.md`
|
||||
- `docs/refactor/SESSION_HANDOFF.md`
|
||||
- `docs/refactor/PHASE_1_SUMMARY.md`
|
||||
- `docs/refactor/PHASE_1_FIX_SUMMARY.md`
|
||||
- `docs/refactor/PHASE_2_SUMMARY.md`
|
||||
- `docs/refactor/PHASE_3_SUMMARY.md`
|
||||
- `docs/refactor/PHASE_4_SUMMARY.md`
|
||||
|
||||
## Cleanup Completed
|
||||
|
||||
- Removed the unused `parseEnvInt` helper and its tests.
|
||||
- Consolidated duplicated `unknown` parsing helpers into `src/main/utils/unknown-values.ts`.
|
||||
- Narrowed `ApplicationController` path typing to reuse `ApplicationPaths`.
|
||||
- Narrowed update config module boundaries so updater settings only accept the runtime config fields they need.
|
||||
- Narrowed the NodeCG process manager child-process contract to the actual process surface it uses.
|
||||
- Removed `as unknown as ChildProcess` casts from process-manager tests.
|
||||
- Fixed update dialog Spanish text encoding.
|
||||
- Added an explicit return type to the update dialog message-box helper.
|
||||
- Renamed legacy updater test files:
|
||||
- `update-settings.test.ts` -> `update-config.test.ts`
|
||||
- `update-utils.test.ts` -> `update-schema.test.ts`
|
||||
|
||||
## Architecture Preserved
|
||||
|
||||
- No UX changes.
|
||||
- No new features.
|
||||
- No renderer, preload, or IPC layer.
|
||||
- No NodeCG runtime model changes.
|
||||
- No Electron permission expansion.
|
||||
- No broad framework or new lifecycle layer.
|
||||
- BrowserWindow security posture remains explicit.
|
||||
- Update validation and download boundaries remain separated.
|
||||
- Managed runtime preservation of `cfg`, `db`, and `logs` remains unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
Passed:
|
||||
|
||||
```text
|
||||
npm.cmd run typecheck
|
||||
npm.cmd exec -- tsc --noEmit --noUnusedLocals --noUnusedParameters
|
||||
npm.cmd test
|
||||
npm.cmd run lint
|
||||
npm.cmd run build
|
||||
npm.cmd run doctor
|
||||
```
|
||||
|
||||
Test result:
|
||||
|
||||
```text
|
||||
63 tests passing
|
||||
```
|
||||
|
||||
Sanity searches passed for production/test source:
|
||||
|
||||
```text
|
||||
rg -n "parseEnvInt\(|ActualizaciÃ|estÃ|versiÃ|cerrarÃ|update-utils|update-settings|\bany\b|unknown as|as unknown|@ts-ignore|@ts-expect-error|eslint-disable|nodeIntegration:\s*true|webSecurity:\s*false" src scripts
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- No `any` in `src` or `scripts`.
|
||||
- No `as unknown` casts remain.
|
||||
- No legacy updater module names remain in `src`.
|
||||
- No Spanish mojibake remains in update dialog source.
|
||||
- No unsafe Electron settings were introduced.
|
||||
|
||||
IPC/preload sanity:
|
||||
|
||||
```text
|
||||
rg -n "ipcMain|ipcRenderer|contextBridge|preload" src scripts
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- Matches are limited to the regression test that guards the no-IPC/no-preload policy.
|
||||
|
||||
## Build Notes
|
||||
|
||||
The first non-escalated `npm.cmd run build` attempt was blocked by sandbox permissions while creating generated parent-repo output at:
|
||||
|
||||
```text
|
||||
C:\Users\pcantos\Documents\scoreko-dev\shared\dist
|
||||
```
|
||||
|
||||
The escalated rerun passed. The build emitted existing dependency/deprecation warnings during runtime dependency installation, but completed successfully and `npm.cmd run doctor` validated the prepared runtime.
|
||||
+3
-2
@@ -74,7 +74,7 @@
|
||||
],
|
||||
"icon": "static/icons/icon.ico",
|
||||
"executableName": "scoreko",
|
||||
"signAndEditExecutable": false
|
||||
"signAndEditExecutable": true
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
@@ -84,7 +84,8 @@
|
||||
"uninstallerIcon": "static/icons/icon.ico",
|
||||
"installerHeaderIcon": "static/icons/icon.ico",
|
||||
"shortcutName": "Scoreko",
|
||||
"useZip": false
|
||||
"useZip": false,
|
||||
"deleteAppDataOnUninstall": true
|
||||
},
|
||||
"compression": "normal"
|
||||
},
|
||||
|
||||
+33
-8
@@ -7,12 +7,31 @@ import { bundleName, nodecgRuntimeRoot } from "./build-config.mjs";
|
||||
|
||||
const checks = [];
|
||||
|
||||
function loadEnv() {
|
||||
if (!fs.existsSync(".env")) {
|
||||
console.error("FAIL Configuración: Archivo .env obligatorio no encontrado.");
|
||||
console.error("Por favor, crea un archivo .env basado en .env.example en la raíz del proyecto.");
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
process.loadEnvFile(".env");
|
||||
console.log("OK Configuración: Archivo .env cargado correctamente.\n");
|
||||
} catch (error) {
|
||||
console.error(`FAIL Configuración: Error al leer el archivo .env: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function addCheck(ok, title, details) {
|
||||
checks.push({ ok, title, details });
|
||||
}
|
||||
|
||||
function parsePort(name, fallback) {
|
||||
const raw = process.env[name] ?? fallback;
|
||||
function parsePort(name) {
|
||||
const raw = process.env[name];
|
||||
if (!raw) {
|
||||
addCheck(false, `${name} missing`, `The required environment variable ${name} is not defined in the .env file.`);
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
||||
addCheck(false, `${name} invalid`, `It must be an integer between 1 and 65535. Received value: '${raw}'.`);
|
||||
@@ -23,8 +42,12 @@ function parsePort(name, fallback) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseIntInRange(name, fallback, min, max) {
|
||||
const raw = process.env[name] ?? String(fallback);
|
||||
function parseIntInRange(name, min, max) {
|
||||
const raw = process.env[name];
|
||||
if (!raw) {
|
||||
addCheck(false, `${name} missing`, `The required environment variable ${name} is not defined in the .env file.`);
|
||||
return;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
||||
addCheck(false, `${name} invalid`, `It must be an integer between ${min} and ${max}. Received value: '${raw}'.`);
|
||||
@@ -73,10 +96,12 @@ function checkPortAvailability(port) {
|
||||
}
|
||||
|
||||
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);
|
||||
loadEnv();
|
||||
|
||||
const port = parsePort("NODECG_PORT");
|
||||
parseIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000);
|
||||
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000);
|
||||
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000);
|
||||
checkNodecgInstall();
|
||||
|
||||
if (port) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { NodecgProcessManager } from "../nodecg/process-manager";
|
||||
import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner";
|
||||
import { getRemainingDelayMs } from "../utils/timing";
|
||||
import { ApplicationPaths } from "./paths";
|
||||
import { createShutdownService, ShutdownService } from "./shutdown-service";
|
||||
|
||||
export type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed";
|
||||
@@ -21,14 +22,7 @@ export type ApplicationControllerConfig = {
|
||||
appVersion: string;
|
||||
isPackaged: boolean;
|
||||
isWindows: boolean;
|
||||
paths: {
|
||||
rootPath: string;
|
||||
sourceNodecgRuntimePath: string;
|
||||
userDataPath: string;
|
||||
nodecgBaseUrl: string;
|
||||
mainDashboardUrl: string;
|
||||
loadingDashboardUrl: string;
|
||||
};
|
||||
paths: ApplicationPaths;
|
||||
deps: {
|
||||
createLoadingWindow: () => ApplicationWindow;
|
||||
createMainWindow: () => ApplicationWindow;
|
||||
@@ -42,7 +36,6 @@ export type ApplicationControllerConfig = {
|
||||
bundleName: string;
|
||||
log: (...args: unknown[]) => void;
|
||||
}) => PreparedNodecgRuntime;
|
||||
relaunch: () => void;
|
||||
scheduleUpdateCheck: (config: {
|
||||
getParentWindow: () => ApplicationWindow | null;
|
||||
beforeInstall: () => Promise<void>;
|
||||
@@ -135,13 +128,6 @@ export function createApplicationController({
|
||||
log: deps.log,
|
||||
});
|
||||
|
||||
if (preparedRuntime.installed && isPackaged) {
|
||||
deps.log("Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.");
|
||||
deps.relaunch();
|
||||
deps.exit(0);
|
||||
state = "stopped";
|
||||
return;
|
||||
}
|
||||
|
||||
nodecgManager = deps.createNodecgProcessManager(preparedRuntime.runtimePath);
|
||||
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import path from "node:path";
|
||||
|
||||
import { getRuntimeConfig } from "../config/runtime-config";
|
||||
import { getRuntimeConfig, loadEnvFile, AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { showFatalError, log } from "../errors/error-presenter";
|
||||
import { createNodecgProcessManager } from "../nodecg/process-manager";
|
||||
import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner";
|
||||
import { scheduleUpdateCheck } from "../updates/update-service";
|
||||
import { createLoadingWindow, createMainWindow } from "../windows/window-service";
|
||||
import { createApplicationController } from "./application-controller";
|
||||
import { getApplicationPaths } from "./paths";
|
||||
import { getApplicationPaths, getRootPath } from "./paths";
|
||||
|
||||
export function bootstrap(): void {
|
||||
const appConfig = getRuntimeConfig();
|
||||
const isDev = !app.isPackaged;
|
||||
const compiledMainDir = path.resolve(__dirname, "..");
|
||||
const resourcesPath = process.resourcesPath;
|
||||
const rootPath = getRootPath(isDev, compiledMainDir, resourcesPath);
|
||||
const envFilePath = path.join(rootPath, ".env");
|
||||
|
||||
let appConfig: AppRuntimeConfig;
|
||||
try {
|
||||
loadEnvFile(envFilePath);
|
||||
appConfig = getRuntimeConfig();
|
||||
} catch (error: unknown) {
|
||||
app.on("ready", () => {
|
||||
showFatalError("No se pudo cargar la configuración de la aplicación.", error);
|
||||
app.exit(1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = getApplicationPaths({
|
||||
appConfig,
|
||||
appDataPath: app.getPath("appData"),
|
||||
compiledMainDir: path.resolve(__dirname, ".."),
|
||||
compiledMainDir,
|
||||
isDev,
|
||||
resourcesPath: process.resourcesPath,
|
||||
resourcesPath,
|
||||
});
|
||||
|
||||
app.setName(appConfig.title);
|
||||
@@ -61,7 +77,6 @@ export function bootstrap(): void {
|
||||
getAllWindows: () => BrowserWindow.getAllWindows(),
|
||||
log,
|
||||
prepareRuntime: prepareUserNodecgRuntime,
|
||||
relaunch: () => app.relaunch(),
|
||||
scheduleUpdateCheck: ({ getParentWindow, beforeInstall }) => {
|
||||
scheduleUpdateCheck({
|
||||
appConfig,
|
||||
|
||||
@@ -27,10 +27,6 @@ export function getSourceNodecgRuntimePath(rootPath: string): string {
|
||||
return path.resolve(rootPath, "lib", "nodecg");
|
||||
}
|
||||
|
||||
export function getDefaultUpdateConfigPath(rootPath: string): string {
|
||||
return path.join(rootPath, "static", "updates.json");
|
||||
}
|
||||
|
||||
export function getUpdateDownloadDirectory(tempDirectory: string): string {
|
||||
return path.join(tempDirectory, "scoreko-updates");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
export type AppRuntimeConfig = {
|
||||
title: string;
|
||||
userModelId: string;
|
||||
@@ -14,36 +16,56 @@ export type AppRuntimeConfig = {
|
||||
updateApiUrl?: string;
|
||||
updateReleasePageUrl?: string;
|
||||
updateAssetPattern?: string;
|
||||
updateConfigPathOverride?: string;
|
||||
updateCheckDelayMs: number;
|
||||
};
|
||||
|
||||
const MIN_TCP_PORT = 1;
|
||||
const MAX_TCP_PORT = 65535;
|
||||
|
||||
export function loadEnvFile(envFilePath: string): void {
|
||||
if (!fs.existsSync(envFilePath)) {
|
||||
throw new Error(
|
||||
`Archivo de configuración obligatorio no encontrado: ${envFilePath}\n\nPor favor, crea un archivo .env basado en .env.example en la raíz de la aplicación.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
process.loadEnvFile(envFilePath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Error al leer el archivo de configuración .env: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getRuntimeConfig(): AppRuntimeConfig {
|
||||
// Centralized defaults keep local development and packaged builds consistent.
|
||||
return {
|
||||
title: getEnv("SCOREKO_APP_TITLE", "Scoreko"),
|
||||
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"),
|
||||
userDataDirectoryName: getEnv("SCOREKO_APP_USER_DATA_DIRECTORY", "scoreko"),
|
||||
title: getRequiredEnv("SCOREKO_APP_TITLE"),
|
||||
userModelId: getRequiredEnv("SCOREKO_APP_USER_MODEL_ID"),
|
||||
userDataDirectoryName: getRequiredEnv("SCOREKO_APP_USER_DATA_DIRECTORY"),
|
||||
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),
|
||||
updatesEnabled: parseEnvBool("SCOREKO_UPDATES_ENABLED", true),
|
||||
nodecgPort: parseRequiredEnvPort("NODECG_PORT"),
|
||||
bundleName: getRequiredEnv("NODECG_BUNDLE_NAME"),
|
||||
mainDashboardRoute: getRequiredEnv("SCOREKO_DASHBOARD_ROUTE"),
|
||||
loadingDashboardRoute: getRequiredEnv("SCOREKO_LOADING_ROUTE"),
|
||||
loadDelayMs: parseRequiredEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000),
|
||||
startupTimeoutMs: parseRequiredEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000),
|
||||
nodecgKillTimeoutMs: parseRequiredEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000),
|
||||
updatesEnabled: parseRequiredEnvBool("SCOREKO_UPDATES_ENABLED"),
|
||||
updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"),
|
||||
updateReleasePageUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_RELEASE_PAGE_URL"),
|
||||
updateAssetPattern: getOptionalEnv("SCOREKO_UPDATE_ASSET_PATTERN"),
|
||||
updateConfigPathOverride: getOptionalEnv("SCOREKO_UPDATE_CONFIG_PATH"),
|
||||
updateCheckDelayMs: parseEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 5000, 0, 600000),
|
||||
updateCheckDelayMs: parseRequiredEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 0, 600000),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRequiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value || value.length === 0) {
|
||||
throw new Error(`La variable de entorno requerida '${name}' no está definida en el archivo .env.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getOptionalEnv(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
return value && value.length > 0 ? value : undefined;
|
||||
@@ -53,18 +75,18 @@ 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;
|
||||
}
|
||||
|
||||
export function parseRequiredEnvIntInRange(name: string, min: number, max: number): number {
|
||||
const rawValue = getRequiredEnv(name);
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
||||
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
|
||||
throw new Error(
|
||||
`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`,
|
||||
);
|
||||
}
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number {
|
||||
// We throw here instead of silently coercing to avoid hidden misconfiguration in production.
|
||||
const rawValue = process.env[name];
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
@@ -80,6 +102,19 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
export function parseRequiredEnvBool(name: string): boolean {
|
||||
const rawValue = getRequiredEnv(name).toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(rawValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (["0", "false", "no", "off"].includes(rawValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(`The ${name} variable must be a boolean. Received value: '${rawValue}'.`);
|
||||
}
|
||||
|
||||
export function parseEnvBool(name: string, fallback: boolean): boolean {
|
||||
const rawValue = process.env[name]?.trim().toLowerCase();
|
||||
if (!rawValue) {
|
||||
@@ -115,6 +150,19 @@ export function parseOptionalHttpUrl(name: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRequiredEnvPort(name: string): string {
|
||||
const rawValue = getRequiredEnv(name);
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
|
||||
if (!Number.isFinite(parsedValue) || parsedValue < MIN_TCP_PORT || parsedValue > MAX_TCP_PORT) {
|
||||
throw new Error(
|
||||
`The ${name} variable must be a valid TCP port (${MIN_TCP_PORT}-${MAX_TCP_PORT}). Received value: '${rawValue}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
return String(parsedValue);
|
||||
}
|
||||
|
||||
export function parseEnvPort(name: string, fallback: string): string {
|
||||
const rawValue = getEnv(name, fallback);
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { ChildProcess, SpawnOptions } from "node:child_process";
|
||||
import { SpawnOptions } from "node:child_process";
|
||||
|
||||
export type PlatformProcessKillerDeps = {
|
||||
platform: NodeJS.Platform;
|
||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => SpawnedKillerProcess;
|
||||
killProcess: (pid: number, signal: NodeJS.Signals) => void;
|
||||
log: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
type SpawnedKillerProcess = {
|
||||
on: (event: "error", listener: (error: Error) => void) => unknown;
|
||||
};
|
||||
|
||||
export function killProcessTree(pid: number, signal: NodeJS.Signals, deps: PlatformProcessKillerDeps): boolean {
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||
deps.log(`Invalid pid for process tree termination: ${pid}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChildProcess, spawn, SpawnOptions } from "node:child_process";
|
||||
import { spawn, SpawnOptions } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
@@ -17,7 +17,7 @@ type NodecgProcessManagerConfig = {
|
||||
};
|
||||
|
||||
type NodecgProcessManagerDeps = {
|
||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => NodecgChildProcess;
|
||||
pathExists: (candidatePath: string) => boolean;
|
||||
fetchUrl: typeof fetch;
|
||||
platform: NodeJS.Platform;
|
||||
@@ -31,6 +31,22 @@ type NodecgProcessManagerDeps = {
|
||||
hasReadWriteAccess: (candidatePath: string) => boolean;
|
||||
};
|
||||
|
||||
type NodecgChildProcess = {
|
||||
pid?: number;
|
||||
killed: boolean;
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
stdout?: ProcessOutputStream | null;
|
||||
stderr?: ProcessOutputStream | null;
|
||||
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
||||
on(event: "error", listener: (error: Error) => void): unknown;
|
||||
once(event: "exit", listener: () => void): unknown;
|
||||
};
|
||||
|
||||
type ProcessOutputStream = {
|
||||
on(event: "data", listener: (chunk: unknown) => void): unknown;
|
||||
};
|
||||
|
||||
export type NodecgProcessManager = {
|
||||
startNodecgProcess: () => Promise<void>;
|
||||
waitForNodecgReady: (startTime: number) => Promise<void>;
|
||||
@@ -50,7 +66,7 @@ export function createNodecgProcessManager({
|
||||
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
||||
const resolvedDeps = resolveDeps(deps);
|
||||
|
||||
let nodecgProcess: ChildProcess | null = null;
|
||||
let nodecgProcess: NodecgChildProcess | null = null;
|
||||
let nodecgState: NodecgProcessState = "idle";
|
||||
let startNodecgPromise: Promise<void> | null = null;
|
||||
let stopNodecgPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import { getDefaultUpdateConfigPath } from "../app/paths";
|
||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { UpdateFileConfig, validateHttpUrl } from "./update-schema";
|
||||
import { readNonEmptyString } from "../utils/unknown-values";
|
||||
import { validateHttpUrl } from "./update-schema";
|
||||
|
||||
const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$";
|
||||
|
||||
@@ -17,71 +15,33 @@ type UpdateConfigOptions = {
|
||||
allowInsecureHttp: boolean;
|
||||
};
|
||||
|
||||
type UpdateRuntimeConfig = Pick<
|
||||
AppRuntimeConfig,
|
||||
"updateApiUrl" | "updateAssetPattern" | "updateReleasePageUrl" | "updatesEnabled"
|
||||
>;
|
||||
|
||||
export function loadUpdateSettings(
|
||||
appConfig: AppRuntimeConfig,
|
||||
appConfig: UpdateRuntimeConfig,
|
||||
rootPath: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
options: UpdateConfigOptions = { allowInsecureHttp: true },
|
||||
): UpdateSettings {
|
||||
const fileConfig = readUpdateFileConfig(appConfig, rootPath, log);
|
||||
const apiUrl = readOptionalHttpUrl(appConfig.updateApiUrl ?? fileConfig.apiUrl, options);
|
||||
const releasePageUrl = readOptionalHttpUrl(appConfig.updateReleasePageUrl ?? fileConfig.releasePageUrl, options);
|
||||
const apiUrl = readOptionalHttpUrl(appConfig.updateApiUrl, options);
|
||||
const releasePageUrl = readOptionalHttpUrl(appConfig.updateReleasePageUrl, options);
|
||||
|
||||
return {
|
||||
enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)) && Boolean(apiUrl),
|
||||
enabled: appConfig.updatesEnabled && Boolean(apiUrl),
|
||||
...(apiUrl ? { apiUrl } : {}),
|
||||
...(releasePageUrl ? { releasePageUrl } : {}),
|
||||
assetPattern:
|
||||
appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN,
|
||||
};
|
||||
}
|
||||
|
||||
export function readUpdateFileConfig(
|
||||
appConfig: AppRuntimeConfig,
|
||||
rootPath: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
): UpdateFileConfig {
|
||||
const configPath = appConfig.updateConfigPathOverride ?? getDefaultUpdateConfigPath(rootPath);
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedConfig: unknown = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
return normalizeUpdateFileConfig(parsedConfig);
|
||||
} catch (error) {
|
||||
log(`Could not read update config at ${configPath}.`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUpdateFileConfig(value: unknown): UpdateFileConfig {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: value.enabled,
|
||||
apiUrl: value.apiUrl,
|
||||
releasePageUrl: value.releasePageUrl,
|
||||
assetPattern: value.assetPattern,
|
||||
assetPattern: appConfig.updateAssetPattern || DEFAULT_UPDATE_ASSET_PATTERN,
|
||||
};
|
||||
}
|
||||
|
||||
function readOptionalHttpUrl(value: unknown, options: UpdateConfigOptions): string | undefined {
|
||||
const rawValue = readOptionalString(value);
|
||||
const rawValue = readNonEmptyString(value);
|
||||
if (!rawValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return validateHttpUrl(rawValue, options) ?? undefined;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BrowserWindow, dialog } from "electron";
|
||||
import type { MessageBoxOptions } from "electron";
|
||||
import type { MessageBoxOptions, MessageBoxReturnValue } from "electron";
|
||||
|
||||
import { ReleaseUpdate } from "./update-schema";
|
||||
|
||||
@@ -41,6 +41,25 @@ export async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: Br
|
||||
return result.response === 0;
|
||||
}
|
||||
|
||||
function showMessageBox(parentWindow: BrowserWindow | null, options: MessageBoxOptions) {
|
||||
export async function showDownloadFailedDialog(
|
||||
update: ReleaseUpdate,
|
||||
error: unknown,
|
||||
parentWindow: BrowserWindow | null,
|
||||
): Promise<void> {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await showMessageBox(parentWindow, {
|
||||
type: "error",
|
||||
title: "Error de descarga",
|
||||
message: `No se pudo descargar la actualización para Scoreko ${update.version}.`,
|
||||
detail: `Detalles del error: ${errorMessage}\n\nPor favor, comprueba tu conexión a internet e inténtalo de nuevo.`,
|
||||
buttons: ["Aceptar"],
|
||||
defaultId: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function showMessageBox(
|
||||
parentWindow: BrowserWindow | null,
|
||||
options: MessageBoxOptions,
|
||||
): Promise<MessageBoxReturnValue> {
|
||||
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ export async function downloadInstaller(update: ReleaseUpdate, config: UpdateDow
|
||||
const targetPath = getSafeChildPath(downloadDirectory, safeFileName);
|
||||
const stagingPath = getSafeChildPath(downloadDirectory, `${safeFileName}.${process.pid}.${Date.now()}.download`);
|
||||
|
||||
if (fs.existsSync(targetPath)) {
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (typeof update.installer.size === "number" && stats.size === update.installer.size) {
|
||||
return targetPath;
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(downloadDirectory, { recursive: true });
|
||||
fs.rmSync(stagingPath, { force: true });
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isRecord, readNonEmptyString } from "../utils/unknown-values";
|
||||
|
||||
export type GiteaReleaseAsset = {
|
||||
name: string;
|
||||
browserDownloadUrl: string;
|
||||
@@ -24,13 +26,6 @@ export type ReleaseUpdate = {
|
||||
installer: InstallerAsset;
|
||||
};
|
||||
|
||||
export type UpdateFileConfig = {
|
||||
enabled?: unknown;
|
||||
apiUrl?: unknown;
|
||||
releasePageUrl?: unknown;
|
||||
assetPattern?: unknown;
|
||||
};
|
||||
|
||||
type UrlPolicy = {
|
||||
allowInsecureHttp: boolean;
|
||||
};
|
||||
@@ -47,11 +42,14 @@ export function parseGiteaRelease(value: unknown): GiteaRelease | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = readNonEmptyString(value.name);
|
||||
const pageUrl = readOptionalUrlString(value.html_url);
|
||||
|
||||
return {
|
||||
tagName,
|
||||
assets,
|
||||
...(readOptionalString(value.name) ? { title: readOptionalString(value.name) } : {}),
|
||||
...(readOptionalUrlString(value.html_url) ? { pageUrl: readOptionalUrlString(value.html_url) } : {}),
|
||||
...(title ? { title } : {}),
|
||||
...(pageUrl ? { pageUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,16 +175,12 @@ function normalizeVersion(version: string): number[] {
|
||||
}
|
||||
|
||||
function readRequiredString(value: unknown): string | null {
|
||||
const text = readOptionalString(value);
|
||||
const text = readNonEmptyString(value);
|
||||
return text && text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function readOptionalUrlString(value: unknown): string | undefined {
|
||||
const rawValue = readOptionalString(value);
|
||||
const rawValue = readNonEmptyString(value);
|
||||
if (!rawValue) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -194,10 +188,6 @@ function readOptionalUrlString(value: unknown): string | undefined {
|
||||
return validateHttpUrl(rawValue, { allowInsecureHttp: true }) ?? undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isPresent<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app, BrowserWindow, shell } from "electron";
|
||||
|
||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { askToDownloadUpdate, askToInstallUpdate } from "./update-dialogs";
|
||||
import { askToDownloadUpdate, askToInstallUpdate, showDownloadFailedDialog } from "./update-dialogs";
|
||||
import { loadUpdateSettings, UpdateSettings } from "./update-config";
|
||||
import { downloadInstaller } from "./update-download";
|
||||
import { buildReleaseUpdate, GiteaRelease, parseGiteaRelease } from "./update-schema";
|
||||
@@ -76,10 +76,17 @@ async function checkForUpdates({
|
||||
return;
|
||||
}
|
||||
|
||||
const installerPath = await downloadInstaller(update, {
|
||||
let installerPath: string;
|
||||
try {
|
||||
installerPath = await downloadInstaller(update, {
|
||||
tempDirectory: app.getPath("temp"),
|
||||
allowInsecureHttp: protocolPolicy.allowInsecureHttp,
|
||||
});
|
||||
} catch (error) {
|
||||
log("Update installer download failed.", error);
|
||||
await showDownloadFailedDialog(update, error, getParentWindow());
|
||||
return;
|
||||
}
|
||||
const shouldInstall = await askToInstallUpdate(update, getParentWindow());
|
||||
if (!shouldInstall) {
|
||||
await shell.showItemInFolder(installerPath);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function readNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import test from "node:test";
|
||||
import {
|
||||
getApplicationPaths,
|
||||
getDashboardUrl,
|
||||
getDefaultUpdateConfigPath,
|
||||
getManagedNodecgRuntimePath,
|
||||
getNodecgBaseUrl,
|
||||
getRootPath,
|
||||
@@ -23,7 +22,6 @@ test("app path helpers build deterministic development paths and URLs", () => {
|
||||
assert.equal(getSourceNodecgRuntimePath(rootPath), path.resolve(rootPath, "lib", "nodecg"));
|
||||
assert.equal(getUserDataPath("/app-data", "scoreko"), path.join("/app-data", "scoreko"));
|
||||
assert.equal(getManagedNodecgRuntimePath("/app-data/scoreko"), path.join("/app-data/scoreko", "nodecg"));
|
||||
assert.equal(getDefaultUpdateConfigPath(rootPath), path.join(rootPath, "static", "updates.json"));
|
||||
assert.equal(getUpdateDownloadDirectory("/tmp"), path.join("/tmp", "scoreko-updates"));
|
||||
assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090");
|
||||
assert.equal(
|
||||
|
||||
@@ -114,7 +114,6 @@ test("ApplicationController preserves startup ordering and schedules updates aft
|
||||
events.push("prepare-runtime");
|
||||
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
|
||||
},
|
||||
relaunch: () => events.push("relaunch"),
|
||||
scheduleUpdateCheck: () => events.push("schedule-update"),
|
||||
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
||||
exit: (code) => events.push(`exit:${code}`),
|
||||
@@ -145,7 +144,7 @@ test("ApplicationController preserves startup ordering and schedules updates aft
|
||||
]);
|
||||
});
|
||||
|
||||
test("ApplicationController relaunches packaged app after runtime install before starting NodeCG", async () => {
|
||||
test("ApplicationController directly launches packaged app after runtime install without relaunching", async () => {
|
||||
const events: string[] = [];
|
||||
const controller = createApplicationController({
|
||||
appConfig: getBaseConfig(),
|
||||
@@ -162,31 +161,45 @@ test("ApplicationController relaunches packaged app after runtime install before
|
||||
},
|
||||
deps: {
|
||||
createLoadingWindow: () => {
|
||||
throw new Error("window creation should wait until after relaunch decisions");
|
||||
events.push("create-loading");
|
||||
return new MockWindow("loading", events);
|
||||
},
|
||||
createMainWindow: () => {
|
||||
throw new Error("window creation should wait until after relaunch decisions");
|
||||
events.push("create-main");
|
||||
return new MockWindow("main", events);
|
||||
},
|
||||
createNodecgProcessManager: () => {
|
||||
throw new Error("NodeCG should not start before relaunch");
|
||||
events.push("create-manager");
|
||||
return createMockManager(events);
|
||||
},
|
||||
getAllWindows: () => [],
|
||||
log: (...args) => events.push(String(args[0])),
|
||||
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }),
|
||||
relaunch: () => events.push("relaunch"),
|
||||
scheduleUpdateCheck: () => events.push("schedule-update"),
|
||||
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
||||
exit: (code) => events.push(`exit:${code}`),
|
||||
now: () => 0,
|
||||
sleep: async (ms) => {
|
||||
events.push(`sleep:${ms}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.launch();
|
||||
|
||||
assert.equal(controller.getState(), "stopped");
|
||||
assert.equal(controller.getState(), "ready");
|
||||
assert.deepEqual(events, [
|
||||
"Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.",
|
||||
"relaunch",
|
||||
"exit:0",
|
||||
"create-manager",
|
||||
"create-main",
|
||||
"create-loading",
|
||||
"start-nodecg",
|
||||
"wait-nodecg",
|
||||
"loading:load:http://localhost:9090/loading",
|
||||
"loading:show",
|
||||
"main:load:http://localhost:9090/main",
|
||||
"main:show",
|
||||
"loading:close",
|
||||
"schedule-update",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -215,7 +228,6 @@ test("ApplicationController activation before readiness routes through launch",
|
||||
events.push("prepare-runtime");
|
||||
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
|
||||
},
|
||||
relaunch: () => events.push("relaunch"),
|
||||
scheduleUpdateCheck: () => events.push("schedule-update"),
|
||||
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
||||
exit: (code) => events.push(`exit:${code}`),
|
||||
@@ -253,7 +265,6 @@ test("ApplicationController shutdown is idempotent", async () => {
|
||||
getAllWindows: () => [],
|
||||
log: () => undefined,
|
||||
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: false }),
|
||||
relaunch: () => events.push("relaunch"),
|
||||
scheduleUpdateCheck: () => events.push("schedule-update"),
|
||||
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
||||
exit: (code) => events.push(`exit:${code}`),
|
||||
|
||||
@@ -85,7 +85,7 @@ test("waitForNodeCGReady resolves when endpoint returns 404", async () => {
|
||||
deps: {
|
||||
platform: "linux",
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
||||
handler();
|
||||
@@ -118,7 +118,7 @@ test("stopNodeCG sends SIGTERM and then SIGKILL if the process does not exit", a
|
||||
deps: {
|
||||
platform: "linux",
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: (pid, signal) => {
|
||||
killSignals.push({ pid, signal });
|
||||
@@ -163,7 +163,7 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
|
||||
log: () => undefined,
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: () => undefined,
|
||||
setTimer: () => 0,
|
||||
@@ -206,7 +206,7 @@ test("startNodeCG reuses the same promise while startup is in progress", async (
|
||||
}),
|
||||
spawnProcess: () => {
|
||||
spawnCalls += 1;
|
||||
return child as unknown as import("node:child_process").ChildProcess;
|
||||
return child;
|
||||
},
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
@@ -241,7 +241,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
|
||||
log: () => undefined,
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: () => undefined,
|
||||
setTimer: (handler, timeoutMs) => {
|
||||
@@ -306,7 +306,7 @@ test("startNodeCG spawns Electron directly on Windows", async () => {
|
||||
capturedCommand = command;
|
||||
capturedArgs = args;
|
||||
capturedOptions.push(options);
|
||||
return child as unknown as import("node:child_process").ChildProcess;
|
||||
return child;
|
||||
},
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
@@ -333,7 +333,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
platform: "linux",
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => {
|
||||
child.emit("exit", 1, null);
|
||||
throw new Error("still starting");
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
getEnv,
|
||||
getOptionalEnv,
|
||||
parseEnvBool,
|
||||
parseEnvInt,
|
||||
parseEnvIntInRange,
|
||||
parseEnvPort,
|
||||
parseOptionalHttpUrl,
|
||||
loadEnvFile,
|
||||
getRuntimeConfig,
|
||||
getRequiredEnv,
|
||||
parseRequiredEnvIntInRange,
|
||||
parseRequiredEnvBool,
|
||||
parseRequiredEnvPort,
|
||||
} from "../main/config/runtime-config";
|
||||
|
||||
function withEnv(name: string, value: string | undefined, run: () => void): void {
|
||||
@@ -32,6 +38,30 @@ function withEnv(name: string, value: string | undefined, run: () => void): void
|
||||
}
|
||||
}
|
||||
|
||||
function withEnvs(envs: Record<string, string | undefined>, run: () => void): void {
|
||||
const previousValues: Record<string, string | undefined> = {};
|
||||
for (const name of Object.keys(envs)) {
|
||||
previousValues[name] = process.env[name];
|
||||
if (envs[name] === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = envs[name];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
for (const name of Object.keys(envs)) {
|
||||
if (previousValues[name] === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = previousValues[name];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("getOptionalEnv returns undefined for missing variable", () => {
|
||||
withEnv("TEST_OPTIONAL_ENV", undefined, () => {
|
||||
assert.equal(getOptionalEnv("TEST_OPTIONAL_ENV"), undefined);
|
||||
@@ -56,18 +86,6 @@ test("getEnv returns the value when present", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parseEnvInt returns fallback for invalid values", () => {
|
||||
withEnv("TEST_ENV_INT", "abc", () => {
|
||||
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 100);
|
||||
});
|
||||
});
|
||||
|
||||
test("parseEnvInt parses valid integers", () => {
|
||||
withEnv("TEST_ENV_INT", "4500", () => {
|
||||
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 4500);
|
||||
});
|
||||
});
|
||||
|
||||
test("parseEnvIntInRange hard-fails for out-of-range values", () => {
|
||||
withEnv("TEST_ENV_INT_RANGE", "999", () => {
|
||||
assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /must be an integer/);
|
||||
@@ -119,3 +137,126 @@ test("parseOptionalHttpUrl rejects unsupported protocols", () => {
|
||||
assert.throws(() => parseOptionalHttpUrl("TEST_UPDATE_URL"), /valid HTTP\(S\) URL/);
|
||||
});
|
||||
});
|
||||
|
||||
test("loadEnvFile throws on non-existent file", () => {
|
||||
const missingPath = path.join(__dirname, "does-not-exist-.env");
|
||||
assert.throws(() => loadEnvFile(missingPath), /Archivo de configuración obligatorio no encontrado/);
|
||||
});
|
||||
|
||||
test("getRequiredEnv throws on missing or empty variable", () => {
|
||||
withEnv("TEST_REQUIRED_ENV", undefined, () => {
|
||||
assert.throws(() => getRequiredEnv("TEST_REQUIRED_ENV"), /no está definida/);
|
||||
});
|
||||
|
||||
withEnv("TEST_REQUIRED_ENV", " ", () => {
|
||||
assert.throws(() => getRequiredEnv("TEST_REQUIRED_ENV"), /no está definida/);
|
||||
});
|
||||
});
|
||||
|
||||
test("getRequiredEnv returns trimmed value when present", () => {
|
||||
withEnv("TEST_REQUIRED_ENV", " scoreko-app ", () => {
|
||||
assert.equal(getRequiredEnv("TEST_REQUIRED_ENV"), "scoreko-app");
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRequiredEnvIntInRange validates required integer and throws if missing", () => {
|
||||
withEnv("TEST_REQ_INT", undefined, () => {
|
||||
assert.throws(() => parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), /no está definida/);
|
||||
});
|
||||
|
||||
withEnv("TEST_REQ_INT", "150", () => {
|
||||
assert.throws(() => parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), /must be an integer/);
|
||||
});
|
||||
|
||||
withEnv("TEST_REQ_INT", "42", () => {
|
||||
assert.equal(parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), 42);
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRequiredEnvBool validates required boolean and throws if missing", () => {
|
||||
withEnv("TEST_REQ_BOOL", undefined, () => {
|
||||
assert.throws(() => parseRequiredEnvBool("TEST_REQ_BOOL"), /no está definida/);
|
||||
});
|
||||
|
||||
withEnv("TEST_REQ_BOOL", "maybe", () => {
|
||||
assert.throws(() => parseRequiredEnvBool("TEST_REQ_BOOL"), /must be a boolean/);
|
||||
});
|
||||
|
||||
withEnv("TEST_REQ_BOOL", "true", () => {
|
||||
assert.equal(parseRequiredEnvBool("TEST_REQ_BOOL"), true);
|
||||
});
|
||||
|
||||
withEnv("TEST_REQ_BOOL", "off", () => {
|
||||
assert.equal(parseRequiredEnvBool("TEST_REQ_BOOL"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRequiredEnvPort validates required port and throws if missing", () => {
|
||||
withEnv("TEST_REQ_PORT", undefined, () => {
|
||||
assert.throws(() => parseRequiredEnvPort("TEST_REQ_PORT"), /no está definida/);
|
||||
});
|
||||
|
||||
withEnv("TEST_REQ_PORT", "70000", () => {
|
||||
assert.throws(() => parseRequiredEnvPort("TEST_REQ_PORT"), /valid TCP port/);
|
||||
});
|
||||
|
||||
withEnv("TEST_REQ_PORT", "9090", () => {
|
||||
assert.equal(parseRequiredEnvPort("TEST_REQ_PORT"), "9090");
|
||||
});
|
||||
});
|
||||
|
||||
test("getRuntimeConfig throws if required variables are missing", () => {
|
||||
withEnvs(
|
||||
{
|
||||
SCOREKO_APP_TITLE: undefined,
|
||||
SCOREKO_APP_USER_MODEL_ID: "com.scoreko.desktop",
|
||||
SCOREKO_APP_USER_DATA_DIRECTORY: "scoreko",
|
||||
NODECG_PORT: "9090",
|
||||
NODECG_BUNDLE_NAME: "scoreko-dev",
|
||||
SCOREKO_DASHBOARD_ROUTE: "dashboard/scoreko-dev/main.html?standalone=true",
|
||||
SCOREKO_LOADING_ROUTE: "dashboard/loading/main.html?standalone=true",
|
||||
ELECTRON_LOAD_DELAY_MS: "10000",
|
||||
NODECG_STARTUP_TIMEOUT_MS: "120000",
|
||||
NODECG_KILL_TIMEOUT_MS: "2500",
|
||||
SCOREKO_UPDATES_ENABLED: "true",
|
||||
SCOREKO_UPDATE_CHECK_DELAY_MS: "5000",
|
||||
},
|
||||
() => {
|
||||
assert.throws(() => getRuntimeConfig(), /SCOREKO_APP_TITLE/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("getRuntimeConfig parses successfully when all required variables are set", () => {
|
||||
withEnvs(
|
||||
{
|
||||
SCOREKO_APP_TITLE: "Scoreko Test App",
|
||||
SCOREKO_APP_USER_MODEL_ID: "com.scoreko.test",
|
||||
SCOREKO_APP_USER_DATA_DIRECTORY: "scoreko-test",
|
||||
NODECG_PORT: "9191",
|
||||
NODECG_BUNDLE_NAME: "scoreko-dev-test",
|
||||
SCOREKO_DASHBOARD_ROUTE: "dashboard/scoreko-dev/test.html",
|
||||
SCOREKO_LOADING_ROUTE: "dashboard/loading/test.html",
|
||||
ELECTRON_LOAD_DELAY_MS: "5000",
|
||||
NODECG_STARTUP_TIMEOUT_MS: "60000",
|
||||
NODECG_KILL_TIMEOUT_MS: "1500",
|
||||
SCOREKO_UPDATES_ENABLED: "false",
|
||||
SCOREKO_UPDATE_CHECK_DELAY_MS: "3000",
|
||||
},
|
||||
() => {
|
||||
const config = getRuntimeConfig();
|
||||
assert.equal(config.title, "Scoreko Test App");
|
||||
assert.equal(config.userModelId, "com.scoreko.test");
|
||||
assert.equal(config.userDataDirectoryName, "scoreko-test");
|
||||
assert.equal(config.nodecgPort, "9191");
|
||||
assert.equal(config.bundleName, "scoreko-dev-test");
|
||||
assert.equal(config.mainDashboardRoute, "dashboard/scoreko-dev/test.html");
|
||||
assert.equal(config.loadingDashboardRoute, "dashboard/loading/test.html");
|
||||
assert.equal(config.loadDelayMs, 5000);
|
||||
assert.equal(config.startupTimeoutMs, 60000);
|
||||
assert.equal(config.nodecgKillTimeoutMs, 1500);
|
||||
assert.equal(config.updatesEnabled, false);
|
||||
assert.equal(config.updateCheckDelayMs, 3000);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { AppRuntimeConfig } from "../main/config/runtime-config";
|
||||
import { loadUpdateSettings } from "../main/updates/update-config";
|
||||
|
||||
const baseConfig: AppRuntimeConfig = {
|
||||
title: "Scoreko",
|
||||
userModelId: "com.scoreko.desktop",
|
||||
userDataDirectoryName: "scoreko",
|
||||
nodecgPort: "9090",
|
||||
bundleName: "scoreko-dev",
|
||||
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
|
||||
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
|
||||
loadDelayMs: 0,
|
||||
startupTimeoutMs: 30000,
|
||||
nodecgKillTimeoutMs: 2500,
|
||||
updatesEnabled: true,
|
||||
updateCheckDelayMs: 5000,
|
||||
};
|
||||
|
||||
test("loadUpdateSettings keeps updates disabled when the runtime config disables them", () => {
|
||||
const settings = loadUpdateSettings(
|
||||
{
|
||||
...baseConfig,
|
||||
updatesEnabled: false,
|
||||
updateApiUrl: "https://gitea.local/releases/latest",
|
||||
},
|
||||
"",
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
assert.equal(settings.enabled, false);
|
||||
assert.equal(settings.apiUrl, "https://gitea.local/releases/latest");
|
||||
});
|
||||
|
||||
test("loadUpdateSettings fails closed on insecure production update URLs", () => {
|
||||
const settings = loadUpdateSettings(
|
||||
{
|
||||
...baseConfig,
|
||||
updateApiUrl: "http://gitea.local/releases/latest",
|
||||
},
|
||||
"",
|
||||
() => undefined,
|
||||
{ allowInsecureHttp: false },
|
||||
);
|
||||
|
||||
assert.equal(settings.enabled, false);
|
||||
assert.equal(settings.apiUrl, undefined);
|
||||
});
|
||||
|
||||
test("loadUpdateSettings lets runtime config specify settings", () => {
|
||||
const settings = loadUpdateSettings(
|
||||
{
|
||||
...baseConfig,
|
||||
updateApiUrl: "https://env.local/releases/latest",
|
||||
updateReleasePageUrl: "https://env.local/releases",
|
||||
updateAssetPattern: "Env-.*\\.exe$",
|
||||
},
|
||||
"",
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
assert.deepEqual(settings, {
|
||||
enabled: true,
|
||||
apiUrl: "https://env.local/releases/latest",
|
||||
releasePageUrl: "https://env.local/releases",
|
||||
assetPattern: "Env-.*\\.exe$",
|
||||
});
|
||||
});
|
||||
@@ -56,3 +56,38 @@ test("downloadInstaller rejects insecure production download URLs", async () =>
|
||||
/unsupported protocol/,
|
||||
);
|
||||
});
|
||||
|
||||
test("downloadInstaller reuses existing file if size matches and does not download again", async () => {
|
||||
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-download-"));
|
||||
const downloadDirectory = path.join(tempDirectory, "scoreko-updates");
|
||||
fs.mkdirSync(downloadDirectory, { recursive: true });
|
||||
|
||||
const installerPath = path.join(downloadDirectory, "Scoreko_setup_0.2.0.exe");
|
||||
fs.writeFileSync(installerPath, "cached-installer-bytes");
|
||||
const cachedSize = fs.statSync(installerPath).size;
|
||||
|
||||
const previousFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("Should not fetch when using cached file!");
|
||||
};
|
||||
|
||||
try {
|
||||
const resultPath = await downloadInstaller(
|
||||
{
|
||||
version: "0.2.0",
|
||||
title: "Scoreko 0.2.0",
|
||||
installer: {
|
||||
name: "Scoreko/setup:0.2.0.exe",
|
||||
downloadUrl: "https://updates.local/Scoreko-setup-0.2.0.exe",
|
||||
size: cachedSize,
|
||||
},
|
||||
},
|
||||
{ tempDirectory, allowInsecureHttp: false },
|
||||
);
|
||||
|
||||
assert.equal(resultPath, installerPath);
|
||||
assert.equal(fs.readFileSync(resultPath, "utf8"), "cached-installer-bytes");
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { AppRuntimeConfig } from "../main/config/runtime-config";
|
||||
import { loadUpdateSettings, readUpdateFileConfig } from "../main/updates/update-config";
|
||||
|
||||
const baseConfig: AppRuntimeConfig = {
|
||||
title: "Scoreko",
|
||||
userModelId: "com.scoreko.desktop",
|
||||
userDataDirectoryName: "scoreko",
|
||||
nodecgPort: "9090",
|
||||
bundleName: "scoreko-dev",
|
||||
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
|
||||
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
|
||||
loadDelayMs: 0,
|
||||
startupTimeoutMs: 30000,
|
||||
nodecgKillTimeoutMs: 2500,
|
||||
updatesEnabled: true,
|
||||
updateCheckDelayMs: 5000,
|
||||
};
|
||||
|
||||
test("loadUpdateSettings keeps updates disabled when the runtime config disables them", () => {
|
||||
const rootPath = makeTempRoot({
|
||||
enabled: true,
|
||||
apiUrl: "https://gitea.local/releases/latest",
|
||||
});
|
||||
|
||||
const settings = loadUpdateSettings({ ...baseConfig, updatesEnabled: false }, rootPath, () => undefined);
|
||||
|
||||
assert.equal(settings.enabled, false);
|
||||
assert.equal(settings.apiUrl, "https://gitea.local/releases/latest");
|
||||
});
|
||||
|
||||
test("loadUpdateSettings fails closed on insecure production update URLs", () => {
|
||||
const rootPath = makeTempRoot({
|
||||
enabled: true,
|
||||
apiUrl: "http://gitea.local/releases/latest",
|
||||
});
|
||||
|
||||
const settings = loadUpdateSettings(baseConfig, rootPath, () => undefined, { allowInsecureHttp: false });
|
||||
|
||||
assert.equal(settings.enabled, false);
|
||||
assert.equal(settings.apiUrl, undefined);
|
||||
});
|
||||
|
||||
test("loadUpdateSettings lets runtime config override file settings", () => {
|
||||
const rootPath = makeTempRoot({
|
||||
enabled: true,
|
||||
apiUrl: "https://file.local/releases/latest",
|
||||
releasePageUrl: "https://file.local/releases",
|
||||
assetPattern: "File-.*\\.exe$",
|
||||
});
|
||||
|
||||
const settings = loadUpdateSettings(
|
||||
{
|
||||
...baseConfig,
|
||||
updateApiUrl: "https://env.local/releases/latest",
|
||||
updateReleasePageUrl: "https://env.local/releases",
|
||||
updateAssetPattern: "Env-.*\\.exe$",
|
||||
},
|
||||
rootPath,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
assert.deepEqual(settings, {
|
||||
enabled: true,
|
||||
apiUrl: "https://env.local/releases/latest",
|
||||
releasePageUrl: "https://env.local/releases",
|
||||
assetPattern: "Env-.*\\.exe$",
|
||||
});
|
||||
});
|
||||
|
||||
test("readUpdateFileConfig normalizes malformed config into an empty file config", () => {
|
||||
const rootPath = makeTempRoot(["not", "an", "object"]);
|
||||
|
||||
assert.deepEqual(readUpdateFileConfig(baseConfig, rootPath, () => undefined), {});
|
||||
});
|
||||
|
||||
test("readUpdateFileConfig logs invalid JSON and returns an empty file config", () => {
|
||||
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-settings-"));
|
||||
const staticPath = path.join(rootPath, "static");
|
||||
fs.mkdirSync(staticPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(staticPath, "updates.json"), "{ invalid", "utf8");
|
||||
const messages: unknown[][] = [];
|
||||
|
||||
const settings = readUpdateFileConfig(baseConfig, rootPath, (...args: unknown[]) => {
|
||||
messages.push(args);
|
||||
});
|
||||
|
||||
assert.deepEqual(settings, {});
|
||||
assert.equal(messages.length, 1);
|
||||
});
|
||||
|
||||
function makeTempRoot(config: unknown): string {
|
||||
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-settings-"));
|
||||
const staticPath = path.join(rootPath, "static");
|
||||
|
||||
fs.mkdirSync(staticPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(staticPath, "updates.json"), JSON.stringify(config), "utf8");
|
||||
|
||||
return rootPath;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"apiUrl": "http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest",
|
||||
"releasePageUrl": "http://gitea.local/OWNER/REPO/releases",
|
||||
"assetPattern": "Scoreko-setup-.*\\.exe$"
|
||||
}
|
||||
Reference in New Issue
Block a user