feat: Implement application bootstrap and window management

- Added bootstrap functionality to initialize the Electron application.
- Created a new paths module to manage application paths and URLs.
- Introduced a shutdown service to handle graceful application shutdowns.
- Refactored error logging to use a dedicated logger module.
- Implemented process killing logic for NodeCG processes across platforms.
- Established navigation policies for internal and external URL handling in windows.
- Developed window service for creating and managing application windows.
- Added tests for application paths, application controller, navigation policies, process killer, and shutdown service.
This commit is contained in:
2026-05-24 16:14:23 +02:00
parent c168c3b84a
commit e3d3936156
17 changed files with 1067 additions and 264 deletions
+90
View File
@@ -0,0 +1,90 @@
# Phase 1 Summary
## Scope
Executed the architecture base refactor only. The change keeps the Electron plus local NodeCG product model intact and does not add a renderer, preload, or IPC layer.
Documentation used as source of truth:
- `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`
## Completed
- Split the Electron entrypoint into a thin `src/main/main.ts` and explicit bootstrap logic in `src/main/app/bootstrap.ts`.
- Added `src/main/app/application-controller.ts` to own startup, activation, update scheduling, and shutdown coordination.
- Added `src/main/app/paths.ts` for pure root path, userData path, NodeCG runtime path, and dashboard URL construction.
- Added `src/main/app/shutdown-service.ts` so repeated shutdown requests reuse one stop operation.
- Renamed window ownership toward the target architecture:
- `src/main/windows/window-service.ts`
- `src/main/windows/navigation-policy.ts`
- Moved logging to `src/main/logging/logger.ts`.
- Extracted process-tree termination to `src/main/nodecg/platform-process-killer.ts`.
- Normalized imports away from old `window-factory`, `navigation-security`, and `errors/logger` paths.
- Made BrowserWindow security settings explicit:
- `nodeIntegration: false`
- `contextIsolation: true`
- `sandbox: true`
- `webSecurity: true`
- devtools controlled by development mode
- permissions denied by default
- Added architecture-base tests for:
- path and URL helpers
- application startup ordering
- packaged relaunch after runtime installation
- activation before readiness
- shutdown idempotency
- platform process killing
## Intentionally Not Changed
- No UX changes.
- No custom renderer.
- No preload script.
- No IPC layer.
- No packaging changes.
- No update-manager rewrite.
- No complex NodeCG lifecycle rewrite.
- No change to the managed runtime location under Electron `userData`.
- No change to preservation of `cfg`, `db`, and `logs`.
- No change to launching NodeCG with `ELECTRON_RUN_AS_NODE`.
## Compatibility Notes
- Startup still prepares the managed NodeCG runtime before launching NodeCG.
- Packaged first-run runtime installation still relaunches before NodeCG starts.
- Loading and main windows are still created before NodeCG readiness so the startup experience remains equivalent.
- Dashboard loading remains gated behind NodeCG readiness.
- Update checks are still scheduled only after the main window is shown.
- Shutdown remains idempotent and still stops the NodeCG process tree.
- macOS-style activation now routes through the controller so dashboard loading cannot bypass readiness.
## Verification
Commands run successfully:
```text
npm run typecheck
npm test
npm run lint
```
Current test result:
```text
52 tests passing
```
Import/security sanity search:
```text
rg -n "navigation-security|window-factory|errors/logger|preload|ipcRenderer|ipcMain|nodeIntegration:\s*true|webSecurity:\s*false|any\b" src docs/refactor
```
Result:
- No legacy imports or unsafe Electron settings remain in `src`.
- Remaining matches are source-of-truth documentation references only.
+234
View File
@@ -0,0 +1,234 @@
import { AppRuntimeConfig } from "../config/runtime-config";
import { NodecgProcessManager } from "../nodecg/process-manager";
import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner";
import { getRemainingDelayMs } from "../utils/timing";
import { createShutdownService, ShutdownService } from "./shutdown-service";
export type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed";
export type ApplicationWindow = {
close: () => void;
focus: () => void;
isDestroyed: () => boolean;
isMinimized: () => boolean;
loadURL: (url: string) => Promise<unknown>;
restore: () => void;
show: () => void;
};
export type ApplicationControllerConfig = {
appConfig: AppRuntimeConfig;
appVersion: string;
isPackaged: boolean;
isWindows: boolean;
paths: {
rootPath: string;
sourceNodecgRuntimePath: string;
userDataPath: string;
nodecgBaseUrl: string;
mainDashboardUrl: string;
loadingDashboardUrl: string;
};
deps: {
createLoadingWindow: () => ApplicationWindow;
createMainWindow: () => ApplicationWindow;
createNodecgProcessManager: (runtimePath: string) => NodecgProcessManager;
getAllWindows: () => ApplicationWindow[];
log: (...args: unknown[]) => void;
prepareRuntime: (config: {
sourceRuntimePath: string;
userDataPath: string;
appVersion: string;
bundleName: string;
log: (...args: unknown[]) => void;
}) => PreparedNodecgRuntime;
relaunch: () => void;
scheduleUpdateCheck: (config: {
getParentWindow: () => ApplicationWindow | null;
beforeInstall: () => Promise<void>;
}) => void;
setAppUserModelId: (userModelId: string) => void;
exit: (code: number) => void;
now?: () => number;
sleep?: (ms: number) => Promise<void>;
};
};
export type ApplicationController = {
activate: () => Promise<void>;
focusExistingWindow: () => void;
getState: () => ApplicationState;
launch: () => Promise<void>;
stopNodecgGracefully: () => Promise<void>;
};
export function createApplicationController({
appConfig,
appVersion,
deps,
isPackaged,
isWindows,
paths,
}: ApplicationControllerConfig): ApplicationController {
let state: ApplicationState = "idle";
let mainWindow: ApplicationWindow | null = null;
let loadingWindow: ApplicationWindow | null = null;
let nodecgManager: NodecgProcessManager | null = null;
let launchPromise: Promise<void> | null = null;
const shutdownService: ShutdownService = createShutdownService(async () => {
await (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve());
});
const now = deps.now ?? Date.now;
const sleep = deps.sleep ?? defaultSleep;
const closeLoadingWindow = (): void => {
if (!loadingWindow || loadingWindow.isDestroyed()) {
return;
}
loadingWindow.close();
loadingWindow = null;
};
const focusExistingWindow = (): void => {
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow;
if (!targetWindow || targetWindow.isDestroyed()) {
return;
}
if (targetWindow.isMinimized()) {
targetWindow.restore();
}
targetWindow.show();
targetWindow.focus();
};
const startNodecg = async (): Promise<void> => {
if (!nodecgManager) {
throw new Error("NodeCG process manager is not initialized.");
}
await nodecgManager.startNodecgProcess();
await nodecgManager.waitForNodecgReady(now());
};
const launch = async (): Promise<void> => {
if (launchPromise) {
return launchPromise;
}
launchPromise = (async () => {
if (isWindows) {
deps.setAppUserModelId(appConfig.userModelId);
}
state = "preparing";
const preparedRuntime = deps.prepareRuntime({
sourceRuntimePath: paths.sourceNodecgRuntimePath,
userDataPath: paths.userDataPath,
appVersion,
bundleName: appConfig.bundleName,
log: deps.log,
});
if (preparedRuntime.installed && isPackaged) {
deps.log("Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.");
deps.relaunch();
deps.exit(0);
state = "stopped";
return;
}
nodecgManager = deps.createNodecgProcessManager(preparedRuntime.runtimePath);
mainWindow = deps.createMainWindow();
loadingWindow = deps.createLoadingWindow();
state = "starting";
await startNodecg();
if (!loadingWindow || loadingWindow.isDestroyed()) {
state = "ready";
return;
}
await loadingWindow.loadURL(paths.loadingDashboardUrl);
loadingWindow.show();
const loadingShownAt = now();
if (!mainWindow) {
state = "ready";
return;
}
await mainWindow.loadURL(paths.mainDashboardUrl);
const remainingLoadingDelay = getRemainingDelayMs(appConfig.loadDelayMs, loadingShownAt, now());
if (remainingLoadingDelay > 0) {
await sleep(remainingLoadingDelay);
}
mainWindow.show();
closeLoadingWindow();
deps.scheduleUpdateCheck({
getParentWindow: () => mainWindow,
beforeInstall: stopNodecgGracefully,
});
state = "ready";
})();
try {
await launchPromise;
} catch (error) {
state = "failed";
launchPromise = null;
closeLoadingWindow();
throw error;
}
};
const activate = async (): Promise<void> => {
if (deps.getAllWindows().length > 0) {
focusExistingWindow();
return;
}
if (state !== "ready") {
await launch();
return;
}
mainWindow = deps.createMainWindow();
await mainWindow.loadURL(paths.mainDashboardUrl);
mainWindow.show();
};
const stopNodecgGracefully = async (): Promise<void> => {
if (shutdownService.getState() === "running") {
state = "stopping";
}
await shutdownService.stop();
state = "stopped";
};
return {
activate,
focusExistingWindow,
getState: () => state,
launch,
stopNodecgGracefully,
};
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
+136
View File
@@ -0,0 +1,136 @@
import { app, BrowserWindow } from "electron";
import { getRuntimeConfig } from "../config/runtime-config";
import { showFatalError, log } from "../errors/error-presenter";
import { createNodecgProcessManager } from "../nodecg/process-manager";
import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner";
import { scheduleUpdateCheck } from "../updates/update-manager";
import { createLoadingWindow, createMainWindow } from "../windows/window-service";
import { createApplicationController } from "./application-controller";
import { getApplicationPaths } from "./paths";
export function bootstrap(): void {
const appConfig = getRuntimeConfig();
const isDev = !app.isPackaged;
const paths = getApplicationPaths({
appConfig,
appDataPath: app.getPath("appData"),
compiledMainDir: __dirname,
isDev,
resourcesPath: process.resourcesPath,
});
app.setName(appConfig.title);
app.setPath("userData", paths.userDataPath);
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.quit();
}
const controller = createApplicationController({
appConfig,
appVersion: app.getVersion(),
isPackaged: app.isPackaged,
isWindows: process.platform === "win32",
paths,
deps: {
createLoadingWindow: () =>
createLoadingWindow({
allowDevTools: isDev,
appConfig,
rootPath: paths.rootPath,
}),
createMainWindow: () =>
createMainWindow({
allowDevTools: isDev,
appConfig,
rootPath: paths.rootPath,
mainDashboardUrl: paths.mainDashboardUrl,
}),
createNodecgProcessManager: (runtimePath) =>
createNodecgProcessManager({
isDev,
nodecgRootPath: runtimePath,
nodecgBaseUrl: paths.nodecgBaseUrl,
appConfig,
log,
}),
getAllWindows: () => BrowserWindow.getAllWindows(),
log,
prepareRuntime: prepareUserNodecgRuntime,
relaunch: () => app.relaunch(),
scheduleUpdateCheck: ({ getParentWindow, beforeInstall }) => {
scheduleUpdateCheck({
appConfig,
rootPath: paths.rootPath,
getParentWindow: () => getParentWindow() as BrowserWindow | null,
beforeInstall,
log,
});
},
setAppUserModelId: (userModelId) => app.setAppUserModelId(userModelId),
exit: (code) => app.exit(code),
},
});
app.on("ready", () => {
if (!hasSingleInstanceLock) {
return;
}
controller.launch().catch((error: unknown) => {
showFatalError("No se pudo iniciar Scoreko.", error);
app.exit(1);
});
});
app.on("second-instance", () => {
controller.focusExistingWindow();
});
app.on("activate", () => {
controller.activate().catch((error: unknown) => {
showFatalError("No se pudo reactivar Scoreko.", error);
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", (event) => {
if (controller.getState() === "stopping" || controller.getState() === "stopped") {
return;
}
event.preventDefault();
controller.stopNodecgGracefully().finally(() => {
app.quit();
});
});
app.on("will-quit", () => {
if (controller.getState() !== "stopping" && controller.getState() !== "stopped") {
void controller.stopNodecgGracefully();
}
});
process.on("exit", () => {
if (controller.getState() !== "stopping" && controller.getState() !== "stopped") {
void controller.stopNodecgGracefully();
}
});
process.on("uncaughtException", (error) => {
showFatalError("Unexpected error in Electron main process.", error);
});
process.on("unhandledRejection", (reason) => {
showFatalError("Unhandled promise in Electron main process.", reason);
});
}
+60
View File
@@ -0,0 +1,60 @@
import path from "node:path";
import { AppRuntimeConfig } from "../config/runtime-config";
export type ApplicationPaths = {
rootPath: string;
sourceNodecgRuntimePath: string;
userDataPath: string;
nodecgBaseUrl: string;
mainDashboardUrl: string;
loadingDashboardUrl: string;
};
export function getRootPath(isDev: boolean, compiledMainDir: string, resourcesPath: string): string {
return isDev ? path.resolve(compiledMainDir, "../..") : resourcesPath;
}
export function getUserDataPath(appDataPath: string, userDataDirectoryName: string): string {
return path.join(appDataPath, userDataDirectoryName);
}
export function getSourceNodecgRuntimePath(rootPath: string): string {
return path.resolve(rootPath, "lib", "nodecg");
}
export function getNodecgBaseUrl(nodecgPort: string): string {
return `http://127.0.0.1:${nodecgPort}`;
}
export function getDashboardUrl(nodecgPort: string, bundleName: string, dashboardRoute: string): string {
return `http://localhost:${nodecgPort}/bundles/${bundleName}/${dashboardRoute}`;
}
export function getApplicationPaths({
appConfig,
appDataPath,
compiledMainDir,
isDev,
resourcesPath,
}: {
appConfig: Pick<
AppRuntimeConfig,
"bundleName" | "loadingDashboardRoute" | "mainDashboardRoute" | "nodecgPort" | "userDataDirectoryName"
>;
appDataPath: string;
compiledMainDir: string;
isDev: boolean;
resourcesPath: string;
}): ApplicationPaths {
const rootPath = getRootPath(isDev, compiledMainDir, resourcesPath);
return {
rootPath,
sourceNodecgRuntimePath: getSourceNodecgRuntimePath(rootPath),
userDataPath: getUserDataPath(appDataPath, appConfig.userDataDirectoryName),
nodecgBaseUrl: getNodecgBaseUrl(appConfig.nodecgPort),
mainDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.mainDashboardRoute),
loadingDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.loadingDashboardRoute),
};
}
+32
View File
@@ -0,0 +1,32 @@
export type AppShutdownState = "running" | "stopping" | "stopped";
export type ShutdownService = {
getState: () => AppShutdownState;
stop: () => Promise<void>;
};
export function createShutdownService(stopRuntime: () => Promise<void>): ShutdownService {
let state: AppShutdownState = "running";
let stopPromise: Promise<void> | null = null;
return {
getState: () => state,
stop: () => {
if (state === "stopped") {
return Promise.resolve();
}
if (stopPromise) {
return stopPromise;
}
state = "stopping";
stopPromise = stopRuntime().finally(() => {
state = "stopped";
stopPromise = null;
});
return stopPromise;
},
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { app, dialog } from "electron"; import { app, dialog } from "electron";
import { logger } from "./logger"; import { logger } from "../logging/logger";
export function log(...args: unknown[]): void { export function log(...args: unknown[]): void {
logger.info("runtime", { args }); logger.info("runtime", { args });
+2 -219
View File
@@ -1,220 +1,3 @@
import { app, BrowserWindow } from "electron"; import { bootstrap } from "./app/bootstrap";
import path from "node:path";
import { getRuntimeConfig } from "./config/runtime-config"; bootstrap();
import { showFatalError, log } from "./errors/error-presenter";
import { createNodecgProcessManager, NodecgProcessManager } from "./nodecg/process-manager";
import { prepareUserNodecgRuntime } from "./nodecg/runtime-provisioner";
import { scheduleUpdateCheck } from "./updates/update-manager";
import { getRemainingDelayMs } from "./utils/timing";
import { createLoadingWindow, createMainWindow } from "./windows/window-factory";
const appConfig = getRuntimeConfig();
// Force a stable userData folder name; overridable via SCOREKO_APP_USER_DATA_DIRECTORY.
app.setName(appConfig.title);
app.setPath("userData", path.join(app.getPath("appData"), appConfig.userDataDirectoryName));
const isDev = !app.isPackaged;
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath;
const sourceNodecgRuntimePath = path.resolve(rootPath, "lib", "nodecg");
const mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`;
const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`;
const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`;
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.quit();
}
type AppShutdownState = "running" | "stopping" | "stopped";
let mainWindow: BrowserWindow | null = null;
let loadingWindow: BrowserWindow | null = null;
let nodecgManager: NodecgProcessManager | null = null;
let shutdownState: AppShutdownState = "running";
function focusExistingWindow(): void {
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow;
if (!targetWindow || targetWindow.isDestroyed()) {
return;
}
if (targetWindow.isMinimized()) {
targetWindow.restore();
}
targetWindow.show();
targetWindow.focus();
}
async function launchApplication(): Promise<void> {
const preparedRuntime = prepareUserNodecgRuntime({
sourceRuntimePath: sourceNodecgRuntimePath,
userDataPath: app.getPath("userData"),
appVersion: app.getVersion(),
bundleName: appConfig.bundleName,
log,
});
if (preparedRuntime.installed && app.isPackaged) {
log("Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.");
app.relaunch();
app.exit(0);
return;
}
nodecgManager = createNodecgProcessManager({
isDev,
nodecgRootPath: preparedRuntime.runtimePath,
nodecgBaseUrl,
appConfig,
log,
});
// We create both windows early so startup feels instant while NodeCG is booting in the background.
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
loadingWindow = createLoadingWindow({ appConfig, rootPath });
await startNodecg();
if (!loadingWindow || loadingWindow.isDestroyed()) {
return;
}
await loadingWindow.loadURL(loadingDashboardUrl);
loadingWindow.show();
const loadingShownAt = Date.now();
if (!mainWindow) {
return;
}
await mainWindow.loadURL(mainDashboardUrl);
// Keep the loading overlay visible for a minimum amount of time to avoid abrupt flashes.
const remainingLoadingDelay = getRemainingDelayMs(appConfig.loadDelayMs, loadingShownAt);
if (remainingLoadingDelay > 0) {
await sleep(remainingLoadingDelay);
}
mainWindow.show();
closeLoadingWindow();
scheduleUpdateCheck({
appConfig,
rootPath,
getParentWindow: () => mainWindow,
beforeInstall: stopNodecgGracefully,
log,
});
}
async function startNodecg(): Promise<void> {
if (!nodecgManager) {
throw new Error("NodeCG process manager is not initialized.");
}
await nodecgManager.startNodecgProcess();
await nodecgManager.waitForNodecgReady(Date.now());
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function closeLoadingWindow(): void {
if (!loadingWindow || loadingWindow.isDestroyed()) {
return;
}
loadingWindow.close();
loadingWindow = null;
}
function stopNodecgGracefully(): Promise<void> {
if (shutdownState === "stopped") {
return Promise.resolve();
}
if (shutdownState === "stopping") {
return nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve();
}
shutdownState = "stopping";
return (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve()).finally(() => {
shutdownState = "stopped";
});
}
app.on("ready", () => {
if (!hasSingleInstanceLock) {
return;
}
if (process.platform === "win32") {
app.setAppUserModelId(appConfig.userModelId);
}
launchApplication().catch((error: unknown) => {
showFatalError("No se pudo iniciar Scoreko.", error);
closeLoadingWindow();
app.exit(1);
});
});
app.on("second-instance", () => {
focusExistingWindow();
});
app.on("activate", async () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
await mainWindow.loadURL(mainDashboardUrl);
mainWindow.show();
}
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", (event) => {
if (shutdownState !== "running") {
return;
}
// Block the default quit flow until we ask NodeCG to stop cleanly.
event.preventDefault();
stopNodecgGracefully().finally(() => {
app.quit();
});
});
app.on("will-quit", () => {
if (shutdownState === "running") {
void stopNodecgGracefully();
}
});
process.on("exit", () => {
if (shutdownState === "running") {
void stopNodecgGracefully();
}
});
process.on("uncaughtException", (error) => {
showFatalError("Unexpected error in Electron main process.", error);
});
process.on("unhandledRejection", (reason) => {
showFatalError("Unhandled promise in Electron main process.", reason);
});
@@ -0,0 +1,54 @@
import { ChildProcess, SpawnOptions } from "node:child_process";
export type PlatformProcessKillerDeps = {
platform: NodeJS.Platform;
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
killProcess: (pid: number, signal: NodeJS.Signals) => void;
log: (...args: unknown[]) => void;
};
export function killProcessTree(pid: number, signal: NodeJS.Signals, deps: PlatformProcessKillerDeps): boolean {
if (!Number.isSafeInteger(pid) || pid <= 0) {
deps.log(`Invalid pid for process tree termination: ${pid}`);
return false;
}
if (deps.platform === "win32") {
return killWindowsProcessTree(pid, signal, deps);
}
return killPosixProcessTree(pid, signal, deps.killProcess);
}
function killWindowsProcessTree(
pid: number,
signal: NodeJS.Signals,
deps: Pick<PlatformProcessKillerDeps, "spawnProcess" | "log">,
): boolean {
const args = ["/pid", String(pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])];
const killer = deps.spawnProcess("taskkill", args, {
stdio: "ignore",
shell: false,
windowsHide: true,
});
killer.on("error", (error) => {
deps.log(`taskkill error for pid=${pid}`, error);
});
return true;
}
function killPosixProcessTree(pid: number, signal: NodeJS.Signals, killProcess: PlatformProcessKillerDeps["killProcess"]): boolean {
try {
killProcess(-pid, signal);
return true;
} catch {
try {
killProcess(pid, signal);
return true;
} catch {
return false;
}
}
}
+13 -35
View File
@@ -5,6 +5,7 @@ import path from "node:path";
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { NODE_RUNTIME_NAME } from "../constants"; import { NODE_RUNTIME_NAME } from "../constants";
import { killProcessTree } from "./platform-process-killer";
type NodecgProcessManagerConfig = { type NodecgProcessManagerConfig = {
isDev: boolean; isDev: boolean;
@@ -161,7 +162,12 @@ export function createNodecgProcessManager({
} }
log(`Stopping NodeCG pid=${pid}`); log(`Stopping NodeCG pid=${pid}`);
killNodecgProcessTree(pid, "SIGTERM", log, resolvedDeps); killProcessTree(pid, "SIGTERM", {
platform: resolvedDeps.platform,
spawnProcess: resolvedDeps.spawnProcess,
killProcess: resolvedDeps.killProcess,
log,
});
stopNodecgPromise = new Promise((resolve) => { stopNodecgPromise = new Promise((resolve) => {
let completed = false; let completed = false;
@@ -189,7 +195,12 @@ export function createNodecgProcessManager({
() => { () => {
if (processToStop.exitCode === null && processToStop.signalCode === null) { if (processToStop.exitCode === null && processToStop.signalCode === null) {
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`); log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps); killProcessTree(pid, "SIGKILL", {
platform: resolvedDeps.platform,
spawnProcess: resolvedDeps.spawnProcess,
killProcess: resolvedDeps.killProcess,
log,
});
complete(); complete();
} }
}, },
@@ -315,39 +326,6 @@ function probePortAvailable(port: number): Promise<boolean> {
}); });
} }
function killNodecgProcessTree(
pid: number,
signal: NodeJS.Signals,
log: (...args: unknown[]) => void,
deps: Pick<NodecgProcessManagerDeps, "platform" | "spawnProcess" | "killProcess">,
): boolean {
if (deps.platform === "win32") {
const force = signal === "SIGKILL" ? "/F" : "";
const killer = deps.spawnProcess("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 {
deps.killProcess(-pid, signal);
return true;
} catch {
try {
deps.killProcess(pid, signal);
return true;
} catch {
return false;
}
}
}
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> { function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimer(resolve, ms); setTimer(resolve, ms);
@@ -1,19 +1,27 @@
import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron"; import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron";
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants"; import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
import { resolveAppIconPath } from "./icon-path"; import { resolveAppIconPath } from "./icon-path";
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-security"; import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-policy";
type WindowFactoryDependencies = { type WindowServiceDependencies = {
appConfig: AppRuntimeConfig; appConfig: AppRuntimeConfig;
allowDevTools: boolean;
rootPath: string; rootPath: string;
mainDashboardUrl: string; mainDashboardUrl: string;
}; };
export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: WindowFactoryDependencies): BrowserWindow { export function createMainWindow({
const windowOptions = createWindowOptions({ appConfig, rootPath, isLoadingWindow: false }); allowDevTools,
appConfig,
rootPath,
mainDashboardUrl,
}: WindowServiceDependencies): BrowserWindow {
const windowOptions = createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: false });
const window = new BrowserWindow(windowOptions); const window = new BrowserWindow(windowOptions);
denyPermissionsByDefault(window);
window.setMenuBarVisibility(false); window.setMenuBarVisibility(false);
window.webContents.setWindowOpenHandler(({ url }) => { window.webContents.setWindowOpenHandler(({ url }) => {
@@ -44,10 +52,13 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind
} }
export function createLoadingWindow({ export function createLoadingWindow({
allowDevTools,
appConfig, appConfig,
rootPath, rootPath,
}: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow { }: Omit<WindowServiceDependencies, "mainDashboardUrl">): BrowserWindow {
const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true })); const window = new BrowserWindow(createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: true }));
denyPermissionsByDefault(window);
window.on("page-title-updated", (event) => { window.on("page-title-updated", (event) => {
event.preventDefault(); event.preventDefault();
@@ -56,11 +67,13 @@ export function createLoadingWindow({
return window; return window;
} }
function createWindowOptions({ export function createWindowOptions({
allowDevTools,
appConfig, appConfig,
rootPath, rootPath,
isLoadingWindow, isLoadingWindow,
}: { }: {
allowDevTools: boolean;
appConfig: AppRuntimeConfig; appConfig: AppRuntimeConfig;
rootPath: string; rootPath: string;
isLoadingWindow: boolean; isLoadingWindow: boolean;
@@ -74,8 +87,10 @@ function createWindowOptions({
backgroundColor: DEFAULT_WINDOW_BACKGROUND, backgroundColor: DEFAULT_WINDOW_BACKGROUND,
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
devTools: allowDevTools,
nodeIntegration: false,
sandbox: true, sandbox: true,
...(isLoadingWindow ? {} : { nodeIntegration: false }), webSecurity: true,
}, },
}; };
@@ -100,3 +115,9 @@ function createWindowOptions({
minHeight: DEFAULT_WINDOW_SIZE.minHeight, minHeight: DEFAULT_WINDOW_SIZE.minHeight,
}; };
} }
function denyPermissionsByDefault(window: BrowserWindow): void {
window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false);
});
}
+47
View File
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import {
getApplicationPaths,
getDashboardUrl,
getNodecgBaseUrl,
getRootPath,
getSourceNodecgRuntimePath,
getUserDataPath,
} from "../main/app/paths";
test("app path helpers build deterministic development paths and URLs", () => {
const compiledMainDir = path.join("repo", "dist", "main");
const rootPath = getRootPath(true, compiledMainDir, "/resources");
assert.equal(rootPath, path.resolve(compiledMainDir, "../.."));
assert.equal(getSourceNodecgRuntimePath(rootPath), path.resolve(rootPath, "lib", "nodecg"));
assert.equal(getUserDataPath("/app-data", "scoreko"), path.join("/app-data", "scoreko"));
assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090");
assert.equal(
getDashboardUrl("9090", "scoreko-dev", "dashboard/main.html?standalone=true"),
"http://localhost:9090/bundles/scoreko-dev/dashboard/main.html?standalone=true",
);
});
test("getApplicationPaths keeps packaged root under Electron resources", () => {
const paths = getApplicationPaths({
appConfig: {
userDataDirectoryName: "scoreko",
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
},
appDataPath: "/users/test/AppData/Roaming",
compiledMainDir: "/app/dist/main",
isDev: false,
resourcesPath: "/opt/Scoreko/resources",
});
assert.equal(paths.rootPath, "/opt/Scoreko/resources");
assert.equal(paths.sourceNodecgRuntimePath, path.resolve("/opt/Scoreko/resources", "lib", "nodecg"));
assert.equal(paths.userDataPath, path.join("/users/test/AppData/Roaming", "scoreko"));
assert.equal(paths.nodecgBaseUrl, "http://127.0.0.1:9090");
});
+271
View File
@@ -0,0 +1,271 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import test from "node:test";
import { createApplicationController, ApplicationWindow } from "../main/app/application-controller";
import { AppRuntimeConfig } from "../main/config/runtime-config";
import { NodecgProcessManager } from "../main/nodecg/process-manager";
class MockWindow implements ApplicationWindow {
private destroyed = false;
private minimized = false;
constructor(
private readonly name: string,
private readonly events: string[],
) {}
close(): void {
this.events.push(`${this.name}:close`);
this.destroyed = true;
}
focus(): void {
this.events.push(`${this.name}:focus`);
}
isDestroyed(): boolean {
return this.destroyed;
}
isMinimized(): boolean {
return this.minimized;
}
async loadURL(url: string): Promise<void> {
this.events.push(`${this.name}:load:${url}`);
}
restore(): void {
this.events.push(`${this.name}:restore`);
this.minimized = false;
}
show(): void {
this.events.push(`${this.name}:show`);
}
}
function getBaseConfig(): AppRuntimeConfig {
return {
title: "Scoreko",
userModelId: "com.scoreko.desktop",
userDataDirectoryName: "scoreko",
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 0,
startupTimeoutMs: 100,
nodecgKillTimeoutMs: 10,
updatesEnabled: true,
updateAssetPattern: "Scoreko-setup-.*\\.exe$",
updateCheckDelayMs: 5000,
};
}
function createMockManager(events: string[]): NodecgProcessManager {
return {
startNodecgProcess: async () => {
events.push("start-nodecg");
return new EventEmitter() as import("node:child_process").ChildProcess;
},
waitForNodecgReady: async () => {
events.push("wait-nodecg");
},
stopNodecgProcessGracefully: async () => {
events.push("stop-nodecg");
},
getProcess: () => null,
};
}
test("ApplicationController preserves startup ordering and schedules updates after main window is shown", async () => {
const events: string[] = [];
const paths = {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html?standalone=true",
loadingDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/loading/main.html?standalone=true",
};
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: false,
isWindows: true,
paths,
deps: {
createLoadingWindow: () => {
events.push("create-loading");
return new MockWindow("loading", events);
},
createMainWindow: () => {
events.push("create-main");
return new MockWindow("main", events);
},
createNodecgProcessManager: () => {
events.push("create-manager");
return createMockManager(events);
},
getAllWindows: () => [],
log: () => undefined,
prepareRuntime: () => {
events.push("prepare-runtime");
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
},
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
now: () => 0,
sleep: async (ms) => {
events.push(`sleep:${ms}`);
},
},
});
await controller.launch();
assert.equal(controller.getState(), "ready");
assert.deepEqual(events, [
"set-app-user-model-id",
"prepare-runtime",
"create-manager",
"create-main",
"create-loading",
"start-nodecg",
"wait-nodecg",
`loading:load:${paths.loadingDashboardUrl}`,
"loading:show",
`main:load:${paths.mainDashboardUrl}`,
"main:show",
"loading:close",
"schedule-update",
]);
});
test("ApplicationController relaunches packaged app after runtime install before starting NodeCG", async () => {
const events: string[] = [];
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: true,
isWindows: false,
paths: {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading",
},
deps: {
createLoadingWindow: () => {
throw new Error("window creation should wait until after relaunch decisions");
},
createMainWindow: () => {
throw new Error("window creation should wait until after relaunch decisions");
},
createNodecgProcessManager: () => {
throw new Error("NodeCG should not start before relaunch");
},
getAllWindows: () => [],
log: (...args) => events.push(String(args[0])),
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }),
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
},
});
await controller.launch();
assert.equal(controller.getState(), "stopped");
assert.deepEqual(events, [
"Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.",
"relaunch",
"exit:0",
]);
});
test("ApplicationController activation before readiness routes through launch", async () => {
const events: string[] = [];
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: false,
isWindows: false,
paths: {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading",
},
deps: {
createLoadingWindow: () => new MockWindow("loading", events),
createMainWindow: () => new MockWindow("main", events),
createNodecgProcessManager: () => createMockManager(events),
getAllWindows: () => [],
log: () => undefined,
prepareRuntime: () => {
events.push("prepare-runtime");
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
},
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
now: () => 0,
},
});
await controller.activate();
assert.equal(controller.getState(), "ready");
assert.ok(events.includes("prepare-runtime"));
assert.ok(events.includes("start-nodecg"));
assert.ok(events.includes("wait-nodecg"));
});
test("ApplicationController shutdown is idempotent", async () => {
const events: string[] = [];
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: false,
isWindows: false,
paths: {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading",
},
deps: {
createLoadingWindow: () => new MockWindow("loading", events),
createMainWindow: () => new MockWindow("main", events),
createNodecgProcessManager: () => createMockManager(events),
getAllWindows: () => [],
log: () => undefined,
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: false }),
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
now: () => 0,
},
});
await controller.launch();
await Promise.all([controller.stopNodecgGracefully(), controller.stopNodecgGracefully()]);
assert.equal(controller.getState(), "stopped");
assert.equal(events.filter((event) => event === "stop-nodecg").length, 1);
});
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation-security"; import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation-policy";
const dashboardUrl = "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html"; const dashboardUrl = "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html";
+65
View File
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { SpawnOptions } from "node:child_process";
import test from "node:test";
import { killProcessTree } from "../main/nodecg/platform-process-killer";
test("killProcessTree validates pid before building Windows taskkill command", () => {
const spawnCalls: Array<{ command: string; args: string[]; options: SpawnOptions }> = [];
const killed = killProcessTree(Number.NaN, "SIGTERM", {
platform: "win32",
spawnProcess: (command, args, options) => {
spawnCalls.push({ command, args, options });
return new EventEmitter() as import("node:child_process").ChildProcess;
},
killProcess: () => undefined,
log: () => undefined,
});
assert.equal(killed, false);
assert.deepEqual(spawnCalls, []);
});
test("killProcessTree builds a narrow Windows taskkill invocation", () => {
const spawnCalls: Array<{ command: string; args: string[]; options: SpawnOptions }> = [];
const killed = killProcessTree(1234, "SIGKILL", {
platform: "win32",
spawnProcess: (command, args, options) => {
spawnCalls.push({ command, args, options });
return new EventEmitter() as import("node:child_process").ChildProcess;
},
killProcess: () => undefined,
log: () => undefined,
});
assert.equal(killed, true);
assert.equal(spawnCalls[0]?.command, "taskkill");
assert.deepEqual(spawnCalls[0]?.args, ["/pid", "1234", "/T", "/F"]);
assert.equal(spawnCalls[0]?.options.shell, false);
assert.equal(spawnCalls[0]?.options.windowsHide, true);
});
test("killProcessTree falls back from POSIX process group to child pid", () => {
const killCalls: Array<{ pid: number; signal: NodeJS.Signals }> = [];
const killed = killProcessTree(1234, "SIGTERM", {
platform: "linux",
spawnProcess: () => new EventEmitter() as import("node:child_process").ChildProcess,
killProcess: (pid, signal) => {
killCalls.push({ pid, signal });
if (pid < 0) {
throw new Error("process group unavailable");
}
},
log: () => undefined,
});
assert.equal(killed, true);
assert.deepEqual(killCalls, [
{ pid: -1234, signal: "SIGTERM" },
{ pid: 1234, signal: "SIGTERM" },
]);
});
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createShutdownService } from "../main/app/shutdown-service";
test("shutdown service reuses the same stop promise while stopping", async () => {
let stopCalls = 0;
let releaseStop: () => void = () => {
throw new Error("stop promise was not created");
};
const service = createShutdownService(
() =>
new Promise<void>((resolve) => {
stopCalls += 1;
releaseStop = resolve;
}),
);
const first = service.stop();
const second = service.stop();
assert.equal(first, second);
assert.equal(stopCalls, 1);
assert.equal(service.getState(), "stopping");
releaseStop();
await first;
assert.equal(service.getState(), "stopped");
await service.stop();
assert.equal(stopCalls, 1);
});