import { app, BrowserWindow, shell } from "electron"; import { fork, ChildProcess } from "node:child_process"; import path from "node:path"; import fs from "node:fs"; const APP_TITLE = "Scoreko"; const DEFAULT_NODECG_PORT = process.env.NODECG_PORT ?? "9090"; const DEFAULT_BUNDLE_NAME = process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev"; const DEFAULT_DASHBOARD_ROUTE = process.env.SCOREKO_DASHBOARD_ROUTE ?? "dashboard/index.html"; const LOAD_DELAY_MS = Number.parseInt(process.env.ELECTRON_LOAD_DELAY_MS ?? "5000", 10); const isDev = !app.isPackaged; const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath; const nodecgPath = path.resolve(rootPath, "lib", "nodecg"); const loadingPath = path.resolve(rootPath, "static", "loading.html"); const dashboardUrl = `http://localhost:${DEFAULT_NODECG_PORT}/bundles/${DEFAULT_BUNDLE_NAME}/${DEFAULT_DASHBOARD_ROUTE}`; let mainWindow: BrowserWindow | null = null; let loadingWindow: BrowserWindow | null = null; let nodecgProcess: ChildProcess | null = null; function createMainWindow(): BrowserWindow { const win = new BrowserWindow({ show: false, title: APP_TITLE, width: 1440, height: 900, minWidth: 960, minHeight: 640, backgroundColor: "#0f0f0f", webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false, }, }); win.setMenuBarVisibility(false); win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url).catch((error) => { log("Error opening external url", url, error); }); return { action: "deny" }; }); win.webContents.on("will-navigate", (event, url) => { if (url !== dashboardUrl) { event.preventDefault(); shell.openExternal(url).catch((error) => { log("Error opening navigation url", url, error); }); } }); win.on("page-title-updated", (event) => { event.preventDefault(); }); return win; } function createLoadingWindow(): BrowserWindow { const win = new BrowserWindow({ show: false, frame: false, title: APP_TITLE, width: 420, height: 280, resizable: false, movable: true, minimizable: false, maximizable: false, backgroundColor: "#0f0f0f", webPreferences: { contextIsolation: true, sandbox: true, }, }); win.on("page-title-updated", (event) => { event.preventDefault(); }); return win; } function startNodeCG(): ChildProcess { const indexPath = path.join(nodecgPath, "index.js"); if (!fs.existsSync(indexPath)) { throw new Error(`NodeCG entrypoint not found: ${indexPath}`); } const child = fork(indexPath, { cwd: nodecgPath, env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", NODE_ENV: isDev ? "development" : "production", NODECG_PORT: DEFAULT_NODECG_PORT, }, stdio: "inherit", }); log(`NodeCG started with pid=${child.pid}`); child.on("exit", (code, signal) => { log(`NodeCG exited code=${code} signal=${signal ?? "none"}`); nodecgProcess = null; }); return child; } async function launch(): Promise { mainWindow = createMainWindow(); loadingWindow = createLoadingWindow(); await loadingWindow.loadFile(loadingPath); loadingWindow.show(); nodecgProcess = startNodeCG(); setTimeout(async () => { if (!mainWindow) { return; } await mainWindow.loadURL(dashboardUrl); mainWindow.show(); if (loadingWindow && !loadingWindow.isDestroyed()) { loadingWindow.close(); loadingWindow = null; } }, LOAD_DELAY_MS); } function stopNodeCG(): void { if (!nodecgProcess || nodecgProcess.killed) { return; } log(`Stopping NodeCG pid=${nodecgProcess.pid}`); nodecgProcess.kill("SIGTERM"); } function log(...args: unknown[]): void { console.log("[scoreko-electron]", ...args); } app.on("ready", () => { launch().catch((error: unknown) => { console.error("Failed to launch Scoreko wrapper", error); app.exit(1); }); }); app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { mainWindow = createMainWindow(); await mainWindow.loadURL(dashboardUrl); mainWindow.show(); } }); app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } }); app.on("before-quit", () => { stopNodeCG(); }); process.on("exit", () => { stopNodeCG(); });