feat: Implement application bootstrap and window management

- Added bootstrap functionality to initialize the Electron application.
- Created a new paths module to manage application paths and URLs.
- Introduced a shutdown service to handle graceful application shutdowns.
- Refactored error logging to use a dedicated logger module.
- Implemented process killing logic for NodeCG processes across platforms.
- Established navigation policies for internal and external URL handling in windows.
- Developed window service for creating and managing application windows.
- Added tests for application paths, application controller, navigation policies, process killer, and shutdown service.
This commit is contained in:
2026-05-24 16:14:23 +02:00
parent c168c3b84a
commit e3d3936156
17 changed files with 1067 additions and 264 deletions
+271
View File
@@ -0,0 +1,271 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import test from "node:test";
import { createApplicationController, ApplicationWindow } from "../main/app/application-controller";
import { AppRuntimeConfig } from "../main/config/runtime-config";
import { NodecgProcessManager } from "../main/nodecg/process-manager";
class MockWindow implements ApplicationWindow {
private destroyed = false;
private minimized = false;
constructor(
private readonly name: string,
private readonly events: string[],
) {}
close(): void {
this.events.push(`${this.name}:close`);
this.destroyed = true;
}
focus(): void {
this.events.push(`${this.name}:focus`);
}
isDestroyed(): boolean {
return this.destroyed;
}
isMinimized(): boolean {
return this.minimized;
}
async loadURL(url: string): Promise<void> {
this.events.push(`${this.name}:load:${url}`);
}
restore(): void {
this.events.push(`${this.name}:restore`);
this.minimized = false;
}
show(): void {
this.events.push(`${this.name}:show`);
}
}
function getBaseConfig(): AppRuntimeConfig {
return {
title: "Scoreko",
userModelId: "com.scoreko.desktop",
userDataDirectoryName: "scoreko",
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 0,
startupTimeoutMs: 100,
nodecgKillTimeoutMs: 10,
updatesEnabled: true,
updateAssetPattern: "Scoreko-setup-.*\\.exe$",
updateCheckDelayMs: 5000,
};
}
function createMockManager(events: string[]): NodecgProcessManager {
return {
startNodecgProcess: async () => {
events.push("start-nodecg");
return new EventEmitter() as import("node:child_process").ChildProcess;
},
waitForNodecgReady: async () => {
events.push("wait-nodecg");
},
stopNodecgProcessGracefully: async () => {
events.push("stop-nodecg");
},
getProcess: () => null,
};
}
test("ApplicationController preserves startup ordering and schedules updates after main window is shown", async () => {
const events: string[] = [];
const paths = {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html?standalone=true",
loadingDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/loading/main.html?standalone=true",
};
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: false,
isWindows: true,
paths,
deps: {
createLoadingWindow: () => {
events.push("create-loading");
return new MockWindow("loading", events);
},
createMainWindow: () => {
events.push("create-main");
return new MockWindow("main", events);
},
createNodecgProcessManager: () => {
events.push("create-manager");
return createMockManager(events);
},
getAllWindows: () => [],
log: () => undefined,
prepareRuntime: () => {
events.push("prepare-runtime");
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
},
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
now: () => 0,
sleep: async (ms) => {
events.push(`sleep:${ms}`);
},
},
});
await controller.launch();
assert.equal(controller.getState(), "ready");
assert.deepEqual(events, [
"set-app-user-model-id",
"prepare-runtime",
"create-manager",
"create-main",
"create-loading",
"start-nodecg",
"wait-nodecg",
`loading:load:${paths.loadingDashboardUrl}`,
"loading:show",
`main:load:${paths.mainDashboardUrl}`,
"main:show",
"loading:close",
"schedule-update",
]);
});
test("ApplicationController relaunches packaged app after runtime install before starting NodeCG", async () => {
const events: string[] = [];
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: true,
isWindows: false,
paths: {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading",
},
deps: {
createLoadingWindow: () => {
throw new Error("window creation should wait until after relaunch decisions");
},
createMainWindow: () => {
throw new Error("window creation should wait until after relaunch decisions");
},
createNodecgProcessManager: () => {
throw new Error("NodeCG should not start before relaunch");
},
getAllWindows: () => [],
log: (...args) => events.push(String(args[0])),
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }),
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
},
});
await controller.launch();
assert.equal(controller.getState(), "stopped");
assert.deepEqual(events, [
"Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.",
"relaunch",
"exit:0",
]);
});
test("ApplicationController activation before readiness routes through launch", async () => {
const events: string[] = [];
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: false,
isWindows: false,
paths: {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading",
},
deps: {
createLoadingWindow: () => new MockWindow("loading", events),
createMainWindow: () => new MockWindow("main", events),
createNodecgProcessManager: () => createMockManager(events),
getAllWindows: () => [],
log: () => undefined,
prepareRuntime: () => {
events.push("prepare-runtime");
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
},
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
now: () => 0,
},
});
await controller.activate();
assert.equal(controller.getState(), "ready");
assert.ok(events.includes("prepare-runtime"));
assert.ok(events.includes("start-nodecg"));
assert.ok(events.includes("wait-nodecg"));
});
test("ApplicationController shutdown is idempotent", async () => {
const events: string[] = [];
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: false,
isWindows: false,
paths: {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading",
},
deps: {
createLoadingWindow: () => new MockWindow("loading", events),
createMainWindow: () => new MockWindow("main", events),
createNodecgProcessManager: () => createMockManager(events),
getAllWindows: () => [],
log: () => undefined,
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: false }),
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
now: () => 0,
},
});
await controller.launch();
await Promise.all([controller.stopNodecgGracefully(), controller.stopNodecgGracefully()]);
assert.equal(controller.getState(), "stopped");
assert.equal(events.filter((event) => event === "stop-nodecg").length, 1);
});