mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-05 21:22:07 +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 { 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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 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";
|
||||||
|
|
||||||
@@ -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