mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-05 21:22:07 +00:00
feat: improve NodeCG runtime installation and relaunch behavior
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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")));
|
||||||
|
|||||||
Reference in New Issue
Block a user