feat: improve NodeCG runtime installation and relaunch behavior

This commit is contained in:
2026-05-16 22:22:30 +02:00
parent 41e4e91c4b
commit 955a1f7116
10 changed files with 104 additions and 37 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ The installer is written to `scoreko-electron-dev/release/Scoreko-setup-0.1.0.ex
## Runtime behavior ## Runtime behavior
On first launch, Scoreko copies the packaged NodeCG runtime to the user's app data folder and runs it from there. This keeps `cfg`, `db`, and `logs` writable on Windows even when the app is installed under `Program Files`. On first launch, Scoreko copies the packaged NodeCG runtime to the user's app data folder and then relaunches itself before starting NodeCG. This keeps `cfg`, `db`, and `logs` writable on Windows even when the app is installed under `Program Files`, and avoids transient startup failures caused by freshly copied runtime files.
## Useful scripts ## Useful scripts
+6 -5
View File
@@ -3,16 +3,17 @@
## Startup flow ## Startup flow
1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`. 1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`.
2. Copies the packaged NodeCG runtime from app resources to user data when needed (`nodecg/runtime-provisioner.ts`). 2. Installs or refreshes the packaged NodeCG runtime in user data when needed (`nodecg/runtime-provisioner.ts`).
3. Creates windows (`windows/window-factory.ts`). 3. Creates windows (`windows/window-factory.ts`).
4. Starts NodeCG with `nodecg/process-manager.ts`. 4. In packaged builds, relaunches once after a fresh runtime install so NodeCG starts from a settled user-data runtime.
5. Waits for HTTP readiness and shows loading -> main dashboard. 5. Starts NodeCG with `nodecg/process-manager.ts`.
6. On shutdown, runs a single graceful-stop flow to avoid orphan processes. 6. Waits for HTTP readiness and shows loading -> main dashboard.
7. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
## Main modules ## Main modules
- `config/runtime-config.ts`: read/validate env vars. - `config/runtime-config.ts`: read/validate env vars.
- `nodecg/runtime-provisioner.ts`: install/refresh the managed runtime in the writable user data folder. - `nodecg/runtime-provisioner.ts`: install/refresh the managed runtime in the writable user data folder and report whether it changed.
- `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation. - `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation.
- `windows/window-factory.ts`: window creation and navigation policy. - `windows/window-factory.ts`: window creation and navigation policy.
- `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes. - `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes.
+6
View File
@@ -26,6 +26,12 @@
- Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow. - Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow.
- Recreate the runtime with `npm run prepare:runtime` if the bundle changed. - Recreate the runtime with `npm run prepare:runtime` if the bundle changed.
## First launch after install fails
- Scoreko relaunches itself automatically after a fresh runtime install.
- If it still fails, check whether antivirus or file indexing is locking `%AppData%\scoreko\nodecg`.
- Rebuild the installer with `npm run dist:win` after running `npm run rebuild:native`.
## macOS build fails because of icon ## macOS build fails because of icon
- The configuration expects `static/icons/icon.icns`. - The configuration expects `static/icons/icon.icns`.
+1 -2
View File
@@ -13,7 +13,7 @@
"clean": "rimraf dist release lib", "clean": "rimraf dist release lib",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build:bundle": "node scripts/build-scoreko-bundle.mjs", "build:bundle": "node scripts/build-scoreko-bundle.mjs",
"build:main": "tsc -p tsconfig.json && node scripts/copy-assets.mjs", "build:main": "tsc -p tsconfig.json",
"prepare:runtime": "node scripts/prepare-nodecg-runtime.mjs", "prepare:runtime": "node scripts/prepare-nodecg-runtime.mjs",
"build": "npm run clean && npm run build:bundle && npm run build:main && npm run prepare:runtime", "build": "npm run clean && npm run build:bundle && npm run build:main && npm run prepare:runtime",
"start": "npm run build && electron .", "start": "npm run build && electron .",
@@ -22,7 +22,6 @@
"dev:electron": "wait-on dist/main/main.js && electron .", "dev:electron": "wait-on dist/main/main.js && electron .",
"pack": "npm run build && electron-builder --dir", "pack": "npm run build && electron-builder --dir",
"rebuild:native": "node scripts/rebuild-nodecg-native.mjs", "rebuild:native": "node scripts/rebuild-nodecg-native.mjs",
"rebuild:better-sqlite3": "electron-rebuild --version 39.5.1 --module-dir lib/nodecg/workspaces/database-adapter-sqlite-legacy --only better-sqlite3 -f",
"test": "rimraf dist && npm run build:main && node --test dist/tests/**/*.test.js", "test": "rimraf dist && npm run build:main && node --test dist/tests/**/*.test.js",
"doctor": "node scripts/doctor.mjs", "doctor": "node scripts/doctor.mjs",
"lint": "eslint . --ext .ts,.js,.mjs", "lint": "eslint . --ext .ts,.js,.mjs",
-15
View File
@@ -1,15 +0,0 @@
import { cpSync, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const distStatic = path.join(root, "dist", "static");
const sourceStatic = path.join(root, "static");
mkdirSync(distStatic, { recursive: true });
if (existsSync(sourceStatic)) {
cpSync(sourceStatic, distStatic, { recursive: true });
console.log("Copied static assets to dist/static");
} else {
console.warn("No static folder found, skipping copy-assets step");
}
+19 -5
View File
@@ -50,7 +50,7 @@ function focusExistingWindow(): void {
} }
async function launchApplication(): Promise<void> { async function launchApplication(): Promise<void> {
const nodecgRootPath = prepareUserNodecgRuntime({ const preparedRuntime = prepareUserNodecgRuntime({
sourceRuntimePath: sourceNodecgRuntimePath, sourceRuntimePath: sourceNodecgRuntimePath,
userDataPath: app.getPath("userData"), userDataPath: app.getPath("userData"),
appVersion: app.getVersion(), appVersion: app.getVersion(),
@@ -58,9 +58,16 @@ async function launchApplication(): Promise<void> {
log, 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({ nodecgManager = createNodecgProcessManager({
isDev, isDev,
nodecgRootPath, nodecgRootPath: preparedRuntime.runtimePath,
nodecgBaseUrl, nodecgBaseUrl,
appConfig, appConfig,
log, log,
@@ -70,9 +77,7 @@ async function launchApplication(): Promise<void> {
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl }); mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
loadingWindow = createLoadingWindow({ appConfig, rootPath }); loadingWindow = createLoadingWindow({ appConfig, rootPath });
await nodecgManager.startNodecgProcess(); await startNodecg();
await nodecgManager.waitForNodecgReady(Date.now());
if (!loadingWindow || loadingWindow.isDestroyed()) { if (!loadingWindow || loadingWindow.isDestroyed()) {
return; return;
@@ -99,6 +104,15 @@ async function launchApplication(): Promise<void> {
closeLoadingWindow(); closeLoadingWindow();
} }
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> { function sleep(ms: number): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(resolve, ms); setTimeout(resolve, ms);
+11 -1
View File
@@ -80,7 +80,8 @@ export function createNodecgProcessManager({
}, },
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
detached: resolvedDeps.platform !== "win32", detached: resolvedDeps.platform !== "win32",
shell: resolvedDeps.platform === "win32", shell: false,
windowsHide: true,
}); });
child.stdout?.on("data", (chunk) => { child.stdout?.on("data", (chunk) => {
@@ -163,7 +164,15 @@ export function createNodecgProcessManager({
killNodecgProcessTree(pid, "SIGTERM", log, resolvedDeps); killNodecgProcessTree(pid, "SIGTERM", log, resolvedDeps);
stopNodecgPromise = new Promise((resolve) => { stopNodecgPromise = new Promise((resolve) => {
let completed = false;
const complete = () => { const complete = () => {
if (completed) {
return;
}
completed = true;
if (nodecgProcess === processToStop) { if (nodecgProcess === processToStop) {
nodecgProcess = null; nodecgProcess = null;
} }
@@ -181,6 +190,7 @@ 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); killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps);
complete();
} }
}, },
Math.max(0, appConfig.nodecgKillTimeoutMs), Math.max(0, appConfig.nodecgKillTimeoutMs),
+14 -4
View File
@@ -28,6 +28,11 @@ type RuntimeProvisionerDeps = {
writeFileSync: (filePath: string, content: string) => unknown; writeFileSync: (filePath: string, content: string) => unknown;
}; };
export type PreparedNodecgRuntime = {
runtimePath: string;
installed: boolean;
};
type RuntimeManifest = { type RuntimeManifest = {
appVersion?: unknown; appVersion?: unknown;
bundleName?: unknown; bundleName?: unknown;
@@ -47,14 +52,16 @@ export function prepareUserNodecgRuntime({
bundleName, bundleName,
log, log,
deps, deps,
}: RuntimeProvisionerConfig): string { }: RuntimeProvisionerConfig): PreparedNodecgRuntime {
const resolvedDeps = resolveDeps(deps); const resolvedDeps = resolveDeps(deps);
const targetRuntimePath = path.join(userDataPath, "nodecg"); const targetRuntimePath = path.join(userDataPath, "nodecg");
validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync); validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync);
resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true }); resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true });
if (shouldInstallRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps)) { const installed = shouldInstallRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps);
if (installed) {
log(`Installing managed NodeCG runtime into ${targetRuntimePath}`); log(`Installing managed NodeCG runtime into ${targetRuntimePath}`);
installManagedRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps); installManagedRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps);
} }
@@ -63,7 +70,7 @@ export function prepareUserNodecgRuntime({
resolvedDeps.mkdirSync(path.join(targetRuntimePath, writableDir), { recursive: true }); resolvedDeps.mkdirSync(path.join(targetRuntimePath, writableDir), { recursive: true });
} }
return targetRuntimePath; return { runtimePath: targetRuntimePath, installed };
} }
function resolveDeps(deps?: Partial<RuntimeProvisionerDeps>): RuntimeProvisionerDeps { function resolveDeps(deps?: Partial<RuntimeProvisionerDeps>): RuntimeProvisionerDeps {
@@ -166,7 +173,10 @@ function installManagedRuntime(
); );
} }
function readJson(filePath: string, deps: Pick<RuntimeProvisionerDeps, "existsSync" | "readFileSync">): RuntimeManifest | null { function readJson(
filePath: string,
deps: Pick<RuntimeProvisionerDeps, "existsSync" | "readFileSync">,
): RuntimeManifest | null {
if (!deps.existsSync(filePath)) { if (!deps.existsSync(filePath)) {
return null; return null;
} }
+39
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { SpawnOptions } from "node:child_process";
import test from "node:test"; import test from "node:test";
import { AppRuntimeConfig } from "../main/config/runtime-config"; import { AppRuntimeConfig } from "../main/config/runtime-config";
@@ -238,6 +239,44 @@ test("startNodeCG fails if the port is already in use", async () => {
}, /is already in use/); }, /is already in use/);
}); });
test("startNodeCG spawns Electron directly on Windows", async () => {
const child = new MockChildProcess(3210);
let capturedCommand: string | null = null;
let capturedArgs: string[] | null = null;
const capturedOptions: SpawnOptions[] = [];
const manager = createNodecgProcessManager({
isDev: false,
nodecgRootPath: "C:\\Users\\tester\\AppData\\Roaming\\scoreko\\nodecg",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
deps: {
platform: "win32",
execPath: "C:\\Program Files\\Scoreko\\scoreko.exe",
pathExists: () => true,
hasReadWriteAccess: () => true,
probePortAvailable: async () => true,
spawnProcess: (command, args, options) => {
capturedCommand = command;
capturedArgs = args;
capturedOptions.push(options);
return child as unknown as import("node:child_process").ChildProcess;
},
stdoutWrite: () => undefined,
stderrWrite: () => undefined,
},
});
await manager.startNodecgProcess();
assert.equal(capturedCommand, "C:\\Program Files\\Scoreko\\scoreko.exe");
assert.deepEqual(capturedArgs, ["C:\\Users\\tester\\AppData\\Roaming\\scoreko\\nodecg\\index.js"]);
assert.equal(capturedOptions[0]?.shell, false);
assert.equal(capturedOptions[0]?.windowsHide, true);
assert.equal(capturedOptions[0]?.env?.ELECTRON_RUN_AS_NODE, "1");
});
test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness", async () => { test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness", async () => {
const child = new MockChildProcess(4242); const child = new MockChildProcess(4242);
const manager = createNodecgProcessManager({ const manager = createNodecgProcessManager({
+7 -4
View File
@@ -70,7 +70,7 @@ test("prepareUserNodecgRuntime copies the packaged runtime into userData", () =>
[path.join(source, ".scoreko-runtime.json")]: JSON.stringify({ bundleVersion: "0.1.0", nodecgVersion: "2.6.4" }), [path.join(source, ".scoreko-runtime.json")]: JSON.stringify({ bundleVersion: "0.1.0", nodecgVersion: "2.6.4" }),
}); });
const runtimePath = prepareUserNodecgRuntime({ const preparedRuntime = prepareUserNodecgRuntime({
sourceRuntimePath: source, sourceRuntimePath: source,
userDataPath: userData, userDataPath: userData,
appVersion: "0.1.0", appVersion: "0.1.0",
@@ -79,7 +79,8 @@ test("prepareUserNodecgRuntime copies the packaged runtime into userData", () =>
deps, deps,
}); });
assert.equal(runtimePath, path.join(userData, "nodecg")); assert.equal(preparedRuntime.runtimePath, path.join(userData, "nodecg"));
assert.equal(preparedRuntime.installed, true);
assert.equal(state.copied.length, 1); assert.equal(state.copied.length, 1);
assert.ok(state.paths.has(path.join(userData, "nodecg", "cfg"))); assert.ok(state.paths.has(path.join(userData, "nodecg", "cfg")));
assert.ok(state.paths.has(path.join(userData, "nodecg", "db"))); assert.ok(state.paths.has(path.join(userData, "nodecg", "db")));
@@ -106,7 +107,7 @@ test("prepareUserNodecgRuntime keeps an up-to-date runtime in place", () => {
}, },
); );
prepareUserNodecgRuntime({ const preparedRuntime = prepareUserNodecgRuntime({
sourceRuntimePath: source, sourceRuntimePath: source,
userDataPath: userData, userDataPath: userData,
appVersion: "0.1.0", appVersion: "0.1.0",
@@ -115,6 +116,7 @@ test("prepareUserNodecgRuntime keeps an up-to-date runtime in place", () => {
deps, deps,
}); });
assert.equal(preparedRuntime.installed, false);
assert.equal(state.copied.length, 0); assert.equal(state.copied.length, 0);
assert.equal(state.removed.length, 0); assert.equal(state.removed.length, 0);
}); });
@@ -138,7 +140,7 @@ test("prepareUserNodecgRuntime refreshes managed files when the app version chan
}, },
); );
prepareUserNodecgRuntime({ const preparedRuntime = prepareUserNodecgRuntime({
sourceRuntimePath: source, sourceRuntimePath: source,
userDataPath: userData, userDataPath: userData,
appVersion: "0.1.0", appVersion: "0.1.0",
@@ -147,6 +149,7 @@ test("prepareUserNodecgRuntime refreshes managed files when the app version chan
deps, deps,
}); });
assert.equal(preparedRuntime.installed, true);
assert.equal(state.copied.length, 1); assert.equal(state.copied.length, 1);
assert.ok(state.removed.includes(path.join(target, "node_modules"))); assert.ok(state.removed.includes(path.join(target, "node_modules")));
assert.ok(state.removed.includes(path.join(target, "bundles"))); assert.ok(state.removed.includes(path.join(target, "bundles")));