diff --git a/README.md b/README.md index 07edf0e..f2f9f86 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The installer is written to `scoreko-electron-dev/release/Scoreko-setup-0.1.0.ex ## 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 diff --git a/docs/architecture.md b/docs/architecture.md index d725f84..5b93edc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,16 +3,17 @@ ## Startup flow 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`). -4. Starts NodeCG with `nodecg/process-manager.ts`. -5. Waits for HTTP readiness and shows loading -> main dashboard. -6. On shutdown, runs a single graceful-stop flow to avoid orphan processes. +4. In packaged builds, relaunches once after a fresh runtime install so NodeCG starts from a settled user-data runtime. +5. Starts NodeCG with `nodecg/process-manager.ts`. +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 - `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. - `windows/window-factory.ts`: window creation and navigation policy. - `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 76b1d3a..440d232 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -26,6 +26,12 @@ - Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow. - 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 - The configuration expects `static/icons/icon.icns`. diff --git a/package.json b/package.json index 5cec1e9..37e745e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "clean": "rimraf dist release lib", "typecheck": "tsc --noEmit", "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", "build": "npm run clean && npm run build:bundle && npm run build:main && npm run prepare:runtime", "start": "npm run build && electron .", @@ -22,7 +22,6 @@ "dev:electron": "wait-on dist/main/main.js && electron .", "pack": "npm run build && electron-builder --dir", "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", "doctor": "node scripts/doctor.mjs", "lint": "eslint . --ext .ts,.js,.mjs", diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs deleted file mode 100644 index fdc6539..0000000 --- a/scripts/copy-assets.mjs +++ /dev/null @@ -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"); -} diff --git a/src/main/main.ts b/src/main/main.ts index a78b96d..11da455 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -50,7 +50,7 @@ function focusExistingWindow(): void { } async function launchApplication(): Promise { - const nodecgRootPath = prepareUserNodecgRuntime({ + const preparedRuntime = prepareUserNodecgRuntime({ sourceRuntimePath: sourceNodecgRuntimePath, userDataPath: app.getPath("userData"), appVersion: app.getVersion(), @@ -58,9 +58,16 @@ async function launchApplication(): Promise { 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, + nodecgRootPath: preparedRuntime.runtimePath, nodecgBaseUrl, appConfig, log, @@ -70,9 +77,7 @@ async function launchApplication(): Promise { mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl }); loadingWindow = createLoadingWindow({ appConfig, rootPath }); - await nodecgManager.startNodecgProcess(); - - await nodecgManager.waitForNodecgReady(Date.now()); + await startNodecg(); if (!loadingWindow || loadingWindow.isDestroyed()) { return; @@ -99,6 +104,15 @@ async function launchApplication(): Promise { closeLoadingWindow(); } +async function startNodecg(): Promise { + if (!nodecgManager) { + throw new Error("NodeCG process manager is not initialized."); + } + + await nodecgManager.startNodecgProcess(); + await nodecgManager.waitForNodecgReady(Date.now()); +} + function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); diff --git a/src/main/nodecg/process-manager.ts b/src/main/nodecg/process-manager.ts index bdc8e3c..9ebd205 100644 --- a/src/main/nodecg/process-manager.ts +++ b/src/main/nodecg/process-manager.ts @@ -80,7 +80,8 @@ export function createNodecgProcessManager({ }, stdio: ["ignore", "pipe", "pipe"], detached: resolvedDeps.platform !== "win32", - shell: resolvedDeps.platform === "win32", + shell: false, + windowsHide: true, }); child.stdout?.on("data", (chunk) => { @@ -163,7 +164,15 @@ export function createNodecgProcessManager({ killNodecgProcessTree(pid, "SIGTERM", log, resolvedDeps); stopNodecgPromise = new Promise((resolve) => { + let completed = false; + const complete = () => { + if (completed) { + return; + } + + completed = true; + if (nodecgProcess === processToStop) { nodecgProcess = null; } @@ -181,6 +190,7 @@ export function createNodecgProcessManager({ if (processToStop.exitCode === null && processToStop.signalCode === null) { log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`); killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps); + complete(); } }, Math.max(0, appConfig.nodecgKillTimeoutMs), diff --git a/src/main/nodecg/runtime-provisioner.ts b/src/main/nodecg/runtime-provisioner.ts index b0cb798..8a77e56 100644 --- a/src/main/nodecg/runtime-provisioner.ts +++ b/src/main/nodecg/runtime-provisioner.ts @@ -28,6 +28,11 @@ type RuntimeProvisionerDeps = { writeFileSync: (filePath: string, content: string) => unknown; }; +export type PreparedNodecgRuntime = { + runtimePath: string; + installed: boolean; +}; + type RuntimeManifest = { appVersion?: unknown; bundleName?: unknown; @@ -47,14 +52,16 @@ export function prepareUserNodecgRuntime({ bundleName, log, deps, -}: RuntimeProvisionerConfig): string { +}: RuntimeProvisionerConfig): PreparedNodecgRuntime { const resolvedDeps = resolveDeps(deps); const targetRuntimePath = path.join(userDataPath, "nodecg"); validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync); 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}`); installManagedRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps); } @@ -63,7 +70,7 @@ export function prepareUserNodecgRuntime({ resolvedDeps.mkdirSync(path.join(targetRuntimePath, writableDir), { recursive: true }); } - return targetRuntimePath; + return { runtimePath: targetRuntimePath, installed }; } function resolveDeps(deps?: Partial): RuntimeProvisionerDeps { @@ -166,7 +173,10 @@ function installManagedRuntime( ); } -function readJson(filePath: string, deps: Pick): RuntimeManifest | null { +function readJson( + filePath: string, + deps: Pick, +): RuntimeManifest | null { if (!deps.existsSync(filePath)) { return null; } diff --git a/src/tests/process-manager.test.ts b/src/tests/process-manager.test.ts index bb9de81..f5d6653 100644 --- a/src/tests/process-manager.test.ts +++ b/src/tests/process-manager.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; +import { SpawnOptions } from "node:child_process"; import test from "node:test"; 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/); }); +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 () => { const child = new MockChildProcess(4242); const manager = createNodecgProcessManager({ diff --git a/src/tests/runtime-provisioner.test.ts b/src/tests/runtime-provisioner.test.ts index e4dc833..f77074d 100644 --- a/src/tests/runtime-provisioner.test.ts +++ b/src/tests/runtime-provisioner.test.ts @@ -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" }), }); - const runtimePath = prepareUserNodecgRuntime({ + const preparedRuntime = prepareUserNodecgRuntime({ sourceRuntimePath: source, userDataPath: userData, appVersion: "0.1.0", @@ -79,7 +79,8 @@ test("prepareUserNodecgRuntime copies the packaged runtime into userData", () => 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.ok(state.paths.has(path.join(userData, "nodecg", "cfg"))); 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, userDataPath: userData, appVersion: "0.1.0", @@ -115,6 +116,7 @@ test("prepareUserNodecgRuntime keeps an up-to-date runtime in place", () => { deps, }); + assert.equal(preparedRuntime.installed, false); assert.equal(state.copied.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, userDataPath: userData, appVersion: "0.1.0", @@ -147,6 +149,7 @@ test("prepareUserNodecgRuntime refreshes managed files when the app version chan deps, }); + assert.equal(preparedRuntime.installed, true); assert.equal(state.copied.length, 1); assert.ok(state.removed.includes(path.join(target, "node_modules"))); assert.ok(state.removed.includes(path.join(target, "bundles")));