mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
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:
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
import { app, dialog } from "electron";
|
||||
|
||||
import { logger } from "./logger";
|
||||
import { logger } from "../logging/logger";
|
||||
|
||||
export function log(...args: unknown[]): void {
|
||||
logger.info("runtime", { args });
|
||||
|
||||
+2
-219
@@ -1,220 +1,3 @@
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import path from "node:path";
|
||||
import { bootstrap } from "./app/bootstrap";
|
||||
|
||||
import { getRuntimeConfig } from "./config/runtime-config";
|
||||
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);
|
||||
});
|
||||
bootstrap();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
|
||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { NODE_RUNTIME_NAME } from "../constants";
|
||||
import { killProcessTree } from "./platform-process-killer";
|
||||
|
||||
type NodecgProcessManagerConfig = {
|
||||
isDev: boolean;
|
||||
@@ -161,7 +162,12 @@ export function createNodecgProcessManager({
|
||||
}
|
||||
|
||||
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) => {
|
||||
let completed = false;
|
||||
@@ -189,7 +195,12 @@ export function createNodecgProcessManager({
|
||||
() => {
|
||||
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
||||
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();
|
||||
}
|
||||
},
|
||||
@@ -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> {
|
||||
return new Promise((resolve) => {
|
||||
setTimer(resolve, ms);
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron";
|
||||
|
||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
|
||||
import { resolveAppIconPath } from "./icon-path";
|
||||
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-security";
|
||||
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-policy";
|
||||
|
||||
type WindowFactoryDependencies = {
|
||||
type WindowServiceDependencies = {
|
||||
appConfig: AppRuntimeConfig;
|
||||
allowDevTools: boolean;
|
||||
rootPath: string;
|
||||
mainDashboardUrl: string;
|
||||
};
|
||||
|
||||
export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: WindowFactoryDependencies): BrowserWindow {
|
||||
const windowOptions = createWindowOptions({ appConfig, rootPath, isLoadingWindow: false });
|
||||
export function createMainWindow({
|
||||
allowDevTools,
|
||||
appConfig,
|
||||
rootPath,
|
||||
mainDashboardUrl,
|
||||
}: WindowServiceDependencies): BrowserWindow {
|
||||
const windowOptions = createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: false });
|
||||
const window = new BrowserWindow(windowOptions);
|
||||
|
||||
denyPermissionsByDefault(window);
|
||||
window.setMenuBarVisibility(false);
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
@@ -44,10 +52,13 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind
|
||||
}
|
||||
|
||||
export function createLoadingWindow({
|
||||
allowDevTools,
|
||||
appConfig,
|
||||
rootPath,
|
||||
}: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
|
||||
const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true }));
|
||||
}: Omit<WindowServiceDependencies, "mainDashboardUrl">): BrowserWindow {
|
||||
const window = new BrowserWindow(createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: true }));
|
||||
|
||||
denyPermissionsByDefault(window);
|
||||
|
||||
window.on("page-title-updated", (event) => {
|
||||
event.preventDefault();
|
||||
@@ -56,11 +67,13 @@ export function createLoadingWindow({
|
||||
return window;
|
||||
}
|
||||
|
||||
function createWindowOptions({
|
||||
export function createWindowOptions({
|
||||
allowDevTools,
|
||||
appConfig,
|
||||
rootPath,
|
||||
isLoadingWindow,
|
||||
}: {
|
||||
allowDevTools: boolean;
|
||||
appConfig: AppRuntimeConfig;
|
||||
rootPath: string;
|
||||
isLoadingWindow: boolean;
|
||||
@@ -74,8 +87,10 @@ function createWindowOptions({
|
||||
backgroundColor: DEFAULT_WINDOW_BACKGROUND,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
devTools: allowDevTools,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
...(isLoadingWindow ? {} : { nodeIntegration: false }),
|
||||
webSecurity: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -100,3 +115,9 @@ function createWindowOptions({
|
||||
minHeight: DEFAULT_WINDOW_SIZE.minHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function denyPermissionsByDefault(window: BrowserWindow): void {
|
||||
window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
||||
callback(false);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 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";
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user