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_TITLE=Scoreko
|
||||||
SCOREKO_APP_USER_MODEL_ID=com.scoreko.desktop
|
SCOREKO_APP_USER_MODEL_ID=com.scoreko.desktop
|
||||||
SCOREKO_APP_USER_DATA_DIRECTORY=scoreko
|
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_BUNDLE_NAME=scoreko-dev
|
||||||
NODECG_PORT=9090
|
NODECG_PORT=9090
|
||||||
SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true
|
SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true
|
||||||
SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true
|
SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true
|
||||||
|
|
||||||
# Timing
|
# Timing & Lifecycles (Required)
|
||||||
ELECTRON_LOAD_DELAY_MS=10000
|
ELECTRON_LOAD_DELAY_MS=10000
|
||||||
NODECG_STARTUP_TIMEOUT_MS=30000
|
NODECG_STARTUP_TIMEOUT_MS=120000
|
||||||
NODECG_KILL_TIMEOUT_MS=2500
|
NODECG_KILL_TIMEOUT_MS=2500
|
||||||
|
|
||||||
# Updates
|
# Automated Updates Configuration (Required)
|
||||||
SCOREKO_UPDATES_ENABLED=true
|
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_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
|
.localappdata
|
||||||
.npm-cache
|
.npm-cache
|
||||||
.npm-runtime-cache
|
.npm-runtime-cache
|
||||||
|
.env
|
||||||
@@ -5,11 +5,10 @@
|
|||||||
1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`.
|
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`).
|
2. Installs or refreshes the packaged NodeCG runtime in user data when needed (`nodecg/runtime-provisioner.ts`).
|
||||||
3. Creates windows (`windows/window-factory.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.
|
4. Starts NodeCG with `nodecg/process-manager.ts`.
|
||||||
5. Starts NodeCG with `nodecg/process-manager.ts`.
|
5. Waits for HTTP readiness and shows loading -> main dashboard.
|
||||||
6. Waits for HTTP readiness and shows loading -> main dashboard.
|
6. Checks the configured Gitea latest-release endpoint for optional updates.
|
||||||
7. Checks the configured Gitea latest-release endpoint for optional updates.
|
7. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
|
||||||
8. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
|
|
||||||
|
|
||||||
## Main modules
|
## 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",
|
"icon": "static/icons/icon.ico",
|
||||||
"executableName": "scoreko",
|
"executableName": "scoreko",
|
||||||
"signAndEditExecutable": false
|
"signAndEditExecutable": true
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
@@ -84,7 +84,8 @@
|
|||||||
"uninstallerIcon": "static/icons/icon.ico",
|
"uninstallerIcon": "static/icons/icon.ico",
|
||||||
"installerHeaderIcon": "static/icons/icon.ico",
|
"installerHeaderIcon": "static/icons/icon.ico",
|
||||||
"shortcutName": "Scoreko",
|
"shortcutName": "Scoreko",
|
||||||
"useZip": false
|
"useZip": false,
|
||||||
|
"deleteAppDataOnUninstall": true
|
||||||
},
|
},
|
||||||
"compression": "normal"
|
"compression": "normal"
|
||||||
},
|
},
|
||||||
|
|||||||
+33
-8
@@ -7,12 +7,31 @@ import { bundleName, nodecgRuntimeRoot } from "./build-config.mjs";
|
|||||||
|
|
||||||
const checks = [];
|
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) {
|
function addCheck(ok, title, details) {
|
||||||
checks.push({ ok, title, details });
|
checks.push({ ok, title, details });
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePort(name, fallback) {
|
function parsePort(name) {
|
||||||
const raw = process.env[name] ?? fallback;
|
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);
|
const parsed = Number.parseInt(raw, 10);
|
||||||
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
||||||
addCheck(false, `${name} invalid`, `It must be an integer between 1 and 65535. Received value: '${raw}'.`);
|
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;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseIntInRange(name, fallback, min, max) {
|
function parseIntInRange(name, min, max) {
|
||||||
const raw = process.env[name] ?? String(fallback);
|
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);
|
const parsed = Number.parseInt(raw, 10);
|
||||||
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
||||||
addCheck(false, `${name} invalid`, `It must be an integer between ${min} and ${max}. Received value: '${raw}'.`);
|
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() {
|
async function main() {
|
||||||
const port = parsePort("NODECG_PORT", "9090");
|
loadEnv();
|
||||||
parseIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000);
|
|
||||||
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000);
|
const port = parsePort("NODECG_PORT");
|
||||||
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000);
|
parseIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000);
|
||||||
|
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000);
|
||||||
|
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000);
|
||||||
checkNodecgInstall();
|
checkNodecgInstall();
|
||||||
|
|
||||||
if (port) {
|
if (port) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { AppRuntimeConfig } from "../config/runtime-config";
|
|||||||
import { NodecgProcessManager } from "../nodecg/process-manager";
|
import { NodecgProcessManager } from "../nodecg/process-manager";
|
||||||
import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner";
|
import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner";
|
||||||
import { getRemainingDelayMs } from "../utils/timing";
|
import { getRemainingDelayMs } from "../utils/timing";
|
||||||
|
import { ApplicationPaths } from "./paths";
|
||||||
import { createShutdownService, ShutdownService } from "./shutdown-service";
|
import { createShutdownService, ShutdownService } from "./shutdown-service";
|
||||||
|
|
||||||
export type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed";
|
export type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed";
|
||||||
@@ -21,14 +22,7 @@ export type ApplicationControllerConfig = {
|
|||||||
appVersion: string;
|
appVersion: string;
|
||||||
isPackaged: boolean;
|
isPackaged: boolean;
|
||||||
isWindows: boolean;
|
isWindows: boolean;
|
||||||
paths: {
|
paths: ApplicationPaths;
|
||||||
rootPath: string;
|
|
||||||
sourceNodecgRuntimePath: string;
|
|
||||||
userDataPath: string;
|
|
||||||
nodecgBaseUrl: string;
|
|
||||||
mainDashboardUrl: string;
|
|
||||||
loadingDashboardUrl: string;
|
|
||||||
};
|
|
||||||
deps: {
|
deps: {
|
||||||
createLoadingWindow: () => ApplicationWindow;
|
createLoadingWindow: () => ApplicationWindow;
|
||||||
createMainWindow: () => ApplicationWindow;
|
createMainWindow: () => ApplicationWindow;
|
||||||
@@ -42,7 +36,6 @@ export type ApplicationControllerConfig = {
|
|||||||
bundleName: string;
|
bundleName: string;
|
||||||
log: (...args: unknown[]) => void;
|
log: (...args: unknown[]) => void;
|
||||||
}) => PreparedNodecgRuntime;
|
}) => PreparedNodecgRuntime;
|
||||||
relaunch: () => void;
|
|
||||||
scheduleUpdateCheck: (config: {
|
scheduleUpdateCheck: (config: {
|
||||||
getParentWindow: () => ApplicationWindow | null;
|
getParentWindow: () => ApplicationWindow | null;
|
||||||
beforeInstall: () => Promise<void>;
|
beforeInstall: () => Promise<void>;
|
||||||
@@ -135,13 +128,6 @@ export function createApplicationController({
|
|||||||
log: deps.log,
|
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);
|
nodecgManager = deps.createNodecgProcessManager(preparedRuntime.runtimePath);
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
import { app, BrowserWindow } from "electron";
|
import { app, BrowserWindow } from "electron";
|
||||||
import path from "node:path";
|
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 { showFatalError, log } from "../errors/error-presenter";
|
||||||
import { createNodecgProcessManager } from "../nodecg/process-manager";
|
import { createNodecgProcessManager } from "../nodecg/process-manager";
|
||||||
import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner";
|
import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner";
|
||||||
import { scheduleUpdateCheck } from "../updates/update-service";
|
import { scheduleUpdateCheck } from "../updates/update-service";
|
||||||
import { createLoadingWindow, createMainWindow } from "../windows/window-service";
|
import { createLoadingWindow, createMainWindow } from "../windows/window-service";
|
||||||
import { createApplicationController } from "./application-controller";
|
import { createApplicationController } from "./application-controller";
|
||||||
import { getApplicationPaths } from "./paths";
|
import { getApplicationPaths, getRootPath } from "./paths";
|
||||||
|
|
||||||
export function bootstrap(): void {
|
export function bootstrap(): void {
|
||||||
const appConfig = getRuntimeConfig();
|
|
||||||
const isDev = !app.isPackaged;
|
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({
|
const paths = getApplicationPaths({
|
||||||
appConfig,
|
appConfig,
|
||||||
appDataPath: app.getPath("appData"),
|
appDataPath: app.getPath("appData"),
|
||||||
compiledMainDir: path.resolve(__dirname, ".."),
|
compiledMainDir,
|
||||||
isDev,
|
isDev,
|
||||||
resourcesPath: process.resourcesPath,
|
resourcesPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setName(appConfig.title);
|
app.setName(appConfig.title);
|
||||||
@@ -61,7 +77,6 @@ export function bootstrap(): void {
|
|||||||
getAllWindows: () => BrowserWindow.getAllWindows(),
|
getAllWindows: () => BrowserWindow.getAllWindows(),
|
||||||
log,
|
log,
|
||||||
prepareRuntime: prepareUserNodecgRuntime,
|
prepareRuntime: prepareUserNodecgRuntime,
|
||||||
relaunch: () => app.relaunch(),
|
|
||||||
scheduleUpdateCheck: ({ getParentWindow, beforeInstall }) => {
|
scheduleUpdateCheck: ({ getParentWindow, beforeInstall }) => {
|
||||||
scheduleUpdateCheck({
|
scheduleUpdateCheck({
|
||||||
appConfig,
|
appConfig,
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ export function getSourceNodecgRuntimePath(rootPath: string): string {
|
|||||||
return path.resolve(rootPath, "lib", "nodecg");
|
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 {
|
export function getUpdateDownloadDirectory(tempDirectory: string): string {
|
||||||
return path.join(tempDirectory, "scoreko-updates");
|
return path.join(tempDirectory, "scoreko-updates");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
export type AppRuntimeConfig = {
|
export type AppRuntimeConfig = {
|
||||||
title: string;
|
title: string;
|
||||||
userModelId: string;
|
userModelId: string;
|
||||||
@@ -14,36 +16,56 @@ export type AppRuntimeConfig = {
|
|||||||
updateApiUrl?: string;
|
updateApiUrl?: string;
|
||||||
updateReleasePageUrl?: string;
|
updateReleasePageUrl?: string;
|
||||||
updateAssetPattern?: string;
|
updateAssetPattern?: string;
|
||||||
updateConfigPathOverride?: string;
|
|
||||||
updateCheckDelayMs: number;
|
updateCheckDelayMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIN_TCP_PORT = 1;
|
const MIN_TCP_PORT = 1;
|
||||||
const MAX_TCP_PORT = 65535;
|
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 {
|
export function getRuntimeConfig(): AppRuntimeConfig {
|
||||||
// Centralized defaults keep local development and packaged builds consistent.
|
|
||||||
return {
|
return {
|
||||||
title: getEnv("SCOREKO_APP_TITLE", "Scoreko"),
|
title: getRequiredEnv("SCOREKO_APP_TITLE"),
|
||||||
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"),
|
userModelId: getRequiredEnv("SCOREKO_APP_USER_MODEL_ID"),
|
||||||
userDataDirectoryName: getEnv("SCOREKO_APP_USER_DATA_DIRECTORY", "scoreko"),
|
userDataDirectoryName: getRequiredEnv("SCOREKO_APP_USER_DATA_DIRECTORY"),
|
||||||
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
|
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
|
||||||
nodecgPort: parseEnvPort("NODECG_PORT", "9090"),
|
nodecgPort: parseRequiredEnvPort("NODECG_PORT"),
|
||||||
bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"),
|
bundleName: getRequiredEnv("NODECG_BUNDLE_NAME"),
|
||||||
mainDashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"),
|
mainDashboardRoute: getRequiredEnv("SCOREKO_DASHBOARD_ROUTE"),
|
||||||
loadingDashboardRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"),
|
loadingDashboardRoute: getRequiredEnv("SCOREKO_LOADING_ROUTE"),
|
||||||
loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000),
|
loadDelayMs: parseRequiredEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000),
|
||||||
startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000),
|
startupTimeoutMs: parseRequiredEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000),
|
||||||
nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000),
|
nodecgKillTimeoutMs: parseRequiredEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000),
|
||||||
updatesEnabled: parseEnvBool("SCOREKO_UPDATES_ENABLED", true),
|
updatesEnabled: parseRequiredEnvBool("SCOREKO_UPDATES_ENABLED"),
|
||||||
updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"),
|
updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"),
|
||||||
updateReleasePageUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_RELEASE_PAGE_URL"),
|
updateReleasePageUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_RELEASE_PAGE_URL"),
|
||||||
updateAssetPattern: getOptionalEnv("SCOREKO_UPDATE_ASSET_PATTERN"),
|
updateAssetPattern: getOptionalEnv("SCOREKO_UPDATE_ASSET_PATTERN"),
|
||||||
updateConfigPathOverride: getOptionalEnv("SCOREKO_UPDATE_CONFIG_PATH"),
|
updateCheckDelayMs: parseRequiredEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 0, 600000),
|
||||||
updateCheckDelayMs: parseEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 5000, 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 {
|
export function getOptionalEnv(name: string): string | undefined {
|
||||||
const value = process.env[name]?.trim();
|
const value = process.env[name]?.trim();
|
||||||
return value && value.length > 0 ? value : undefined;
|
return value && value.length > 0 ? value : undefined;
|
||||||
@@ -53,18 +75,18 @@ export function getEnv(name: string, fallback: string): string {
|
|||||||
return getOptionalEnv(name) ?? fallback;
|
return getOptionalEnv(name) ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseEnvInt(name: string, fallback: number): number {
|
export function parseRequiredEnvIntInRange(name: string, min: number, max: number): number {
|
||||||
const rawValue = process.env[name];
|
const rawValue = getRequiredEnv(name);
|
||||||
if (!rawValue) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedValue = Number.parseInt(rawValue, 10);
|
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 {
|
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];
|
const rawValue = process.env[name];
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -80,6 +102,19 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
|
|||||||
return parsedValue;
|
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 {
|
export function parseEnvBool(name: string, fallback: boolean): boolean {
|
||||||
const rawValue = process.env[name]?.trim().toLowerCase();
|
const rawValue = process.env[name]?.trim().toLowerCase();
|
||||||
if (!rawValue) {
|
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 {
|
export function parseEnvPort(name: string, fallback: string): string {
|
||||||
const rawValue = getEnv(name, fallback);
|
const rawValue = getEnv(name, fallback);
|
||||||
const parsedValue = Number.parseInt(rawValue, 10);
|
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 = {
|
export type PlatformProcessKillerDeps = {
|
||||||
platform: NodeJS.Platform;
|
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;
|
killProcess: (pid: number, signal: NodeJS.Signals) => void;
|
||||||
log: (...args: unknown[]) => 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 {
|
export function killProcessTree(pid: number, signal: NodeJS.Signals, deps: PlatformProcessKillerDeps): boolean {
|
||||||
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
||||||
deps.log(`Invalid pid for process tree termination: ${pid}`);
|
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 fs from "node:fs";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -17,7 +17,7 @@ type NodecgProcessManagerConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type NodecgProcessManagerDeps = {
|
type NodecgProcessManagerDeps = {
|
||||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
spawnProcess: (command: string, args: string[], options: SpawnOptions) => NodecgChildProcess;
|
||||||
pathExists: (candidatePath: string) => boolean;
|
pathExists: (candidatePath: string) => boolean;
|
||||||
fetchUrl: typeof fetch;
|
fetchUrl: typeof fetch;
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
@@ -31,6 +31,22 @@ type NodecgProcessManagerDeps = {
|
|||||||
hasReadWriteAccess: (candidatePath: string) => boolean;
|
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 = {
|
export type NodecgProcessManager = {
|
||||||
startNodecgProcess: () => Promise<void>;
|
startNodecgProcess: () => Promise<void>;
|
||||||
waitForNodecgReady: (startTime: number) => Promise<void>;
|
waitForNodecgReady: (startTime: number) => Promise<void>;
|
||||||
@@ -50,7 +66,7 @@ export function createNodecgProcessManager({
|
|||||||
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
||||||
const resolvedDeps = resolveDeps(deps);
|
const resolvedDeps = resolveDeps(deps);
|
||||||
|
|
||||||
let nodecgProcess: ChildProcess | null = null;
|
let nodecgProcess: NodecgChildProcess | null = null;
|
||||||
let nodecgState: NodecgProcessState = "idle";
|
let nodecgState: NodecgProcessState = "idle";
|
||||||
let startNodecgPromise: Promise<void> | null = null;
|
let startNodecgPromise: Promise<void> | null = null;
|
||||||
let stopNodecgPromise: 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 { 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$";
|
const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$";
|
||||||
|
|
||||||
@@ -17,71 +15,33 @@ type UpdateConfigOptions = {
|
|||||||
allowInsecureHttp: boolean;
|
allowInsecureHttp: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UpdateRuntimeConfig = Pick<
|
||||||
|
AppRuntimeConfig,
|
||||||
|
"updateApiUrl" | "updateAssetPattern" | "updateReleasePageUrl" | "updatesEnabled"
|
||||||
|
>;
|
||||||
|
|
||||||
export function loadUpdateSettings(
|
export function loadUpdateSettings(
|
||||||
appConfig: AppRuntimeConfig,
|
appConfig: UpdateRuntimeConfig,
|
||||||
rootPath: string,
|
rootPath: string,
|
||||||
log: (...args: unknown[]) => void,
|
log: (...args: unknown[]) => void,
|
||||||
options: UpdateConfigOptions = { allowInsecureHttp: true },
|
options: UpdateConfigOptions = { allowInsecureHttp: true },
|
||||||
): UpdateSettings {
|
): UpdateSettings {
|
||||||
const fileConfig = readUpdateFileConfig(appConfig, rootPath, log);
|
const apiUrl = readOptionalHttpUrl(appConfig.updateApiUrl, options);
|
||||||
const apiUrl = readOptionalHttpUrl(appConfig.updateApiUrl ?? fileConfig.apiUrl, options);
|
const releasePageUrl = readOptionalHttpUrl(appConfig.updateReleasePageUrl, options);
|
||||||
const releasePageUrl = readOptionalHttpUrl(appConfig.updateReleasePageUrl ?? fileConfig.releasePageUrl, options);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)) && Boolean(apiUrl),
|
enabled: appConfig.updatesEnabled && Boolean(apiUrl),
|
||||||
...(apiUrl ? { apiUrl } : {}),
|
...(apiUrl ? { apiUrl } : {}),
|
||||||
...(releasePageUrl ? { releasePageUrl } : {}),
|
...(releasePageUrl ? { releasePageUrl } : {}),
|
||||||
assetPattern:
|
assetPattern: appConfig.updateAssetPattern || DEFAULT_UPDATE_ASSET_PATTERN,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function readOptionalHttpUrl(value: unknown, options: UpdateConfigOptions): string | undefined {
|
function readOptionalHttpUrl(value: unknown, options: UpdateConfigOptions): string | undefined {
|
||||||
const rawValue = readOptionalString(value);
|
const rawValue = readNonEmptyString(value);
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return validateHttpUrl(rawValue, options) ?? 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 { BrowserWindow, dialog } from "electron";
|
||||||
import type { MessageBoxOptions } from "electron";
|
import type { MessageBoxOptions, MessageBoxReturnValue } from "electron";
|
||||||
|
|
||||||
import { ReleaseUpdate } from "./update-schema";
|
import { ReleaseUpdate } from "./update-schema";
|
||||||
|
|
||||||
@@ -41,6 +41,25 @@ export async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: Br
|
|||||||
return result.response === 0;
|
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);
|
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 targetPath = getSafeChildPath(downloadDirectory, safeFileName);
|
||||||
const stagingPath = getSafeChildPath(downloadDirectory, `${safeFileName}.${process.pid}.${Date.now()}.download`);
|
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.mkdirSync(downloadDirectory, { recursive: true });
|
||||||
fs.rmSync(stagingPath, { force: true });
|
fs.rmSync(stagingPath, { force: true });
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isRecord, readNonEmptyString } from "../utils/unknown-values";
|
||||||
|
|
||||||
export type GiteaReleaseAsset = {
|
export type GiteaReleaseAsset = {
|
||||||
name: string;
|
name: string;
|
||||||
browserDownloadUrl: string;
|
browserDownloadUrl: string;
|
||||||
@@ -24,13 +26,6 @@ export type ReleaseUpdate = {
|
|||||||
installer: InstallerAsset;
|
installer: InstallerAsset;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateFileConfig = {
|
|
||||||
enabled?: unknown;
|
|
||||||
apiUrl?: unknown;
|
|
||||||
releasePageUrl?: unknown;
|
|
||||||
assetPattern?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UrlPolicy = {
|
type UrlPolicy = {
|
||||||
allowInsecureHttp: boolean;
|
allowInsecureHttp: boolean;
|
||||||
};
|
};
|
||||||
@@ -47,11 +42,14 @@ export function parseGiteaRelease(value: unknown): GiteaRelease | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const title = readNonEmptyString(value.name);
|
||||||
|
const pageUrl = readOptionalUrlString(value.html_url);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tagName,
|
tagName,
|
||||||
assets,
|
assets,
|
||||||
...(readOptionalString(value.name) ? { title: readOptionalString(value.name) } : {}),
|
...(title ? { title } : {}),
|
||||||
...(readOptionalUrlString(value.html_url) ? { pageUrl: readOptionalUrlString(value.html_url) } : {}),
|
...(pageUrl ? { pageUrl } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,16 +175,12 @@ function normalizeVersion(version: string): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readRequiredString(value: unknown): string | null {
|
function readRequiredString(value: unknown): string | null {
|
||||||
const text = readOptionalString(value);
|
const text = readNonEmptyString(value);
|
||||||
return text && text.length > 0 ? text : null;
|
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 {
|
function readOptionalUrlString(value: unknown): string | undefined {
|
||||||
const rawValue = readOptionalString(value);
|
const rawValue = readNonEmptyString(value);
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -194,10 +188,6 @@ function readOptionalUrlString(value: unknown): string | undefined {
|
|||||||
return validateHttpUrl(rawValue, { allowInsecureHttp: true }) ?? 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 {
|
function isPresent<T>(value: T | null): value is T {
|
||||||
return value !== null;
|
return value !== null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { app, BrowserWindow, shell } from "electron";
|
import { app, BrowserWindow, shell } from "electron";
|
||||||
|
|
||||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
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 { loadUpdateSettings, UpdateSettings } from "./update-config";
|
||||||
import { downloadInstaller } from "./update-download";
|
import { downloadInstaller } from "./update-download";
|
||||||
import { buildReleaseUpdate, GiteaRelease, parseGiteaRelease } from "./update-schema";
|
import { buildReleaseUpdate, GiteaRelease, parseGiteaRelease } from "./update-schema";
|
||||||
@@ -76,10 +76,17 @@ async function checkForUpdates({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const installerPath = await downloadInstaller(update, {
|
let installerPath: string;
|
||||||
tempDirectory: app.getPath("temp"),
|
try {
|
||||||
allowInsecureHttp: protocolPolicy.allowInsecureHttp,
|
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());
|
const shouldInstall = await askToInstallUpdate(update, getParentWindow());
|
||||||
if (!shouldInstall) {
|
if (!shouldInstall) {
|
||||||
await shell.showItemInFolder(installerPath);
|
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 {
|
import {
|
||||||
getApplicationPaths,
|
getApplicationPaths,
|
||||||
getDashboardUrl,
|
getDashboardUrl,
|
||||||
getDefaultUpdateConfigPath,
|
|
||||||
getManagedNodecgRuntimePath,
|
getManagedNodecgRuntimePath,
|
||||||
getNodecgBaseUrl,
|
getNodecgBaseUrl,
|
||||||
getRootPath,
|
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(getSourceNodecgRuntimePath(rootPath), path.resolve(rootPath, "lib", "nodecg"));
|
||||||
assert.equal(getUserDataPath("/app-data", "scoreko"), path.join("/app-data", "scoreko"));
|
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(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(getUpdateDownloadDirectory("/tmp"), path.join("/tmp", "scoreko-updates"));
|
||||||
assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090");
|
assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ test("ApplicationController preserves startup ordering and schedules updates aft
|
|||||||
events.push("prepare-runtime");
|
events.push("prepare-runtime");
|
||||||
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
|
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
|
||||||
},
|
},
|
||||||
relaunch: () => events.push("relaunch"),
|
|
||||||
scheduleUpdateCheck: () => events.push("schedule-update"),
|
scheduleUpdateCheck: () => events.push("schedule-update"),
|
||||||
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
||||||
exit: (code) => events.push(`exit:${code}`),
|
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 events: string[] = [];
|
||||||
const controller = createApplicationController({
|
const controller = createApplicationController({
|
||||||
appConfig: getBaseConfig(),
|
appConfig: getBaseConfig(),
|
||||||
@@ -162,31 +161,45 @@ test("ApplicationController relaunches packaged app after runtime install before
|
|||||||
},
|
},
|
||||||
deps: {
|
deps: {
|
||||||
createLoadingWindow: () => {
|
createLoadingWindow: () => {
|
||||||
throw new Error("window creation should wait until after relaunch decisions");
|
events.push("create-loading");
|
||||||
|
return new MockWindow("loading", events);
|
||||||
},
|
},
|
||||||
createMainWindow: () => {
|
createMainWindow: () => {
|
||||||
throw new Error("window creation should wait until after relaunch decisions");
|
events.push("create-main");
|
||||||
|
return new MockWindow("main", events);
|
||||||
},
|
},
|
||||||
createNodecgProcessManager: () => {
|
createNodecgProcessManager: () => {
|
||||||
throw new Error("NodeCG should not start before relaunch");
|
events.push("create-manager");
|
||||||
|
return createMockManager(events);
|
||||||
},
|
},
|
||||||
getAllWindows: () => [],
|
getAllWindows: () => [],
|
||||||
log: (...args) => events.push(String(args[0])),
|
log: (...args) => events.push(String(args[0])),
|
||||||
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }),
|
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }),
|
||||||
relaunch: () => events.push("relaunch"),
|
|
||||||
scheduleUpdateCheck: () => events.push("schedule-update"),
|
scheduleUpdateCheck: () => events.push("schedule-update"),
|
||||||
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
||||||
exit: (code) => events.push(`exit:${code}`),
|
exit: (code) => events.push(`exit:${code}`),
|
||||||
|
now: () => 0,
|
||||||
|
sleep: async (ms) => {
|
||||||
|
events.push(`sleep:${ms}`);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await controller.launch();
|
await controller.launch();
|
||||||
|
|
||||||
assert.equal(controller.getState(), "stopped");
|
assert.equal(controller.getState(), "ready");
|
||||||
assert.deepEqual(events, [
|
assert.deepEqual(events, [
|
||||||
"Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.",
|
"create-manager",
|
||||||
"relaunch",
|
"create-main",
|
||||||
"exit:0",
|
"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");
|
events.push("prepare-runtime");
|
||||||
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
|
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
|
||||||
},
|
},
|
||||||
relaunch: () => events.push("relaunch"),
|
|
||||||
scheduleUpdateCheck: () => events.push("schedule-update"),
|
scheduleUpdateCheck: () => events.push("schedule-update"),
|
||||||
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
||||||
exit: (code) => events.push(`exit:${code}`),
|
exit: (code) => events.push(`exit:${code}`),
|
||||||
@@ -253,7 +265,6 @@ test("ApplicationController shutdown is idempotent", async () => {
|
|||||||
getAllWindows: () => [],
|
getAllWindows: () => [],
|
||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: false }),
|
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: false }),
|
||||||
relaunch: () => events.push("relaunch"),
|
|
||||||
scheduleUpdateCheck: () => events.push("schedule-update"),
|
scheduleUpdateCheck: () => events.push("schedule-update"),
|
||||||
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
||||||
exit: (code) => events.push(`exit:${code}`),
|
exit: (code) => events.push(`exit:${code}`),
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ test("waitForNodeCGReady resolves when endpoint returns 404", async () => {
|
|||||||
deps: {
|
deps: {
|
||||||
platform: "linux",
|
platform: "linux",
|
||||||
pathExists: () => true,
|
pathExists: () => true,
|
||||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
spawnProcess: () => child,
|
||||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||||
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
||||||
handler();
|
handler();
|
||||||
@@ -118,7 +118,7 @@ test("stopNodeCG sends SIGTERM and then SIGKILL if the process does not exit", a
|
|||||||
deps: {
|
deps: {
|
||||||
platform: "linux",
|
platform: "linux",
|
||||||
pathExists: () => true,
|
pathExists: () => true,
|
||||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
spawnProcess: () => child,
|
||||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||||
killProcess: (pid, signal) => {
|
killProcess: (pid, signal) => {
|
||||||
killSignals.push({ pid, signal });
|
killSignals.push({ pid, signal });
|
||||||
@@ -163,7 +163,7 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
|
|||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
deps: {
|
deps: {
|
||||||
pathExists: () => true,
|
pathExists: () => true,
|
||||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
spawnProcess: () => child,
|
||||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||||
killProcess: () => undefined,
|
killProcess: () => undefined,
|
||||||
setTimer: () => 0,
|
setTimer: () => 0,
|
||||||
@@ -206,7 +206,7 @@ test("startNodeCG reuses the same promise while startup is in progress", async (
|
|||||||
}),
|
}),
|
||||||
spawnProcess: () => {
|
spawnProcess: () => {
|
||||||
spawnCalls += 1;
|
spawnCalls += 1;
|
||||||
return child as unknown as import("node:child_process").ChildProcess;
|
return child;
|
||||||
},
|
},
|
||||||
stdoutWrite: () => undefined,
|
stdoutWrite: () => undefined,
|
||||||
stderrWrite: () => undefined,
|
stderrWrite: () => undefined,
|
||||||
@@ -241,7 +241,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
|
|||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
deps: {
|
deps: {
|
||||||
pathExists: () => true,
|
pathExists: () => true,
|
||||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
spawnProcess: () => child,
|
||||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||||
killProcess: () => undefined,
|
killProcess: () => undefined,
|
||||||
setTimer: (handler, timeoutMs) => {
|
setTimer: (handler, timeoutMs) => {
|
||||||
@@ -306,7 +306,7 @@ test("startNodeCG spawns Electron directly on Windows", async () => {
|
|||||||
capturedCommand = command;
|
capturedCommand = command;
|
||||||
capturedArgs = args;
|
capturedArgs = args;
|
||||||
capturedOptions.push(options);
|
capturedOptions.push(options);
|
||||||
return child as unknown as import("node:child_process").ChildProcess;
|
return child;
|
||||||
},
|
},
|
||||||
stdoutWrite: () => undefined,
|
stdoutWrite: () => undefined,
|
||||||
stderrWrite: () => undefined,
|
stderrWrite: () => undefined,
|
||||||
@@ -333,7 +333,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
|
|||||||
deps: {
|
deps: {
|
||||||
pathExists: () => true,
|
pathExists: () => true,
|
||||||
platform: "linux",
|
platform: "linux",
|
||||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
spawnProcess: () => child,
|
||||||
fetchUrl: async () => {
|
fetchUrl: async () => {
|
||||||
child.emit("exit", 1, null);
|
child.emit("exit", 1, null);
|
||||||
throw new Error("still starting");
|
throw new Error("still starting");
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getEnv,
|
getEnv,
|
||||||
getOptionalEnv,
|
getOptionalEnv,
|
||||||
parseEnvBool,
|
parseEnvBool,
|
||||||
parseEnvInt,
|
|
||||||
parseEnvIntInRange,
|
parseEnvIntInRange,
|
||||||
parseEnvPort,
|
parseEnvPort,
|
||||||
parseOptionalHttpUrl,
|
parseOptionalHttpUrl,
|
||||||
|
loadEnvFile,
|
||||||
|
getRuntimeConfig,
|
||||||
|
getRequiredEnv,
|
||||||
|
parseRequiredEnvIntInRange,
|
||||||
|
parseRequiredEnvBool,
|
||||||
|
parseRequiredEnvPort,
|
||||||
} from "../main/config/runtime-config";
|
} from "../main/config/runtime-config";
|
||||||
|
|
||||||
function withEnv(name: string, value: string | undefined, run: () => void): void {
|
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", () => {
|
test("getOptionalEnv returns undefined for missing variable", () => {
|
||||||
withEnv("TEST_OPTIONAL_ENV", undefined, () => {
|
withEnv("TEST_OPTIONAL_ENV", undefined, () => {
|
||||||
assert.equal(getOptionalEnv("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", () => {
|
test("parseEnvIntInRange hard-fails for out-of-range values", () => {
|
||||||
withEnv("TEST_ENV_INT_RANGE", "999", () => {
|
withEnv("TEST_ENV_INT_RANGE", "999", () => {
|
||||||
assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /must be an integer/);
|
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/);
|
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/,
|
/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