mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
refactor(main): extraer configuración, ventanas, procesos y errores
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
export type AppRuntimeConfig = {
|
||||||
|
title: string;
|
||||||
|
userModelId: string;
|
||||||
|
iconPathOverride?: string;
|
||||||
|
nodecgPort: string;
|
||||||
|
bundleName: string;
|
||||||
|
dashboardRoute: string;
|
||||||
|
loadingRoute: string;
|
||||||
|
loadDelayMs: number;
|
||||||
|
startupTimeoutMs: number;
|
||||||
|
nodecgKillTimeoutMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRuntimeConfig(): AppRuntimeConfig {
|
||||||
|
return {
|
||||||
|
title: getEnv("SCOREKO_APP_TITLE", "Scoreko"),
|
||||||
|
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"),
|
||||||
|
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
|
||||||
|
nodecgPort: getEnv("NODECG_PORT", "9090"),
|
||||||
|
bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"),
|
||||||
|
dashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"),
|
||||||
|
loadingRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"),
|
||||||
|
loadDelayMs: parseEnvInt("ELECTRON_LOAD_DELAY_MS", 10000),
|
||||||
|
startupTimeoutMs: parseEnvInt("NODECG_STARTUP_TIMEOUT_MS", 30000),
|
||||||
|
nodecgKillTimeoutMs: parseEnvInt("NODECG_KILL_TIMEOUT_MS", 2500),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionalEnv(name: string): string | undefined {
|
||||||
|
const value = process.env[name]?.trim();
|
||||||
|
return value && value.length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnv(name: string, fallback: string): string {
|
||||||
|
return getOptionalEnv(name) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const NODE_RUNTIME_NAME = "electron internal node";
|
||||||
|
export const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f";
|
||||||
|
|
||||||
|
export const DEFAULT_WINDOW_SIZE = {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
minWidth: 1920,
|
||||||
|
minHeight: 1080,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const LOADING_WINDOW_SIZE = {
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { app, dialog } from "electron";
|
||||||
|
|
||||||
|
export function log(...args: unknown[]): void {
|
||||||
|
console.log("[scoreko-electron]", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const stack = error.stack?.trim();
|
||||||
|
return stack && stack.length > 0 ? stack : error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!app.isReady()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.showErrorBox("Scoreko - Error al iniciar", details);
|
||||||
|
}
|
||||||
+26
-346
@@ -1,46 +1,12 @@
|
|||||||
import { app, BrowserWindow, BrowserWindowConstructorOptions, dialog, shell } from "electron";
|
import { app, BrowserWindow } from "electron";
|
||||||
import { ChildProcess, spawn } from "node:child_process";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
type AppRuntimeConfig = {
|
import { getRuntimeConfig } from "./config/runtime-config";
|
||||||
title: string;
|
import { showFatalError, log } from "./errors/error-presenter";
|
||||||
userModelId: string;
|
import { createNodecgProcessManager } from "./nodecg/process-manager";
|
||||||
iconPathOverride?: string;
|
import { createLoadingWindow, createMainWindow } from "./windows/window-factory";
|
||||||
nodecgPort: string;
|
|
||||||
bundleName: string;
|
|
||||||
dashboardRoute: string;
|
|
||||||
loadingRoute: string;
|
|
||||||
loadDelayMs: number;
|
|
||||||
startupTimeoutMs: number;
|
|
||||||
nodecgKillTimeoutMs: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const RUNTIME_NAME = "electron internal node";
|
const runtimeConfig = getRuntimeConfig();
|
||||||
const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f";
|
|
||||||
const DEFAULT_WINDOW_SIZE = {
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
minWidth: 1920,
|
|
||||||
minHeight: 1080,
|
|
||||||
};
|
|
||||||
const LOADING_WINDOW_SIZE = {
|
|
||||||
width: 300,
|
|
||||||
height: 300,
|
|
||||||
};
|
|
||||||
|
|
||||||
const runtimeConfig: AppRuntimeConfig = {
|
|
||||||
title: getEnv("SCOREKO_APP_TITLE", "Scoreko"),
|
|
||||||
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"),
|
|
||||||
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
|
|
||||||
nodecgPort: getEnv("NODECG_PORT", "9090"),
|
|
||||||
bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"),
|
|
||||||
dashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"),
|
|
||||||
loadingRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"),
|
|
||||||
loadDelayMs: parseEnvInt("ELECTRON_LOAD_DELAY_MS", 10000),
|
|
||||||
startupTimeoutMs: parseEnvInt("NODECG_STARTUP_TIMEOUT_MS", 30000),
|
|
||||||
nodecgKillTimeoutMs: parseEnvInt("NODECG_KILL_TIMEOUT_MS", 2500),
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDev = !app.isPackaged;
|
const isDev = !app.isPackaged;
|
||||||
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath;
|
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath;
|
||||||
@@ -49,200 +15,25 @@ const dashboardUrl = `http://localhost:${runtimeConfig.nodecgPort}/bundles/${run
|
|||||||
const loadingUrl = `http://localhost:${runtimeConfig.nodecgPort}/bundles/${runtimeConfig.bundleName}/${runtimeConfig.loadingRoute}`;
|
const loadingUrl = `http://localhost:${runtimeConfig.nodecgPort}/bundles/${runtimeConfig.bundleName}/${runtimeConfig.loadingRoute}`;
|
||||||
const baseUrl = `http://127.0.0.1:${runtimeConfig.nodecgPort}`;
|
const baseUrl = `http://127.0.0.1:${runtimeConfig.nodecgPort}`;
|
||||||
|
|
||||||
|
const nodecgManager = createNodecgProcessManager({
|
||||||
|
isDev,
|
||||||
|
nodecgPath,
|
||||||
|
baseUrl,
|
||||||
|
runtimeConfig,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let loadingWindow: BrowserWindow | null = null;
|
let loadingWindow: BrowserWindow | null = null;
|
||||||
let nodecgProcess: ChildProcess | null = null;
|
|
||||||
let stopNodeCGPromise: Promise<void> | null = null;
|
|
||||||
let isQuitting = false;
|
let isQuitting = false;
|
||||||
|
|
||||||
function createMainWindow(): BrowserWindow {
|
|
||||||
const windowOptions = createWindowOptions({ isLoadingWindow: false });
|
|
||||||
const window = new BrowserWindow(windowOptions);
|
|
||||||
|
|
||||||
window.setMenuBarVisibility(false);
|
|
||||||
|
|
||||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
|
||||||
void shell.openExternal(url);
|
|
||||||
return { action: "deny" };
|
|
||||||
});
|
|
||||||
|
|
||||||
window.webContents.on("will-navigate", (event, url) => {
|
|
||||||
if (url !== dashboardUrl) {
|
|
||||||
event.preventDefault();
|
|
||||||
void shell.openExternal(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.on("page-title-updated", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLoadingWindow(): BrowserWindow {
|
|
||||||
const window = new BrowserWindow(createWindowOptions({ isLoadingWindow: true }));
|
|
||||||
|
|
||||||
window.on("page-title-updated", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWindowOptions({ isLoadingWindow }: { isLoadingWindow: boolean }): BrowserWindowConstructorOptions {
|
|
||||||
const iconPath = resolveAppIconPath();
|
|
||||||
|
|
||||||
const baseOptions: BrowserWindowConstructorOptions = {
|
|
||||||
show: false,
|
|
||||||
title: runtimeConfig.title,
|
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
|
||||||
backgroundColor: DEFAULT_WINDOW_BACKGROUND,
|
|
||||||
webPreferences: {
|
|
||||||
contextIsolation: true,
|
|
||||||
sandbox: true,
|
|
||||||
...(isLoadingWindow ? {} : { nodeIntegration: false }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoadingWindow) {
|
|
||||||
return {
|
|
||||||
...baseOptions,
|
|
||||||
frame: false,
|
|
||||||
width: LOADING_WINDOW_SIZE.width,
|
|
||||||
height: LOADING_WINDOW_SIZE.height,
|
|
||||||
resizable: false,
|
|
||||||
movable: true,
|
|
||||||
minimizable: false,
|
|
||||||
maximizable: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...baseOptions,
|
|
||||||
width: DEFAULT_WINDOW_SIZE.width,
|
|
||||||
height: DEFAULT_WINDOW_SIZE.height,
|
|
||||||
minWidth: DEFAULT_WINDOW_SIZE.minWidth,
|
|
||||||
minHeight: DEFAULT_WINDOW_SIZE.minHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAppIconPath(): string | undefined {
|
|
||||||
const iconCandidates = [
|
|
||||||
runtimeConfig.iconPathOverride,
|
|
||||||
path.join(rootPath, "static", "icons", "icon.ico"),
|
|
||||||
path.join(rootPath, "static", "icons", "icon.png"),
|
|
||||||
path.join(rootPath, "static", "icon.ico"),
|
|
||||||
path.join(rootPath, "static", "icon.png"),
|
|
||||||
].filter((candidate): candidate is string => Boolean(candidate));
|
|
||||||
|
|
||||||
return iconCandidates.find((candidate) => fs.existsSync(candidate));
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateNodeCGInstall(): void {
|
|
||||||
const indexPath = path.join(nodecgPath, "index.js");
|
|
||||||
const nodecgBootstrapPath = path.join(nodecgPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
|
||||||
const bundlePath = path.join(nodecgPath, "bundles", runtimeConfig.bundleName);
|
|
||||||
|
|
||||||
if (!fs.existsSync(nodecgPath)) {
|
|
||||||
throw new Error(`No existe la carpeta NodeCG: ${nodecgPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(indexPath)) {
|
|
||||||
throw new Error(`No se encontró ${indexPath}. Copia una instalación completa de NodeCG en lib/nodecg.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(nodecgBootstrapPath)) {
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
"NodeCG está presente pero faltan dependencias internas.",
|
|
||||||
`No existe: ${nodecgBootstrapPath}`,
|
|
||||||
"Solución: entra a lib/nodecg e instala dependencias:",
|
|
||||||
" npm install",
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(bundlePath)) {
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
`No se encontró el bundle '${runtimeConfig.bundleName}'.`,
|
|
||||||
`Ruta esperada: ${bundlePath}`,
|
|
||||||
"Copia/clona tu bundle dentro de lib/nodecg/bundles antes de ejecutar Electron.",
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startNodeCG(): ChildProcess {
|
|
||||||
validateNodeCGInstall();
|
|
||||||
|
|
||||||
const indexPath = path.join(nodecgPath, "index.js");
|
|
||||||
const child = spawn(process.execPath, [indexPath], {
|
|
||||||
cwd: nodecgPath,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
NODE_ENV: isDev ? "development" : "production",
|
|
||||||
NODECG_PORT: runtimeConfig.nodecgPort,
|
|
||||||
ELECTRON_RUN_AS_NODE: "1",
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
detached: process.platform !== "win32",
|
|
||||||
shell: process.platform === "win32",
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stdout?.on("data", (chunk) => {
|
|
||||||
process.stdout.write(String(chunk));
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr?.on("data", (chunk) => {
|
|
||||||
process.stderr.write(String(chunk));
|
|
||||||
});
|
|
||||||
|
|
||||||
log(`NodeCG started with pid=${child.pid} using ${RUNTIME_NAME}`);
|
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
|
||||||
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
|
|
||||||
nodecgProcess = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForNodeCGReady(startTime: number): Promise<void> {
|
|
||||||
while (Date.now() - startTime < runtimeConfig.startupTimeoutMs) {
|
|
||||||
if (!nodecgProcess) {
|
|
||||||
throw new Error("NodeCG terminó antes de estar listo.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(baseUrl, { method: "GET" });
|
|
||||||
if (response.ok || response.status === 404) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// retry until timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Timeout esperando NodeCG en ${baseUrl} (${runtimeConfig.startupTimeoutMs}ms).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launch(): Promise<void> {
|
async function launch(): Promise<void> {
|
||||||
mainWindow = createMainWindow();
|
mainWindow = createMainWindow({ runtimeConfig, rootPath, dashboardUrl });
|
||||||
loadingWindow = createLoadingWindow();
|
loadingWindow = createLoadingWindow({ runtimeConfig, rootPath });
|
||||||
|
|
||||||
nodecgProcess = startNodeCG();
|
nodecgManager.startNodeCG();
|
||||||
|
|
||||||
await waitForNodeCGReady(Date.now());
|
await nodecgManager.waitForNodeCGReady(Date.now());
|
||||||
|
|
||||||
if (!loadingWindow || loadingWindow.isDestroyed()) {
|
if (!loadingWindow || loadingWindow.isDestroyed()) {
|
||||||
return;
|
return;
|
||||||
@@ -268,121 +59,10 @@ async function launch(): Promise<void> {
|
|||||||
closeLoadingWindow();
|
closeLoadingWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
function killNodeCGProcessTree(pid: number, signal: NodeJS.Signals): boolean {
|
function sleep(ms: number): Promise<void> {
|
||||||
if (process.platform === "win32") {
|
return new Promise((resolve) => {
|
||||||
const force = signal === "SIGKILL" ? "/F" : "";
|
setTimeout(resolve, ms);
|
||||||
const killer = spawn("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
|
|
||||||
stdio: "ignore",
|
|
||||||
shell: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
killer.on("error", (error) => {
|
|
||||||
log(`taskkill error for pid=${pid}`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
process.kill(-pid, signal);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
process.kill(pid, signal);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopNodeCG(): Promise<void> {
|
|
||||||
if (stopNodeCGPromise) {
|
|
||||||
return stopNodeCGPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nodecgProcess || nodecgProcess.killed) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
const processToStop = nodecgProcess;
|
|
||||||
const pid = processToStop.pid;
|
|
||||||
|
|
||||||
if (typeof pid !== "number") {
|
|
||||||
log("NodeCG pid unavailable, skipping graceful stop");
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`Stopping NodeCG pid=${pid}`);
|
|
||||||
killNodeCGProcessTree(pid, "SIGTERM");
|
|
||||||
|
|
||||||
stopNodeCGPromise = new Promise((resolve) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (nodecgProcess === processToStop) {
|
|
||||||
nodecgProcess = null;
|
|
||||||
}
|
|
||||||
stopNodeCGPromise = null;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
processToStop.once("exit", () => {
|
|
||||||
complete();
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
|
||||||
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
|
||||||
killNodeCGProcessTree(pid, "SIGKILL");
|
|
||||||
}
|
|
||||||
}, Math.max(0, runtimeConfig.nodecgKillTimeoutMs));
|
|
||||||
});
|
|
||||||
|
|
||||||
return stopNodeCGPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
function log(...args: unknown[]): void {
|
|
||||||
console.log("[scoreko-electron]", ...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatErrorMessage(error: unknown): string {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
const stack = error.stack?.trim();
|
|
||||||
return stack && stack.length > 0 ? stack : error.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFatalError(message: string, error?: unknown): void {
|
|
||||||
const formattedError = error ? formatErrorMessage(error) : undefined;
|
|
||||||
const details = formattedError ? `${message}\n\n${formattedError}` : message;
|
|
||||||
|
|
||||||
console.error(details);
|
|
||||||
|
|
||||||
if (!app.isReady()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.showErrorBox("Scoreko - Error al iniciar", details);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOptionalEnv(name: string): string | undefined {
|
|
||||||
const value = process.env[name]?.trim();
|
|
||||||
return value && value.length > 0 ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEnv(name: string, fallback: string): string {
|
|
||||||
return getOptionalEnv(name) ?? fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLoadingWindow(): void {
|
function closeLoadingWindow(): void {
|
||||||
@@ -401,7 +81,7 @@ app.on("ready", () => {
|
|||||||
app.setAppUserModelId(runtimeConfig.userModelId);
|
app.setAppUserModelId(runtimeConfig.userModelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
launch().catch(async (error: unknown) => {
|
launch().catch((error: unknown) => {
|
||||||
showFatalError("No se pudo iniciar Scoreko.", error);
|
showFatalError("No se pudo iniciar Scoreko.", error);
|
||||||
closeLoadingWindow();
|
closeLoadingWindow();
|
||||||
app.exit(1);
|
app.exit(1);
|
||||||
@@ -410,7 +90,7 @@ app.on("ready", () => {
|
|||||||
|
|
||||||
app.on("activate", async () => {
|
app.on("activate", async () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
mainWindow = createMainWindow();
|
mainWindow = createMainWindow({ runtimeConfig, rootPath, dashboardUrl });
|
||||||
await mainWindow.loadURL(dashboardUrl);
|
await mainWindow.loadURL(dashboardUrl);
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
}
|
}
|
||||||
@@ -430,17 +110,17 @@ app.on("before-quit", (event) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
isQuitting = true;
|
isQuitting = true;
|
||||||
|
|
||||||
stopNodeCG().finally(() => {
|
nodecgManager.stopNodeCG().finally(() => {
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on("will-quit", () => {
|
app.on("will-quit", () => {
|
||||||
stopNodeCG();
|
void nodecgManager.stopNodeCG();
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("exit", () => {
|
process.on("exit", () => {
|
||||||
stopNodeCG();
|
void nodecgManager.stopNodeCG();
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { ChildProcess, spawn } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
|
import { NODE_RUNTIME_NAME } from "../constants";
|
||||||
|
|
||||||
|
type NodecgProcessManagerConfig = {
|
||||||
|
isDev: boolean;
|
||||||
|
nodecgPath: string;
|
||||||
|
baseUrl: string;
|
||||||
|
runtimeConfig: AppRuntimeConfig;
|
||||||
|
log: (...args: unknown[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodecgProcessManager = {
|
||||||
|
startNodeCG: () => ChildProcess;
|
||||||
|
waitForNodeCGReady: (startTime: number) => Promise<void>;
|
||||||
|
stopNodeCG: () => Promise<void>;
|
||||||
|
getProcess: () => ChildProcess | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createNodecgProcessManager({
|
||||||
|
isDev,
|
||||||
|
nodecgPath,
|
||||||
|
baseUrl,
|
||||||
|
runtimeConfig,
|
||||||
|
log,
|
||||||
|
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
||||||
|
let nodecgProcess: ChildProcess | null = null;
|
||||||
|
let stopNodeCGPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const startNodeCG = (): ChildProcess => {
|
||||||
|
validateNodeCGInstall(nodecgPath, runtimeConfig.bundleName);
|
||||||
|
|
||||||
|
const indexPath = path.join(nodecgPath, "index.js");
|
||||||
|
const child = spawn(process.execPath, [indexPath], {
|
||||||
|
cwd: nodecgPath,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: isDev ? "development" : "production",
|
||||||
|
NODECG_PORT: runtimeConfig.nodecgPort,
|
||||||
|
ELECTRON_RUN_AS_NODE: "1",
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout?.on("data", (chunk) => {
|
||||||
|
process.stdout.write(String(chunk));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
process.stderr.write(String(chunk));
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
|
||||||
|
nodecgProcess = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
nodecgProcess = child;
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForNodeCGReady = async (startTime: number): Promise<void> => {
|
||||||
|
while (Date.now() - startTime < runtimeConfig.startupTimeoutMs) {
|
||||||
|
if (!nodecgProcess) {
|
||||||
|
throw new Error("NodeCG terminó antes de estar listo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(baseUrl, { method: "GET" });
|
||||||
|
if (response.ok || response.status === 404) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry until timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Timeout esperando NodeCG en ${baseUrl} (${runtimeConfig.startupTimeoutMs}ms).`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopNodeCG = (): Promise<void> => {
|
||||||
|
if (stopNodeCGPromise) {
|
||||||
|
return stopNodeCGPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodecgProcess || nodecgProcess.killed) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const processToStop = nodecgProcess;
|
||||||
|
const pid = processToStop.pid;
|
||||||
|
|
||||||
|
if (typeof pid !== "number") {
|
||||||
|
log("NodeCG pid unavailable, skipping graceful stop");
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Stopping NodeCG pid=${pid}`);
|
||||||
|
killNodeCGProcessTree(pid, "SIGTERM", log);
|
||||||
|
|
||||||
|
stopNodeCGPromise = new Promise((resolve) => {
|
||||||
|
const complete = () => {
|
||||||
|
if (nodecgProcess === processToStop) {
|
||||||
|
nodecgProcess = null;
|
||||||
|
}
|
||||||
|
stopNodeCGPromise = null;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
processToStop.once("exit", () => {
|
||||||
|
complete();
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
||||||
|
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
||||||
|
killNodeCGProcessTree(pid, "SIGKILL", log);
|
||||||
|
}
|
||||||
|
}, Math.max(0, runtimeConfig.nodecgKillTimeoutMs));
|
||||||
|
});
|
||||||
|
|
||||||
|
return stopNodeCGPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
startNodeCG,
|
||||||
|
waitForNodeCGReady,
|
||||||
|
stopNodeCG,
|
||||||
|
getProcess: () => nodecgProcess,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNodeCGInstall(nodecgPath: string, bundleName: string): void {
|
||||||
|
const indexPath = path.join(nodecgPath, "index.js");
|
||||||
|
const nodecgBootstrapPath = path.join(nodecgPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
||||||
|
const bundlePath = path.join(nodecgPath, "bundles", bundleName);
|
||||||
|
|
||||||
|
if (!fs.existsSync(nodecgPath)) {
|
||||||
|
throw new Error(`No existe la carpeta NodeCG: ${nodecgPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
throw new Error(`No se encontró ${indexPath}. Copia una instalación completa de NodeCG en lib/nodecg.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(nodecgBootstrapPath)) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"NodeCG está presente pero faltan dependencias internas.",
|
||||||
|
`No existe: ${nodecgBootstrapPath}`,
|
||||||
|
"Solución: entra a lib/nodecg e instala dependencias:",
|
||||||
|
" npm install",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(bundlePath)) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`No se encontró el bundle '${bundleName}'.`,
|
||||||
|
`Ruta esperada: ${bundlePath}`,
|
||||||
|
"Copia/clona tu bundle dentro de lib/nodecg/bundles antes de ejecutar Electron.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function killNodeCGProcessTree(pid: number, signal: NodeJS.Signals, log: (...args: unknown[]) => void): boolean {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const force = signal === "SIGKILL" ? "/F" : "";
|
||||||
|
const killer = spawn("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
|
||||||
|
stdio: "ignore",
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
killer.on("error", (error) => {
|
||||||
|
log(`taskkill error for pid=${pid}`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.kill(-pid, signal);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
process.kill(pid, signal);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
|
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
|
||||||
|
|
||||||
|
type WindowFactoryDependencies = {
|
||||||
|
runtimeConfig: AppRuntimeConfig;
|
||||||
|
rootPath: string;
|
||||||
|
dashboardUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMainWindow({ runtimeConfig, rootPath, dashboardUrl }: WindowFactoryDependencies): BrowserWindow {
|
||||||
|
const windowOptions = createWindowOptions({ runtimeConfig, rootPath, isLoadingWindow: false });
|
||||||
|
const window = new BrowserWindow(windowOptions);
|
||||||
|
|
||||||
|
window.setMenuBarVisibility(false);
|
||||||
|
|
||||||
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
void shell.openExternal(url);
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
|
||||||
|
window.webContents.on("will-navigate", (event, url) => {
|
||||||
|
if (url !== dashboardUrl) {
|
||||||
|
event.preventDefault();
|
||||||
|
void shell.openExternal(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.on("page-title-updated", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLoadingWindow({ runtimeConfig, rootPath }: Omit<WindowFactoryDependencies, "dashboardUrl">): BrowserWindow {
|
||||||
|
const window = new BrowserWindow(createWindowOptions({ runtimeConfig, rootPath, isLoadingWindow: true }));
|
||||||
|
|
||||||
|
window.on("page-title-updated", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindowOptions({
|
||||||
|
runtimeConfig,
|
||||||
|
rootPath,
|
||||||
|
isLoadingWindow,
|
||||||
|
}: {
|
||||||
|
runtimeConfig: AppRuntimeConfig;
|
||||||
|
rootPath: string;
|
||||||
|
isLoadingWindow: boolean;
|
||||||
|
}): BrowserWindowConstructorOptions {
|
||||||
|
const iconPath = resolveAppIconPath(runtimeConfig, rootPath);
|
||||||
|
|
||||||
|
const baseOptions: BrowserWindowConstructorOptions = {
|
||||||
|
show: false,
|
||||||
|
title: runtimeConfig.title,
|
||||||
|
...(iconPath ? { icon: iconPath } : {}),
|
||||||
|
backgroundColor: DEFAULT_WINDOW_BACKGROUND,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
...(isLoadingWindow ? {} : { nodeIntegration: false }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingWindow) {
|
||||||
|
return {
|
||||||
|
...baseOptions,
|
||||||
|
frame: false,
|
||||||
|
width: LOADING_WINDOW_SIZE.width,
|
||||||
|
height: LOADING_WINDOW_SIZE.height,
|
||||||
|
resizable: false,
|
||||||
|
movable: true,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseOptions,
|
||||||
|
width: DEFAULT_WINDOW_SIZE.width,
|
||||||
|
height: DEFAULT_WINDOW_SIZE.height,
|
||||||
|
minWidth: DEFAULT_WINDOW_SIZE.minWidth,
|
||||||
|
minHeight: DEFAULT_WINDOW_SIZE.minHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAppIconPath(runtimeConfig: AppRuntimeConfig, rootPath: string): string | undefined {
|
||||||
|
const iconCandidates = [
|
||||||
|
runtimeConfig.iconPathOverride,
|
||||||
|
path.join(rootPath, "static", "icons", "icon.ico"),
|
||||||
|
path.join(rootPath, "static", "icons", "icon.png"),
|
||||||
|
path.join(rootPath, "static", "icon.ico"),
|
||||||
|
path.join(rootPath, "static", "icon.png"),
|
||||||
|
].filter((candidate): candidate is string => Boolean(candidate));
|
||||||
|
|
||||||
|
return iconCandidates.find((candidate) => fs.existsSync(candidate));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user