diff --git a/src/main/config/runtime-config.ts b/src/main/config/runtime-config.ts new file mode 100644 index 0000000..e51aa52 --- /dev/null +++ b/src/main/config/runtime-config.ts @@ -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; +} diff --git a/src/main/constants.ts b/src/main/constants.ts new file mode 100644 index 0000000..372b3e5 --- /dev/null +++ b/src/main/constants.ts @@ -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; diff --git a/src/main/errors/error-presenter.ts b/src/main/errors/error-presenter.ts new file mode 100644 index 0000000..6cc84e0 --- /dev/null +++ b/src/main/errors/error-presenter.ts @@ -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); +} diff --git a/src/main/main.ts b/src/main/main.ts index dd8e596..207e283 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,46 +1,12 @@ -import { app, BrowserWindow, BrowserWindowConstructorOptions, dialog, shell } from "electron"; -import { ChildProcess, spawn } from "node:child_process"; -import fs from "node:fs"; +import { app, BrowserWindow } from "electron"; import path from "node:path"; -type AppRuntimeConfig = { - title: string; - userModelId: string; - iconPathOverride?: string; - nodecgPort: string; - bundleName: string; - dashboardRoute: string; - loadingRoute: string; - loadDelayMs: number; - startupTimeoutMs: number; - nodecgKillTimeoutMs: number; -}; +import { getRuntimeConfig } from "./config/runtime-config"; +import { showFatalError, log } from "./errors/error-presenter"; +import { createNodecgProcessManager } from "./nodecg/process-manager"; +import { createLoadingWindow, createMainWindow } from "./windows/window-factory"; -const RUNTIME_NAME = "electron internal node"; -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 runtimeConfig = getRuntimeConfig(); const isDev = !app.isPackaged; 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 baseUrl = `http://127.0.0.1:${runtimeConfig.nodecgPort}`; +const nodecgManager = createNodecgProcessManager({ + isDev, + nodecgPath, + baseUrl, + runtimeConfig, + log, +}); + let mainWindow: BrowserWindow | null = null; let loadingWindow: BrowserWindow | null = null; -let nodecgProcess: ChildProcess | null = null; -let stopNodeCGPromise: Promise | null = null; 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 { - 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 { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - async function launch(): Promise { - mainWindow = createMainWindow(); - loadingWindow = createLoadingWindow(); + mainWindow = createMainWindow({ runtimeConfig, rootPath, dashboardUrl }); + loadingWindow = createLoadingWindow({ runtimeConfig, rootPath }); - nodecgProcess = startNodeCG(); + nodecgManager.startNodeCG(); - await waitForNodeCGReady(Date.now()); + await nodecgManager.waitForNodeCGReady(Date.now()); if (!loadingWindow || loadingWindow.isDestroyed()) { return; @@ -268,121 +59,10 @@ async function launch(): Promise { closeLoadingWindow(); } -function killNodeCGProcessTree(pid: number, signal: NodeJS.Signals): 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 stopNodeCG(): Promise { - 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)); +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); }); - - 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 { @@ -401,7 +81,7 @@ app.on("ready", () => { app.setAppUserModelId(runtimeConfig.userModelId); } - launch().catch(async (error: unknown) => { + launch().catch((error: unknown) => { showFatalError("No se pudo iniciar Scoreko.", error); closeLoadingWindow(); app.exit(1); @@ -410,7 +90,7 @@ app.on("ready", () => { app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createMainWindow(); + mainWindow = createMainWindow({ runtimeConfig, rootPath, dashboardUrl }); await mainWindow.loadURL(dashboardUrl); mainWindow.show(); } @@ -430,17 +110,17 @@ app.on("before-quit", (event) => { event.preventDefault(); isQuitting = true; - stopNodeCG().finally(() => { + nodecgManager.stopNodeCG().finally(() => { app.quit(); }); }); app.on("will-quit", () => { - stopNodeCG(); + void nodecgManager.stopNodeCG(); }); process.on("exit", () => { - stopNodeCG(); + void nodecgManager.stopNodeCG(); }); process.on("uncaughtException", (error) => { diff --git a/src/main/nodecg/process-manager.ts b/src/main/nodecg/process-manager.ts new file mode 100644 index 0000000..91e2db2 --- /dev/null +++ b/src/main/nodecg/process-manager.ts @@ -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; + stopNodeCG: () => Promise; + getProcess: () => ChildProcess | null; +}; + +export function createNodecgProcessManager({ + isDev, + nodecgPath, + baseUrl, + runtimeConfig, + log, +}: NodecgProcessManagerConfig): NodecgProcessManager { + let nodecgProcess: ChildProcess | null = null; + let stopNodeCGPromise: Promise | 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 => { + 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 => { + 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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/src/main/windows/window-factory.ts b/src/main/windows/window-factory.ts new file mode 100644 index 0000000..8741fbe --- /dev/null +++ b/src/main/windows/window-factory.ts @@ -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): 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)); +}