Cleanup final

This commit is contained in:
2026-05-31 14:50:32 +02:00
parent 865c3589bd
commit 33665ed896
13 changed files with 168 additions and 71 deletions
+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.
+2 -8
View File
@@ -2,6 +2,7 @@ import { AppRuntimeConfig } from "../config/runtime-config";
import { NodecgProcessManager } from "../nodecg/process-manager"; import { NodecgProcessManager } from "../nodecg/process-manager";
import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner"; import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner";
import { getRemainingDelayMs } from "../utils/timing"; import { getRemainingDelayMs } from "../utils/timing";
import { ApplicationPaths } from "./paths";
import { createShutdownService, ShutdownService } from "./shutdown-service"; import { createShutdownService, ShutdownService } from "./shutdown-service";
export type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed"; export type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed";
@@ -21,14 +22,7 @@ export type ApplicationControllerConfig = {
appVersion: string; appVersion: string;
isPackaged: boolean; isPackaged: boolean;
isWindows: boolean; isWindows: boolean;
paths: { paths: ApplicationPaths;
rootPath: string;
sourceNodecgRuntimePath: string;
userDataPath: string;
nodecgBaseUrl: string;
mainDashboardUrl: string;
loadingDashboardUrl: string;
};
deps: { deps: {
createLoadingWindow: () => ApplicationWindow; createLoadingWindow: () => ApplicationWindow;
createMainWindow: () => ApplicationWindow; createMainWindow: () => ApplicationWindow;
-10
View File
@@ -53,16 +53,6 @@ export function getEnv(name: string, fallback: string): string {
return getOptionalEnv(name) ?? fallback; return getOptionalEnv(name) ?? fallback;
} }
export function parseEnvInt(name: string, fallback: number): number {
const rawValue = process.env[name];
if (!rawValue) {
return fallback;
}
const parsedValue = Number.parseInt(rawValue, 10);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
}
export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number { 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. // We throw here instead of silently coercing to avoid hidden misconfiguration in production.
const rawValue = process.env[name]; const rawValue = process.env[name];
+6 -2
View File
@@ -1,12 +1,16 @@
import { ChildProcess, SpawnOptions } from "node:child_process"; import { SpawnOptions } from "node:child_process";
export type PlatformProcessKillerDeps = { export type PlatformProcessKillerDeps = {
platform: NodeJS.Platform; platform: NodeJS.Platform;
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess; spawnProcess: (command: string, args: string[], options: SpawnOptions) => SpawnedKillerProcess;
killProcess: (pid: number, signal: NodeJS.Signals) => void; killProcess: (pid: number, signal: NodeJS.Signals) => void;
log: (...args: unknown[]) => void; log: (...args: unknown[]) => void;
}; };
type SpawnedKillerProcess = {
on: (event: "error", listener: (error: Error) => void) => unknown;
};
export function killProcessTree(pid: number, signal: NodeJS.Signals, deps: PlatformProcessKillerDeps): boolean { export function killProcessTree(pid: number, signal: NodeJS.Signals, deps: PlatformProcessKillerDeps): boolean {
if (!Number.isSafeInteger(pid) || pid <= 0) { if (!Number.isSafeInteger(pid) || pid <= 0) {
deps.log(`Invalid pid for process tree termination: ${pid}`); deps.log(`Invalid pid for process tree termination: ${pid}`);
+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 fs from "node:fs";
import net from "node:net"; import net from "node:net";
import path from "node:path"; import path from "node:path";
@@ -17,7 +17,7 @@ type NodecgProcessManagerConfig = {
}; };
type NodecgProcessManagerDeps = { type NodecgProcessManagerDeps = {
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess; spawnProcess: (command: string, args: string[], options: SpawnOptions) => NodecgChildProcess;
pathExists: (candidatePath: string) => boolean; pathExists: (candidatePath: string) => boolean;
fetchUrl: typeof fetch; fetchUrl: typeof fetch;
platform: NodeJS.Platform; platform: NodeJS.Platform;
@@ -31,6 +31,22 @@ type NodecgProcessManagerDeps = {
hasReadWriteAccess: (candidatePath: string) => boolean; hasReadWriteAccess: (candidatePath: string) => boolean;
}; };
type NodecgChildProcess = {
pid?: number;
killed: boolean;
exitCode: number | null;
signalCode: NodeJS.Signals | null;
stdout?: ProcessOutputStream | null;
stderr?: ProcessOutputStream | null;
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
on(event: "error", listener: (error: Error) => void): unknown;
once(event: "exit", listener: () => void): unknown;
};
type ProcessOutputStream = {
on(event: "data", listener: (chunk: unknown) => void): unknown;
};
export type NodecgProcessManager = { export type NodecgProcessManager = {
startNodecgProcess: () => Promise<void>; startNodecgProcess: () => Promise<void>;
waitForNodecgReady: (startTime: number) => Promise<void>; waitForNodecgReady: (startTime: number) => Promise<void>;
@@ -50,7 +66,7 @@ export function createNodecgProcessManager({
}: NodecgProcessManagerConfig): NodecgProcessManager { }: NodecgProcessManagerConfig): NodecgProcessManager {
const resolvedDeps = resolveDeps(deps); const resolvedDeps = resolveDeps(deps);
let nodecgProcess: ChildProcess | null = null; let nodecgProcess: NodecgChildProcess | null = null;
let nodecgState: NodecgProcessState = "idle"; let nodecgState: NodecgProcessState = "idle";
let startNodecgPromise: Promise<void> | null = null; let startNodecgPromise: Promise<void> | null = null;
let stopNodecgPromise: Promise<void> | null = null; let stopNodecgPromise: Promise<void> | null = null;
+14 -12
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import { getDefaultUpdateConfigPath } from "../app/paths"; import { getDefaultUpdateConfigPath } from "../app/paths";
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { isRecord, readNonEmptyString } from "../utils/unknown-values";
import { UpdateFileConfig, validateHttpUrl } from "./update-schema"; import { UpdateFileConfig, validateHttpUrl } from "./update-schema";
const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$"; const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$";
@@ -17,8 +18,17 @@ type UpdateConfigOptions = {
allowInsecureHttp: boolean; allowInsecureHttp: boolean;
}; };
type UpdateRuntimeConfig = Pick<
AppRuntimeConfig,
| "updateApiUrl"
| "updateAssetPattern"
| "updateConfigPathOverride"
| "updateReleasePageUrl"
| "updatesEnabled"
>;
export function loadUpdateSettings( export function loadUpdateSettings(
appConfig: AppRuntimeConfig, appConfig: UpdateRuntimeConfig,
rootPath: string, rootPath: string,
log: (...args: unknown[]) => void, log: (...args: unknown[]) => void,
options: UpdateConfigOptions = { allowInsecureHttp: true }, options: UpdateConfigOptions = { allowInsecureHttp: true },
@@ -32,12 +42,12 @@ export function loadUpdateSettings(
...(apiUrl ? { apiUrl } : {}), ...(apiUrl ? { apiUrl } : {}),
...(releasePageUrl ? { releasePageUrl } : {}), ...(releasePageUrl ? { releasePageUrl } : {}),
assetPattern: assetPattern:
appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN, appConfig.updateAssetPattern || readNonEmptyString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN,
}; };
} }
export function readUpdateFileConfig( export function readUpdateFileConfig(
appConfig: AppRuntimeConfig, appConfig: Pick<AppRuntimeConfig, "updateConfigPathOverride">,
rootPath: string, rootPath: string,
log: (...args: unknown[]) => void, log: (...args: unknown[]) => void,
): UpdateFileConfig { ): UpdateFileConfig {
@@ -70,18 +80,10 @@ function normalizeUpdateFileConfig(value: unknown): UpdateFileConfig {
} }
function readOptionalHttpUrl(value: unknown, options: UpdateConfigOptions): string | undefined { function readOptionalHttpUrl(value: unknown, options: UpdateConfigOptions): string | undefined {
const rawValue = readOptionalString(value); const rawValue = readNonEmptyString(value);
if (!rawValue) { if (!rawValue) {
return undefined; return undefined;
} }
return validateHttpUrl(rawValue, options) ?? undefined; return validateHttpUrl(rawValue, options) ?? undefined;
} }
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+5 -2
View File
@@ -1,5 +1,5 @@
import { BrowserWindow, dialog } from "electron"; import { BrowserWindow, dialog } from "electron";
import type { MessageBoxOptions } from "electron"; import type { MessageBoxOptions, MessageBoxReturnValue } from "electron";
import { ReleaseUpdate } from "./update-schema"; import { ReleaseUpdate } from "./update-schema";
@@ -41,6 +41,9 @@ export async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: Br
return result.response === 0; return result.response === 0;
} }
function showMessageBox(parentWindow: BrowserWindow | null, options: MessageBoxOptions) { function showMessageBox(
parentWindow: BrowserWindow | null,
options: MessageBoxOptions,
): Promise<MessageBoxReturnValue> {
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options); return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
} }
+9 -12
View File
@@ -1,3 +1,5 @@
import { isRecord, readNonEmptyString } from "../utils/unknown-values";
export type GiteaReleaseAsset = { export type GiteaReleaseAsset = {
name: string; name: string;
browserDownloadUrl: string; browserDownloadUrl: string;
@@ -47,11 +49,14 @@ export function parseGiteaRelease(value: unknown): GiteaRelease | null {
return null; return null;
} }
const title = readNonEmptyString(value.name);
const pageUrl = readOptionalUrlString(value.html_url);
return { return {
tagName, tagName,
assets, assets,
...(readOptionalString(value.name) ? { title: readOptionalString(value.name) } : {}), ...(title ? { title } : {}),
...(readOptionalUrlString(value.html_url) ? { pageUrl: readOptionalUrlString(value.html_url) } : {}), ...(pageUrl ? { pageUrl } : {}),
}; };
} }
@@ -177,16 +182,12 @@ function normalizeVersion(version: string): number[] {
} }
function readRequiredString(value: unknown): string | null { function readRequiredString(value: unknown): string | null {
const text = readOptionalString(value); const text = readNonEmptyString(value);
return text && text.length > 0 ? text : null; return text && text.length > 0 ? text : null;
} }
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function readOptionalUrlString(value: unknown): string | undefined { function readOptionalUrlString(value: unknown): string | undefined {
const rawValue = readOptionalString(value); const rawValue = readNonEmptyString(value);
if (!rawValue) { if (!rawValue) {
return undefined; return undefined;
} }
@@ -194,10 +195,6 @@ function readOptionalUrlString(value: unknown): string | undefined {
return validateHttpUrl(rawValue, { allowInsecureHttp: true }) ?? undefined; return validateHttpUrl(rawValue, { allowInsecureHttp: true }) ?? undefined;
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isPresent<T>(value: T | null): value is T { function isPresent<T>(value: T | null): value is T {
return value !== null; return value !== null;
} }
+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;
}
+7 -7
View File
@@ -85,7 +85,7 @@ test("waitForNodeCGReady resolves when endpoint returns 404", async () => {
deps: { deps: {
platform: "linux", platform: "linux",
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => { setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
handler(); handler();
@@ -118,7 +118,7 @@ test("stopNodeCG sends SIGTERM and then SIGKILL if the process does not exit", a
deps: { deps: {
platform: "linux", platform: "linux",
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
killProcess: (pid, signal) => { killProcess: (pid, signal) => {
killSignals.push({ pid, signal }); killSignals.push({ pid, signal });
@@ -163,7 +163,7 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
log: () => undefined, log: () => undefined,
deps: { deps: {
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
killProcess: () => undefined, killProcess: () => undefined,
setTimer: () => 0, setTimer: () => 0,
@@ -206,7 +206,7 @@ test("startNodeCG reuses the same promise while startup is in progress", async (
}), }),
spawnProcess: () => { spawnProcess: () => {
spawnCalls += 1; spawnCalls += 1;
return child as unknown as import("node:child_process").ChildProcess; return child;
}, },
stdoutWrite: () => undefined, stdoutWrite: () => undefined,
stderrWrite: () => undefined, stderrWrite: () => undefined,
@@ -241,7 +241,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
log: () => undefined, log: () => undefined,
deps: { deps: {
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
killProcess: () => undefined, killProcess: () => undefined,
setTimer: (handler, timeoutMs) => { setTimer: (handler, timeoutMs) => {
@@ -306,7 +306,7 @@ test("startNodeCG spawns Electron directly on Windows", async () => {
capturedCommand = command; capturedCommand = command;
capturedArgs = args; capturedArgs = args;
capturedOptions.push(options); capturedOptions.push(options);
return child as unknown as import("node:child_process").ChildProcess; return child;
}, },
stdoutWrite: () => undefined, stdoutWrite: () => undefined,
stderrWrite: () => undefined, stderrWrite: () => undefined,
@@ -333,7 +333,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
deps: { deps: {
pathExists: () => true, pathExists: () => true,
platform: "linux", platform: "linux",
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => { fetchUrl: async () => {
child.emit("exit", 1, null); child.emit("exit", 1, null);
throw new Error("still starting"); throw new Error("still starting");
-13
View File
@@ -5,7 +5,6 @@ import {
getEnv, getEnv,
getOptionalEnv, getOptionalEnv,
parseEnvBool, parseEnvBool,
parseEnvInt,
parseEnvIntInRange, parseEnvIntInRange,
parseEnvPort, parseEnvPort,
parseOptionalHttpUrl, parseOptionalHttpUrl,
@@ -56,18 +55,6 @@ test("getEnv returns the value when present", () => {
}); });
}); });
test("parseEnvInt returns fallback for invalid values", () => {
withEnv("TEST_ENV_INT", "abc", () => {
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 100);
});
});
test("parseEnvInt parses valid integers", () => {
withEnv("TEST_ENV_INT", "4500", () => {
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 4500);
});
});
test("parseEnvIntInRange hard-fails for out-of-range values", () => { test("parseEnvIntInRange hard-fails for out-of-range values", () => {
withEnv("TEST_ENV_INT_RANGE", "999", () => { withEnv("TEST_ENV_INT_RANGE", "999", () => {
assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /must be an integer/); assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /must be an integer/);
@@ -80,7 +80,7 @@ test("readUpdateFileConfig normalizes malformed config into an empty file config
}); });
test("readUpdateFileConfig logs invalid JSON and returns an empty file config", () => { test("readUpdateFileConfig logs invalid JSON and returns an empty file config", () => {
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-settings-")); const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-config-"));
const staticPath = path.join(rootPath, "static"); const staticPath = path.join(rootPath, "static");
fs.mkdirSync(staticPath, { recursive: true }); fs.mkdirSync(staticPath, { recursive: true });
fs.writeFileSync(path.join(staticPath, "updates.json"), "{ invalid", "utf8"); fs.writeFileSync(path.join(staticPath, "updates.json"), "{ invalid", "utf8");
@@ -95,7 +95,7 @@ test("readUpdateFileConfig logs invalid JSON and returns an empty file config",
}); });
function makeTempRoot(config: unknown): string { function makeTempRoot(config: unknown): string {
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-settings-")); const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-config-"));
const staticPath = path.join(rootPath, "static"); const staticPath = path.join(rootPath, "static");
fs.mkdirSync(staticPath, { recursive: true }); fs.mkdirSync(staticPath, { recursive: true });