Compare commits

..

7 Commits

Author SHA1 Message Date
Pandipipas 3f756feca6 a 2026-05-31 18:57:18 +02:00
Pandipipas ca74a23d19 Improving Installer and Updater Process 2026-05-31 18:52:51 +02:00
Pandipipas 8e6b79ca68 deleted unnecesary 2026-05-31 18:45:57 +02:00
Pandipipas ce59c5db89 env config 2026-05-31 18:35:59 +02:00
Pandipipas 92e2da1758 Augmented NODECG_STARTUP_TIMEOUT_MS 2026-05-31 17:50:54 +02:00
Pandipipas 42a298925b Investigating Electron Startup Failures 2026-05-31 16:24:14 +02:00
Pandipipas 33665ed896 Cleanup final 2026-05-31 14:50:32 +02:00
27 changed files with 629 additions and 303 deletions
+14 -10
View File
@@ -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$
+1
View File
@@ -7,3 +7,4 @@ lib
.localappdata
.npm-cache
.npm-runtime-cache
.env
+4 -5
View File
@@ -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
+97
View File
@@ -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
View File
@@ -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
View File
@@ -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 -16
View File
@@ -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);
+21 -6
View File
@@ -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,
-4
View File
@@ -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");
}
+71 -23
View File
@@ -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);
+6 -2
View File
@@ -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}`);
+19 -3
View File
@@ -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;
+13 -53
View File
@@ -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);
}
+21 -2
View File
@@ -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);
}
+7
View File
@@ -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 });
+9 -19
View File
@@ -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;
}
+12 -5
View File
@@ -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, {
tempDirectory: app.getPath("temp"),
allowInsecureHttp: protocolPolicy.allowInsecureHttp,
});
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);
+7
View File
@@ -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;
}
-2
View File
@@ -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(
+23 -12
View File
@@ -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}`),
+7 -7
View File
@@ -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");
+154 -13
View File
@@ -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);
},
);
});
+70
View File
@@ -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$",
});
});
+35
View File
@@ -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;
}
});
-105
View File
@@ -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;
}
-6
View File
@@ -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$"
}