feat: complete pending roadmap items with doctor, hardening, and code quality

This commit is contained in:
Pandipipas
2026-02-21 19:27:11 +01:00
parent 710fea38c0
commit 2b0d627396
20 changed files with 1620 additions and 106 deletions
+3 -1
View File
@@ -67,7 +67,9 @@ export function parseEnvPort(name: string, fallback: string): string {
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue) || parsedValue < MIN_TCP_PORT || parsedValue > MAX_TCP_PORT) {
throw new Error(`La variable ${name} debe ser un puerto TCP válido (${MIN_TCP_PORT}-${MAX_TCP_PORT}). Valor recibido: '${rawValue}'.`);
throw new Error(
`La variable ${name} debe ser un puerto TCP válido (${MIN_TCP_PORT}-${MAX_TCP_PORT}). Valor recibido: '${rawValue}'.`,
);
}
return String(parsedValue);
+7 -2
View File
@@ -1,7 +1,9 @@
import { app, dialog } from "electron";
import { logger } from "./logger";
export function log(...args: unknown[]): void {
console.log("[scoreko-electron]", ...args);
logger.info("runtime", { args });
}
export function formatErrorMessage(error: unknown): string {
@@ -17,7 +19,10 @@ export function showFatalError(message: string, error?: unknown): void {
const formattedError = error ? formatErrorMessage(error) : undefined;
const details = formattedError ? `${message}\n\n${formattedError}` : message;
console.error(details);
logger.error("fatal-startup-error", {
message,
error: formattedError,
});
if (!app.isReady()) {
return;
+34
View File
@@ -0,0 +1,34 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
type LogContext = Record<string, unknown>;
function write(level: LogLevel, message: string, context?: LogContext): void {
const payload = {
ts: new Date().toISOString(),
level,
source: "scoreko-electron",
message,
...(context ? { context } : {}),
};
const line = JSON.stringify(payload);
if (level === "error") {
console.error(line);
return;
}
if (level === "warn") {
console.warn(line);
return;
}
console.log(line);
}
export const logger = {
debug: (message: string, context?: LogContext): void => write("debug", message, context),
info: (message: string, context?: LogContext): void => write("info", message, context),
warn: (message: string, context?: LogContext): void => write("warn", message, context),
error: (message: string, context?: LogContext): void => write("error", message, context),
};
+1 -1
View File
@@ -34,7 +34,7 @@ async function launchApplication(): Promise<void> {
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
loadingWindow = createLoadingWindow({ appConfig, rootPath });
nodecgManager.startNodecgProcess();
await nodecgManager.startNodecgProcess();
await nodecgManager.waitForNodecgReady(Date.now());
+65 -10
View File
@@ -1,5 +1,6 @@
import { ChildProcess, spawn, SpawnOptions } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { AppRuntimeConfig } from "../config/runtime-config";
@@ -25,10 +26,12 @@ type NodecgProcessManagerDeps = {
setTimer: (handler: () => void, timeoutMs: number) => unknown;
stdoutWrite: (chunk: string) => void;
stderrWrite: (chunk: string) => void;
probePortAvailable: (port: number) => Promise<boolean>;
hasReadWriteAccess: (candidatePath: string) => boolean;
};
export type NodecgProcessManager = {
startNodecgProcess: () => ChildProcess;
startNodecgProcess: () => Promise<ChildProcess>;
waitForNodecgReady: (startTime: number) => Promise<void>;
stopNodecgProcessGracefully: () => Promise<void>;
getProcess: () => ChildProcess | null;
@@ -47,8 +50,21 @@ export function createNodecgProcessManager({
let nodecgProcess: ChildProcess | null = null;
let stopNodecgPromise: Promise<void> | null = null;
const startNodecgProcess = (): ChildProcess => {
validateNodecgInstall(nodecgRootPath, appConfig.bundleName, resolvedDeps.pathExists);
const startNodecgProcess = async (): Promise<ChildProcess> => {
validateNodecgInstall(
nodecgRootPath,
appConfig.bundleName,
resolvedDeps.pathExists,
resolvedDeps.hasReadWriteAccess,
);
const portAsNumber = Number.parseInt(appConfig.nodecgPort, 10);
const isPortAvailable = await resolvedDeps.probePortAvailable(portAsNumber);
if (!isPortAvailable) {
throw new Error(
`El puerto ${appConfig.nodecgPort} ya está en uso. Cierra el proceso que lo ocupa o configura NODECG_PORT antes de iniciar.`,
);
}
const indexPath = path.join(nodecgRootPath, "index.js");
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
@@ -138,12 +154,15 @@ export function createNodecgProcessManager({
complete();
});
resolvedDeps.setTimer(() => {
if (processToStop.exitCode === null && processToStop.signalCode === null) {
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps);
}
}, Math.max(0, appConfig.nodecgKillTimeoutMs));
resolvedDeps.setTimer(
() => {
if (processToStop.exitCode === null && processToStop.signalCode === null) {
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps);
}
},
Math.max(0, appConfig.nodecgKillTimeoutMs),
);
});
return stopNodecgPromise;
@@ -169,10 +188,17 @@ function resolveDeps(deps?: Partial<NodecgProcessManagerDeps>): NodecgProcessMan
setTimer: deps?.setTimer ?? setTimeout,
stdoutWrite: deps?.stdoutWrite ?? ((chunk) => process.stdout.write(chunk)),
stderrWrite: deps?.stderrWrite ?? ((chunk) => process.stderr.write(chunk)),
probePortAvailable: deps?.probePortAvailable ?? probePortAvailable,
hasReadWriteAccess: deps?.hasReadWriteAccess ?? hasReadWriteAccess,
};
}
function validateNodecgInstall(nodecgRootPath: string, bundleName: string, pathExists: (candidatePath: string) => boolean): void {
function validateNodecgInstall(
nodecgRootPath: string,
bundleName: string,
pathExists: (candidatePath: string) => boolean,
hasReadWriteAccessToPath: (candidatePath: string) => boolean,
): void {
const indexPath = path.join(nodecgRootPath, "index.js");
const nodecgBootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
@@ -181,6 +207,10 @@ function validateNodecgInstall(nodecgRootPath: string, bundleName: string, pathE
throw new Error(`No existe la carpeta NodeCG: ${nodecgRootPath}`);
}
if (!hasReadWriteAccessToPath(nodecgRootPath)) {
throw new Error(`Sin permisos de lectura/escritura sobre NodeCG: ${nodecgRootPath}`);
}
if (!pathExists(indexPath)) {
throw new Error(`No se encontró ${indexPath}. Copia una instalación completa de NodeCG en lib/nodecg.`);
}
@@ -207,6 +237,31 @@ function validateNodecgInstall(nodecgRootPath: string, bundleName: string, pathE
}
}
function hasReadWriteAccess(candidatePath: string): boolean {
try {
fs.accessSync(candidatePath, fs.constants.R_OK | fs.constants.W_OK);
return true;
} catch {
return false;
}
}
function probePortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => {
resolve(false);
});
server.listen(port, "127.0.0.1", () => {
server.close(() => {
resolve(true);
});
});
});
}
function killNodecgProcessTree(
pid: number,
signal: NodeJS.Signals,
+5 -1
View File
@@ -1,3 +1,7 @@
export function getRemainingDelayMs(targetDelayMs: number, startedAtMs: number, currentTimeMs: number = Date.now()): number {
export function getRemainingDelayMs(
targetDelayMs: number,
startedAtMs: number,
currentTimeMs: number = Date.now(),
): number {
return Math.max(0, targetDelayMs - (currentTimeMs - startedAtMs));
}
+4 -1
View File
@@ -43,7 +43,10 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind
return window;
}
export function createLoadingWindow({ appConfig, rootPath }: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
export function createLoadingWindow({
appConfig,
rootPath,
}: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true }));
window.on("page-title-updated", (event) => {