From 42a298925b06941008b4d7c92049fba242319aa6 Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sun, 31 May 2026 16:24:14 +0200 Subject: [PATCH 01/10] Investigating Electron Startup Failures --- docs/architecture.md | 9 +++--- package.json | 2 +- src/main/app/application-controller.ts | 8 ------ src/main/app/bootstrap.ts | 1 - src/tests/application-controller.test.ts | 35 ++++++++++++++++-------- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 8f5edf0..655d985 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/package.json b/package.json index 37e745e..816aee5 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ ], "icon": "static/icons/icon.ico", "executableName": "scoreko", - "signAndEditExecutable": false + "signAndEditExecutable": true }, "nsis": { "oneClick": false, diff --git a/src/main/app/application-controller.ts b/src/main/app/application-controller.ts index fae32a5..c747592 100644 --- a/src/main/app/application-controller.ts +++ b/src/main/app/application-controller.ts @@ -36,7 +36,6 @@ export type ApplicationControllerConfig = { bundleName: string; log: (...args: unknown[]) => void; }) => PreparedNodecgRuntime; - relaunch: () => void; scheduleUpdateCheck: (config: { getParentWindow: () => ApplicationWindow | null; beforeInstall: () => Promise; @@ -129,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); diff --git a/src/main/app/bootstrap.ts b/src/main/app/bootstrap.ts index 26ee8ee..5d2b22b 100644 --- a/src/main/app/bootstrap.ts +++ b/src/main/app/bootstrap.ts @@ -61,7 +61,6 @@ export function bootstrap(): void { getAllWindows: () => BrowserWindow.getAllWindows(), log, prepareRuntime: prepareUserNodecgRuntime, - relaunch: () => app.relaunch(), scheduleUpdateCheck: ({ getParentWindow, beforeInstall }) => { scheduleUpdateCheck({ appConfig, diff --git a/src/tests/application-controller.test.ts b/src/tests/application-controller.test.ts index f0fb07a..f2af329 100644 --- a/src/tests/application-controller.test.ts +++ b/src/tests/application-controller.test.ts @@ -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}`), From 92e2da1758456d6b189f1d2a1d85a90533929d86 Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sun, 31 May 2026 17:50:54 +0200 Subject: [PATCH 02/10] Augmented NODECG_STARTUP_TIMEOUT_MS --- .env.example | 2 +- scripts/doctor.mjs | 2 +- src/main/config/runtime-config.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index ab530db..2366c00 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true # Timing ELECTRON_LOAD_DELAY_MS=10000 -NODECG_STARTUP_TIMEOUT_MS=30000 +NODECG_STARTUP_TIMEOUT_MS=120000 NODECG_KILL_TIMEOUT_MS=2500 # Updates diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index e36d6f3..017d48b 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -75,7 +75,7 @@ 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_STARTUP_TIMEOUT_MS", 120000, 1000, 600000); parseIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000); checkNodecgInstall(); diff --git a/src/main/config/runtime-config.ts b/src/main/config/runtime-config.ts index 6ec2409..37468fd 100644 --- a/src/main/config/runtime-config.ts +++ b/src/main/config/runtime-config.ts @@ -33,7 +33,7 @@ export function getRuntimeConfig(): AppRuntimeConfig { 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), + startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 120000, 1000, 600000), nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000), updatesEnabled: parseEnvBool("SCOREKO_UPDATES_ENABLED", true), updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"), From ce59c5db897eaa9acd6cb6ca2e826843b676422f Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sun, 31 May 2026 18:35:59 +0200 Subject: [PATCH 03/10] env config --- .gitignore | 1 + scripts/doctor.mjs | 41 ++++++-- src/main/app/bootstrap.ts | 26 ++++- src/main/config/runtime-config.ts | 88 ++++++++++++++--- src/tests/runtime-config.test.ts | 154 ++++++++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 250ab4a..f16c7b4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ lib .localappdata .npm-cache .npm-runtime-cache +.env \ No newline at end of file diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index 017d48b..c9e77e1 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -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", 120000, 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) { diff --git a/src/main/app/bootstrap.ts b/src/main/app/bootstrap.ts index 5d2b22b..9d1fb40 100644 --- a/src/main/app/bootstrap.ts +++ b/src/main/app/bootstrap.ts @@ -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); diff --git a/src/main/config/runtime-config.ts b/src/main/config/runtime-config.ts index 37468fd..8e976bc 100644 --- a/src/main/config/runtime-config.ts +++ b/src/main/config/runtime-config.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; + export type AppRuntimeConfig = { title: string; userModelId: string; @@ -21,29 +23,51 @@ export type AppRuntimeConfig = { 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", 120000, 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,8 +77,18 @@ export function getEnv(name: string, fallback: string): string { return getOptionalEnv(name) ?? fallback; } +export function parseRequiredEnvIntInRange(name: string, min: number, max: number): number { + const rawValue = getRequiredEnv(name); + const parsedValue = Number.parseInt(rawValue, 10); + 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; @@ -70,6 +104,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) { @@ -105,6 +152,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); diff --git a/src/tests/runtime-config.test.ts b/src/tests/runtime-config.test.ts index a283f96..853580d 100644 --- a/src/tests/runtime-config.test.ts +++ b/src/tests/runtime-config.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import path from "node:path"; import { getEnv, @@ -8,6 +9,12 @@ import { parseEnvIntInRange, parseEnvPort, parseOptionalHttpUrl, + loadEnvFile, + getRuntimeConfig, + getRequiredEnv, + parseRequiredEnvIntInRange, + parseRequiredEnvBool, + parseRequiredEnvPort, } from "../main/config/runtime-config"; function withEnv(name: string, value: string | undefined, run: () => void): void { @@ -31,6 +38,30 @@ function withEnv(name: string, value: string | undefined, run: () => void): void } } +function withEnvs(envs: Record, run: () => void): void { + const previousValues: Record = {}; + 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); @@ -106,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); + }, + ); +}); From 8e6b79ca688f7418422898459b10b6296173ed3b Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sun, 31 May 2026 18:45:57 +0200 Subject: [PATCH 04/10] deleted unnecesary --- .env.example | 22 +++++---- src/main/app/paths.ts | 4 -- src/main/config/runtime-config.ts | 2 - src/main/updates/update-config.ts | 56 +++------------------- src/main/updates/update-schema.ts | 7 --- src/tests/app-paths.test.ts | 2 - src/tests/update-config.test.ts | 77 +++++++++---------------------- static/updates.json | 6 --- 8 files changed, 41 insertions(+), 135 deletions(-) delete mode 100644 static/updates.json diff --git a/.env.example b/.env.example index 2366c00..b8b94d2 100644 --- a/.env.example +++ b/.env.example @@ -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=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$ diff --git a/src/main/app/paths.ts b/src/main/app/paths.ts index 6e4b8b6..97034a1 100644 --- a/src/main/app/paths.ts +++ b/src/main/app/paths.ts @@ -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"); } diff --git a/src/main/config/runtime-config.ts b/src/main/config/runtime-config.ts index 8e976bc..372a827 100644 --- a/src/main/config/runtime-config.ts +++ b/src/main/config/runtime-config.ts @@ -16,7 +16,6 @@ export type AppRuntimeConfig = { updateApiUrl?: string; updateReleasePageUrl?: string; updateAssetPattern?: string; - updateConfigPathOverride?: string; updateCheckDelayMs: number; }; @@ -55,7 +54,6 @@ export function getRuntimeConfig(): AppRuntimeConfig { 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: parseRequiredEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 0, 600000), }; } diff --git a/src/main/updates/update-config.ts b/src/main/updates/update-config.ts index 2852327..cefafdf 100644 --- a/src/main/updates/update-config.ts +++ b/src/main/updates/update-config.ts @@ -1,9 +1,6 @@ -import fs from "node:fs"; - -import { getDefaultUpdateConfigPath } from "../app/paths"; import { AppRuntimeConfig } from "../config/runtime-config"; -import { isRecord, readNonEmptyString } from "../utils/unknown-values"; -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$"; @@ -20,11 +17,7 @@ type UpdateConfigOptions = { type UpdateRuntimeConfig = Pick< AppRuntimeConfig, - | "updateApiUrl" - | "updateAssetPattern" - | "updateConfigPathOverride" - | "updateReleasePageUrl" - | "updatesEnabled" + "updateApiUrl" | "updateAssetPattern" | "updateReleasePageUrl" | "updatesEnabled" >; export function loadUpdateSettings( @@ -33,49 +26,14 @@ export function loadUpdateSettings( 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 || readNonEmptyString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN, - }; -} - -export function readUpdateFileConfig( - appConfig: Pick, - 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, }; } diff --git a/src/main/updates/update-schema.ts b/src/main/updates/update-schema.ts index 10e3172..da8878f 100644 --- a/src/main/updates/update-schema.ts +++ b/src/main/updates/update-schema.ts @@ -26,13 +26,6 @@ export type ReleaseUpdate = { installer: InstallerAsset; }; -export type UpdateFileConfig = { - enabled?: unknown; - apiUrl?: unknown; - releasePageUrl?: unknown; - assetPattern?: unknown; -}; - type UrlPolicy = { allowInsecureHttp: boolean; }; diff --git a/src/tests/app-paths.test.ts b/src/tests/app-paths.test.ts index f8da921..a0f21e8 100644 --- a/src/tests/app-paths.test.ts +++ b/src/tests/app-paths.test.ts @@ -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( diff --git a/src/tests/update-config.test.ts b/src/tests/update-config.test.ts index 8462936..ab1751e 100644 --- a/src/tests/update-config.test.ts +++ b/src/tests/update-config.test.ts @@ -1,11 +1,8 @@ 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"; +import { loadUpdateSettings } from "../main/updates/update-config"; const baseConfig: AppRuntimeConfig = { title: "Scoreko", @@ -23,37 +20,36 @@ const baseConfig: AppRuntimeConfig = { }; 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); + 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 rootPath = makeTempRoot({ - enabled: true, - apiUrl: "http://gitea.local/releases/latest", - }); - - const settings = loadUpdateSettings(baseConfig, rootPath, () => undefined, { allowInsecureHttp: false }); + 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 override file settings", () => { - const rootPath = makeTempRoot({ - enabled: true, - apiUrl: "https://file.local/releases/latest", - releasePageUrl: "https://file.local/releases", - assetPattern: "File-.*\\.exe$", - }); - +test("loadUpdateSettings lets runtime config specify settings", () => { const settings = loadUpdateSettings( { ...baseConfig, @@ -61,7 +57,7 @@ test("loadUpdateSettings lets runtime config override file settings", () => { updateReleasePageUrl: "https://env.local/releases", updateAssetPattern: "Env-.*\\.exe$", }, - rootPath, + "", () => undefined, ); @@ -72,34 +68,3 @@ test("loadUpdateSettings lets runtime config override file settings", () => { 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-config-")); - 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-config-")); - const staticPath = path.join(rootPath, "static"); - - fs.mkdirSync(staticPath, { recursive: true }); - fs.writeFileSync(path.join(staticPath, "updates.json"), JSON.stringify(config), "utf8"); - - return rootPath; -} diff --git a/static/updates.json b/static/updates.json deleted file mode 100644 index c7bc959..0000000 --- a/static/updates.json +++ /dev/null @@ -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$" -} From ca74a23d19926d710de24febedea79e6bb89222b Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sun, 31 May 2026 18:52:51 +0200 Subject: [PATCH 05/10] Improving Installer and Updater Process --- package.json | 5 ++++- src/main/updates/update-dialogs.ts | 16 +++++++++++++ src/main/updates/update-download.ts | 7 ++++++ src/main/updates/update-service.ts | 17 +++++++++----- src/tests/update-download.test.ts | 35 +++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 816aee5..87188d3 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,10 @@ "uninstallerIcon": "static/icons/icon.ico", "installerHeaderIcon": "static/icons/icon.ico", "shortcutName": "Scoreko", - "useZip": false + "useZip": false, + "deleteAppDataOnUninstall": true, + "showInstDetails": "show", + "showUninstDetails": "show" }, "compression": "normal" }, diff --git a/src/main/updates/update-dialogs.ts b/src/main/updates/update-dialogs.ts index d448fae..48e7695 100644 --- a/src/main/updates/update-dialogs.ts +++ b/src/main/updates/update-dialogs.ts @@ -41,6 +41,22 @@ export async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: Br return result.response === 0; } +export async function showDownloadFailedDialog( + update: ReleaseUpdate, + error: unknown, + parentWindow: BrowserWindow | null, +): Promise { + 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, diff --git a/src/main/updates/update-download.ts b/src/main/updates/update-download.ts index 5e58d51..dd36419 100644 --- a/src/main/updates/update-download.ts +++ b/src/main/updates/update-download.ts @@ -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 }); diff --git a/src/main/updates/update-service.ts b/src/main/updates/update-service.ts index 5b74477..d350c71 100644 --- a/src/main/updates/update-service.ts +++ b/src/main/updates/update-service.ts @@ -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); diff --git a/src/tests/update-download.test.ts b/src/tests/update-download.test.ts index 566b891..6259f45 100644 --- a/src/tests/update-download.test.ts +++ b/src/tests/update-download.test.ts @@ -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; + } +}); From 3f756feca61fb9b44edd80c4d3508b33cac686a1 Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sun, 31 May 2026 18:57:18 +0200 Subject: [PATCH 06/10] a --- package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/package.json b/package.json index 87188d3..df0fafb 100644 --- a/package.json +++ b/package.json @@ -85,9 +85,7 @@ "installerHeaderIcon": "static/icons/icon.ico", "shortcutName": "Scoreko", "useZip": false, - "deleteAppDataOnUninstall": true, - "showInstDetails": "show", - "showUninstDetails": "show" + "deleteAppDataOnUninstall": true }, "compression": "normal" }, From d01ae1fa6b21f0626219b2751d4d8ad6c63b5374 Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sun, 31 May 2026 19:47:30 +0200 Subject: [PATCH 07/10] Fix complete. Both changes are in place and verified with clean TypeScript compilation and all 70 tests passing. --- package.json | 8 ++++++++ src/main/config/runtime-config.ts | 25 +++++++++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index df0fafb..a81be3f 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,14 @@ { "from": "static", "to": "static" + }, + { + "from": ".env", + "to": ".env" + }, + { + "from": ".env.example", + "to": ".env.example" } ], "mac": { diff --git a/src/main/config/runtime-config.ts b/src/main/config/runtime-config.ts index 372a827..cdb0c90 100644 --- a/src/main/config/runtime-config.ts +++ b/src/main/config/runtime-config.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import path from "node:path"; export type AppRuntimeConfig = { title: string; @@ -23,13 +24,9 @@ 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.`, - ); - } + const resolvedPath = resolveEnvFilePath(envFilePath); try { - process.loadEnvFile(envFilePath); + process.loadEnvFile(resolvedPath); } catch (error) { throw new Error( `Error al leer el archivo de configuración .env: ${error instanceof Error ? error.message : String(error)}`, @@ -37,6 +34,22 @@ export function loadEnvFile(envFilePath: string): void { } } +function resolveEnvFilePath(envFilePath: string): string { + if (fs.existsSync(envFilePath)) { + return envFilePath; + } + + const dir = path.dirname(envFilePath); + const fallbackPath = path.join(dir, ".env.example"); + if (fs.existsSync(fallbackPath)) { + return fallbackPath; + } + + 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.`, + ); +} + export function getRuntimeConfig(): AppRuntimeConfig { return { title: getRequiredEnv("SCOREKO_APP_TITLE"), From ed5a7d0994c8034fbfa5462142b113ff93c019e1 Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sun, 31 May 2026 20:39:55 +0200 Subject: [PATCH 08/10] b --- package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/package.json b/package.json index a81be3f..aab1ae4 100644 --- a/package.json +++ b/package.json @@ -57,10 +57,6 @@ { "from": ".env", "to": ".env" - }, - { - "from": ".env.example", - "to": ".env.example" } ], "mac": { From 88223d744c86a38e8fc3c5a0598b758c9b4bea87 Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Mon, 1 Jun 2026 11:16:07 +0200 Subject: [PATCH 09/10] Add before-pack script and installer configuration for NSIS --- package.json | 2 + scripts/before-pack.mjs | 13 +++++ static/installSection.nsh | 110 ++++++++++++++++++++++++++++++++++++++ static/installer.nsh | 4 ++ 4 files changed, 129 insertions(+) create mode 100644 scripts/before-pack.mjs create mode 100644 static/installSection.nsh create mode 100644 static/installer.nsh diff --git a/package.json b/package.json index aab1ae4..0c5cc81 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac" }, "build": { + "beforePack": "./scripts/before-pack.mjs", "appId": "com.scoreko.desktop", "productName": "Scoreko", "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", @@ -81,6 +82,7 @@ "signAndEditExecutable": true }, "nsis": { + "include": "static/installer.nsh", "oneClick": false, "allowToChangeInstallationDirectory": true, "artifactName": "${productName}-setup-${version}.${ext}", diff --git a/scripts/before-pack.mjs b/scripts/before-pack.mjs new file mode 100644 index 0000000..d8b5bde --- /dev/null +++ b/scripts/before-pack.mjs @@ -0,0 +1,13 @@ +// scripts/beforePack.mjs +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default async function () { + const src = path.resolve(__dirname, '../static/installSection.nsh'); + const dest = path.resolve(__dirname, '../node_modules/app-builder-lib/templates/nsis/installSection.nsh'); + fs.copyFileSync(src, dest); + console.log('✅ installSection.nsh parcheado'); +} \ No newline at end of file diff --git a/static/installSection.nsh b/static/installSection.nsh new file mode 100644 index 0000000..3a06368 --- /dev/null +++ b/static/installSection.nsh @@ -0,0 +1,110 @@ +!include installer.nsh + +InitPluginsDir + +${IfNot} ${Silent} + SetDetailsPrint both +${endif} + +StrCpy $appExe "$INSTDIR\${APP_EXECUTABLE_FILENAME}" + +# must be called before uninstallOldVersion +!insertmacro setLinkVars + +!ifdef ONE_CLICK + !ifdef HEADER_ICO + File /oname=$PLUGINSDIR\installerHeaderico.ico "${HEADER_ICO}" + !endif + ${IfNot} ${Silent} + !ifdef HEADER_ICO + SpiderBanner::Show /MODERN /ICON "$PLUGINSDIR\installerHeaderico.ico" + !else + SpiderBanner::Show /MODERN + !endif + + FindWindow $0 "#32770" "" $hwndparent + FindWindow $0 "#32770" "" $hwndparent $0 + GetDlgItem $0 $0 1000 + SendMessage $0 ${WM_SETTEXT} 0 "STR:$(installing)" + + StrCpy $1 $hwndparent + System::Call 'user32::ShutdownBlockReasonCreate(${SYSTYPE_PTR}r1, w "$(installing)")' + ${endif} + !insertmacro CHECK_APP_RUNNING +!else + ${ifNot} ${UAC_IsInnerInstance} + !insertmacro CHECK_APP_RUNNING + ${endif} +!endif + +Var /GLOBAL keepShortcuts +StrCpy $keepShortcuts "false" +!insertMacro setIsTryToKeepShortcuts +${if} $isTryToKeepShortcuts == "true" + ReadRegStr $R1 SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" KeepShortcuts + + ${if} $R1 == "true" + ${andIf} ${FileExists} "$appExe" + StrCpy $keepShortcuts "true" + ${endIf} +${endif} + +!insertmacro uninstallOldVersion SHELL_CONTEXT +!insertmacro handleUninstallResult SHELL_CONTEXT + +${if} $installMode == "all" + !insertmacro uninstallOldVersion HKEY_CURRENT_USER + !insertmacro handleUninstallResult HKEY_CURRENT_USER +${endIf} + +SetOutPath $INSTDIR + +!ifdef UNINSTALLER_ICON + File /oname=uninstallerIcon.ico "${UNINSTALLER_ICON}" +!endif + +!insertmacro installApplicationFiles +!insertmacro registryAddInstallInfo +!insertmacro addStartMenuLink $keepShortcuts +!insertmacro addDesktopLink $keepShortcuts + +${if} ${FileExists} "$newStartMenuLink" + StrCpy $launchLink "$newStartMenuLink" +${else} + StrCpy $launchLink "$INSTDIR\${APP_EXECUTABLE_FILENAME}" +${endIf} + +!ifmacrodef registerFileAssociations + !insertmacro registerFileAssociations +!endif + +!ifmacrodef customInstall + !insertmacro customInstall +!endif + +!macro doStartApp + # otherwise app window will be in background + HideWindow + !insertmacro StartApp +!macroend + +!ifdef ONE_CLICK + # https://github.com/electron-userland/electron-builder/pull/3093#issuecomment-403734568 + !ifdef RUN_AFTER_FINISH + ${ifNot} ${Silent} + ${orIf} ${isForceRun} + !insertmacro doStartApp + ${endIf} + !else + ${if} ${isForceRun} + !insertmacro doStartApp + ${endIf} + !endif + !insertmacro quitSuccess +!else + # for assisted installer run only if silent, because assisted installer has run after finish option + ${if} ${isForceRun} + ${andIf} ${Silent} + !insertmacro doStartApp + ${endIf} +!endif diff --git a/static/installer.nsh b/static/installer.nsh new file mode 100644 index 0000000..870f7ac --- /dev/null +++ b/static/installer.nsh @@ -0,0 +1,4 @@ +!macro customHeader + ShowInstDetails show + ShowUninstDetails show +!macroend \ No newline at end of file From beb22cb43898edc9a14dc00fa22baaf4404feae7 Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Tue, 2 Jun 2026 19:36:30 +0200 Subject: [PATCH 10/10] Hide installation and uninstallation details in the custom header of the installer script --- static/installer.nsh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/installer.nsh b/static/installer.nsh index 870f7ac..0245b19 100644 --- a/static/installer.nsh +++ b/static/installer.nsh @@ -1,4 +1,4 @@ !macro customHeader - ShowInstDetails show - ShowUninstDetails show + ShowInstDetails hide + ShowUninstDetails hide !macroend \ No newline at end of file