From 41e4e91c4b7caf35730ff968f294a582befd49d6 Mon Sep 17 00:00:00 2001 From: Pandipipas Date: Sat, 9 May 2026 17:45:36 +0200 Subject: [PATCH] feat: enhance NodeCG runtime management and packaging - Update .gitignore and .prettierignore to exclude additional cache and configuration files. - Revise README.md for clarity on build processes and runtime behavior. - Improve architecture documentation to reflect changes in startup flow and module responsibilities. - Modify troubleshooting guide to address common runtime issues and installation steps. - Enhance ESLint configuration to ignore more directories. - Update package.json scripts for better build and distribution processes. - Introduce build-scoreko-bundle.mjs for building the Scoreko bundle. - Implement prepare-nodecg-runtime.mjs for managing NodeCG runtime installation and updates. - Add runtime-provisioner.ts to handle user-specific NodeCG runtime provisioning. - Create tests for runtime provisioning to ensure correct behavior. - Refactor process-manager.ts and main.ts to integrate new runtime management logic. --- .gitignore | 7 +- .prettierignore | 5 + README.md | 70 +++++----- docs/architecture.md | 10 +- docs/troubleshooting.md | 19 ++- eslint.config.mjs | 12 +- package.json | 20 +-- scripts/build-scoreko-bundle.mjs | 77 +++++++++++ scripts/doctor.mjs | 14 +- scripts/prepare-nodecg-runtime.mjs | 183 +++++++++++++++++++++++++ scripts/rebuild-nodecg-native.mjs | 41 +++--- src/main/constants.ts | 2 +- src/main/main.ts | 34 +++-- src/main/nodecg/process-manager.ts | 10 +- src/main/nodecg/runtime-provisioner.ts | 179 ++++++++++++++++++++++++ src/tests/runtime-provisioner.test.ts | 154 +++++++++++++++++++++ 16 files changed, 737 insertions(+), 100 deletions(-) create mode 100644 scripts/build-scoreko-bundle.mjs create mode 100644 scripts/prepare-nodecg-runtime.mjs create mode 100644 src/main/nodecg/runtime-provisioner.ts create mode 100644 src/tests/runtime-provisioner.test.ts diff --git a/.gitignore b/.gitignore index bb7077d..250ab4a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ node_modules dist release -lib \ No newline at end of file +lib +.corepack +.electron-cache +.localappdata +.npm-cache +.npm-runtime-cache diff --git a/.prettierignore b/.prettierignore index 4b56620..936d9a0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,8 @@ release lib/nodecg node_modules package-lock.json +.corepack +.electron-cache +.localappdata +.npm-cache +.npm-runtime-cache diff --git a/README.md b/README.md index 240f319..07edf0e 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,51 @@ # scoreko-electron -Desktop app (Electron + TypeScript) to run and package a NodeCG installation with the `scoreko-dev` bundle. +Windows desktop installer for Scoreko. The packaged app includes Electron, NodeCG, the compiled `scoreko-dev` bundle, and the production modules needed to run it, so end users do not need Node.js, pnpm, or a cloned repository. -## Requirements +## Build on a development machine -- Node.js `>=22` -- Dependencies installed with `npm install` +From the repository root: -## Available scripts +```powershell +pnpm install +``` -### Development +Then from `scoreko-electron-dev`: -- `npm run dev`: compiles in watch mode and opens Electron. -- `npm run watch`: TypeScript watch mode. -- `npm run dev:electron`: opens Electron when `dist/main/main.js` is ready. -- `npm run start`: full build and local run. +```powershell +npm install +npm run dist:win +``` -### Build and distribution +The installer is written to `scoreko-electron-dev/release/Scoreko-setup-0.1.0.exe`. -- `npm run clean`: removes `dist` and `release`. -- `npm run typecheck`: validates types without emitting files. -- `npm run build`: compiles TypeScript and copies assets. -- `npm run pack`: generates the app without an installer (`electron-builder --dir`). -- `npm run dist:win`: builds a Windows installer. -- `npm run dist:linux`: builds a Linux AppImage. -- `npm run dist:mac`: builds a macOS package. -- `npm run dist:all`: builds artifacts for Windows, Linux, and macOS. +## What the build does -### Quality and diagnostics +- Builds the parent `scoreko-dev` bundle with `pnpm build`. +- Creates `scoreko-electron-dev/lib/nodecg` with a small NodeCG runtime. +- Installs production runtime modules into that runtime. +- Rebuilds `better-sqlite3` for Electron before creating the installer. +- Packages the runtime as an Electron extra resource outside the app archive. -- `npm run test`: build and tests (`node:test`). -- `npm run doctor`: environment/configuration diagnostics. -- `npm run lint`: lint with ESLint. -- `npm run lint:fix`: lint with auto-fix. -- `npm run format`: checks formatting with Prettier. -- `npm run format:write`: applies formatting with Prettier. +## Runtime behavior -### Native modules +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`. -- `npm run rebuild:native`: rebuilds NodeCG native modules. -- `npm run rebuild:better-sqlite3`: rebuilds only `better-sqlite3` for Electron. +## Useful scripts -## Quick setup +- `npm run start`: build everything and run Electron locally. +- `npm run prepare:runtime`: recreate `lib/nodecg` from the parent bundle. +- `npm run rebuild:native`: rebuild NodeCG native modules for Electron. +- `npm run dist:win`: create the Windows installer. +- `npm run doctor`: check the prepared runtime and the configured port. -1. Copy `.env.example` to `.env`. -2. Adjust variables for your environment. -3. Run `npm run doctor` before developing or packaging. +## Configuration -## References +The defaults match the parent bundle: -- Troubleshooting: `docs/troubleshooting.md` -- Architecture: `docs/architecture.md` +- `NODECG_BUNDLE_NAME=scoreko-dev` +- `NODECG_PORT=9090` +- `SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true` +- `SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true` + +Copy `.env.example` only if you need local overrides while developing. diff --git a/docs/architecture.md b/docs/architecture.md index 94c2afc..d725f84 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,14 +3,16 @@ ## Startup flow 1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`. -2. Creates windows (`windows/window-factory.ts`). -3. Starts NodeCG with `nodecg/process-manager.ts`. -4. Waits for HTTP readiness and shows loading -> main dashboard. -5. On shutdown, runs a single graceful-stop flow to avoid orphan processes. +2. Copies the packaged NodeCG runtime from app resources to 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. ## 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/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 310df27..76b1d3a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,14 +1,19 @@ # Troubleshooting -## `NodeCG folder does not exist` +## `The packaged NodeCG runtime is incomplete` -- Verify `lib/nodecg` exists. -- Make sure the project contains a full NodeCG installation. +- Run `npm run prepare:runtime` from `scoreko-electron-dev`. +- If the parent bundle is not installed yet, run `pnpm install` from the repository root first. + +## `NodeCG is present but internal dependencies are missing` + +- Recreate the runtime with `npm run prepare:runtime`. +- If native SQLite errors appear during launch, run `npm run rebuild:native` before packaging. ## `No read/write permissions on NodeCG` -- Adjust permissions on `lib/nodecg` for the user running Electron. -- On Linux/macOS: `chmod -R u+rw lib/nodecg` (according to your local policy). +- Installed builds run NodeCG from the user's app data folder, so this usually means the local development copy is locked. +- Close any running Scoreko/NodeCG process and run `npm run start` again. ## `Port is already in use` @@ -17,9 +22,9 @@ ## `Timeout while waiting for NodeCG` -- Check NodeCG logs in standard output. +- Check the Electron/NodeCG output in the terminal. - Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow. -- Verify NodeCG dependencies (`cd lib/nodecg && npm install`). +- Recreate the runtime with `npm run prepare:runtime` if the bundle changed. ## macOS build fails because of icon diff --git a/eslint.config.mjs b/eslint.config.mjs index 3eae9e9..fb09a71 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,17 @@ import tsParser from "@typescript-eslint/parser"; export default [ { - ignores: ["dist/**", "release/**", "lib/**"], + ignores: [ + "dist/**", + "release/**", + "lib/**", + "node_modules/**", + ".corepack/**", + ".electron-cache/**", + ".localappdata/**", + ".npm-cache/**", + ".npm-runtime-cache/**", + ], }, { files: ["**/*.ts"], diff --git a/package.json b/package.json index ca17c4e..5cec1e9 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,12 @@ "private": true, "main": "dist/main/main.js", "scripts": { - "clean": "rimraf dist release", + "clean": "rimraf dist release lib", "typecheck": "tsc --noEmit", - "build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-assets.mjs", + "build:bundle": "node scripts/build-scoreko-bundle.mjs", + "build:main": "tsc -p tsconfig.json && node scripts/copy-assets.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", "start": "npm run build && electron .", "dev": "concurrently -k \"npm:watch\" \"npm:dev:electron\"", "watch": "tsc -p tsconfig.json --watch", @@ -20,16 +23,16 @@ "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": "npm run build && node --test dist/tests/**/*.test.js", + "test": "rimraf dist && npm run build:main && node --test dist/tests/**/*.test.js", "doctor": "node scripts/doctor.mjs", "lint": "eslint . --ext .ts,.js,.mjs", "lint:fix": "npm run lint -- --fix", "format": "prettier --check .", "format:write": "prettier --write .", - "dist:win": "npm run build && electron-builder --win", - "dist:linux": "npm run build && electron-builder --linux AppImage", - "dist:all": "npm run build && electron-builder --win --linux --mac", - "dist:mac": "npm run build && electron-builder --mac" + "dist:win": "npm run build && npm run rebuild:native && electron-builder --win", + "dist:linux": "npm run build && npm run rebuild:native && electron-builder --linux AppImage", + "dist:all": "npm run build && npm run rebuild:native && electron-builder --win --linux --mac", + "dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac" }, "build": { "appId": "com.scoreko.desktop", @@ -71,7 +74,8 @@ "nsis" ], "icon": "static/icons/icon.ico", - "executableName": "scoreko" + "executableName": "scoreko", + "signAndEditExecutable": false }, "nsis": { "oneClick": false, diff --git a/scripts/build-scoreko-bundle.mjs b/scripts/build-scoreko-bundle.mjs new file mode 100644 index 0000000..eceaf79 --- /dev/null +++ b/scripts/build-scoreko-bundle.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const electronRoot = process.cwd(); +const bundleRoot = path.resolve(electronRoot, ".."); +const packageJsonPath = path.join(bundleRoot, "package.json"); +const pnpmLockPath = path.join(bundleRoot, "pnpm-lock.yaml"); +const nodeModulesPath = path.join(bundleRoot, "node_modules"); + +if (!existsSync(packageJsonPath) || !existsSync(pnpmLockPath)) { + console.error(`Scoreko bundle root was not found at: ${bundleRoot}`); + process.exit(1); +} + +if (!existsSync(nodeModulesPath)) { + console.error( + [ + "The Scoreko bundle dependencies are not installed.", + `Run this once from ${bundleRoot}:`, + " pnpm install", + ].join("\n"), + ); + process.exit(1); +} + +const generatedBundleEntries = ["extension", "node_modules/.vite", "shared/dist", "dashboard", "graphics"]; +const childEnv = { + ...process.env, + COREPACK_HOME: process.env.COREPACK_HOME ?? path.join(electronRoot, ".corepack"), + PATH: `${path.join(bundleRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`, +}; + +function removeGeneratedOutput(relativePath) { + const targetPath = path.resolve(bundleRoot, relativePath); + + if (!targetPath.startsWith(`${bundleRoot}${path.sep}`)) { + throw new Error(`Refusing to remove path outside the bundle root: ${targetPath}`); + } + + rmSync(targetPath, { recursive: true, force: true }); +} + +function runCommand(command, args) { + const result = spawnSync(command, args, { + cwd: bundleRoot, + stdio: "inherit", + shell: process.platform === "win32", + env: childEnv, + }); + + if (result.error) { + console.error(`Could not run '${command} ${args.join(" ")}': ${result.error.message}`); + process.exit(1); + } + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function runLocalBin(commandName, args) { + const extension = process.platform === "win32" ? ".CMD" : ""; + runCommand(path.join(bundleRoot, "node_modules", ".bin", `${commandName}${extension}`), args); +} + +for (const entry of generatedBundleEntries) { + removeGeneratedOutput(entry); +} + +for (const entry of ["shared/dist", "dashboard", "graphics", "extension"]) { + mkdirSync(path.join(bundleRoot, entry), { recursive: true }); +} + +runLocalBin("vite", ["build", "--configLoader", "runner"]); +runLocalBin("tsc", ["-b", "tsconfig.extension.json"]); diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index f44a330..323f423 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -37,16 +37,20 @@ function parseIntInRange(name, fallback, min, max) { function checkNodecgInstall() { const indexPath = path.join(nodecgRootPath, "index.js"); + const bootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"); + const manifestPath = path.join(nodecgRootPath, ".scoreko-runtime.json"); const bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim(); const bundlePath = path.join(nodecgRootPath, "bundles", bundleName); - addCheck(fs.existsSync(nodecgRootPath), "NodeCG root", nodecgRootPath); - addCheck(fs.existsSync(indexPath), "NodeCG index.js", indexPath); - addCheck(fs.existsSync(bundlePath), `Bundle '${bundleName}'`, bundlePath); + addCheck(fs.existsSync(nodecgRootPath), "Packaged NodeCG runtime", nodecgRootPath); + addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath); + addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath); + addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath); + addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath); try { fs.accessSync(nodecgRootPath, fs.constants.R_OK | fs.constants.W_OK); - addCheck(true, "lib/nodecg permissions", "Read/write OK"); + addCheck(true, "lib/nodecg permissions", "Read/write OK for local development"); } catch { addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg"); } @@ -82,7 +86,7 @@ async function main() { } for (const check of checks) { - const icon = check.ok ? "✅" : "❌"; + const icon = check.ok ? "OK" : "FAIL"; console.log(`${icon} ${check.title}: ${check.details}`); } diff --git a/scripts/prepare-nodecg-runtime.mjs b/scripts/prepare-nodecg-runtime.mjs new file mode 100644 index 0000000..fee8dcb --- /dev/null +++ b/scripts/prepare-nodecg-runtime.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const electronRoot = process.cwd(); +const bundleRoot = path.resolve(electronRoot, ".."); +const runtimeRoot = path.join(electronRoot, "lib", "nodecg"); +const runtimeNodeModules = path.join(runtimeRoot, "node_modules"); +const bundleName = process.env.NODECG_BUNDLE_NAME?.trim() || "scoreko-dev"; +const runtimeBundleRoot = path.join(runtimeRoot, "bundles", bundleName); + +const bundleEntries = [ + "assets", + "dashboard", + "extension", + "graphics", + "schemas", + "shared", + "configschema.json", + "LICENSE", + "package.json", + "README.md", +]; + +const requiredBundleEntries = ["dashboard", "extension", "graphics", "schemas", "shared", "package.json"]; + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function copyIfExists(source, destination) { + if (!existsSync(source)) { + return false; + } + + cpSync(source, destination, { + recursive: true, + force: true, + dereference: true, + filter: (sourcePath) => !sourcePath.split(path.sep).includes("node_modules"), + }); + return true; +} + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + stdio: "inherit", + shell: process.platform === "win32", + env: { + ...process.env, + npm_config_cache: process.env.npm_config_cache ?? path.join(electronRoot, ".npm-runtime-cache"), + }, + }); + + if (result.error) { + throw new Error(`${command} ${args.join(" ")} failed: ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed with code ${result.status}`); + } +} + +function getInstalledNodecgVersion() { + const nodecgPackagePath = path.join(bundleRoot, "node_modules", "nodecg", "package.json"); + + if (!existsSync(nodecgPackagePath)) { + throw new Error( + [ + "NodeCG is not installed in the parent project.", + `Expected: ${nodecgPackagePath}`, + `Run 'pnpm install' from ${bundleRoot} before packaging.`, + ].join("\n"), + ); + } + + return readJson(nodecgPackagePath).version; +} + +function assertBundleBuildExists() { + for (const entry of requiredBundleEntries) { + const source = path.join(bundleRoot, entry); + if (!existsSync(source)) { + throw new Error( + [ + `The built Scoreko bundle is missing '${entry}'.`, + `Expected: ${source}`, + `Run 'pnpm build' from ${bundleRoot} before packaging.`, + ].join("\n"), + ); + } + } +} + +function createRuntimePackageJson() { + const bundlePackageJson = readJson(path.join(bundleRoot, "package.json")); + const dependencies = { + nodecg: getInstalledNodecgVersion(), + ...(bundlePackageJson.dependencies ?? {}), + }; + + writeFileSync( + path.join(runtimeRoot, "package.json"), + `${JSON.stringify( + { + private: true, + name: "scoreko-nodecg-runtime", + version: bundlePackageJson.version ?? "0.0.0", + description: "Packaged NodeCG runtime for Scoreko Desktop.", + type: "commonjs", + scripts: { + start: "node index.js", + }, + dependencies, + }, + null, + 2, + )}\n`, + ); + + writeFileSync(path.join(runtimeRoot, "index.js"), 'require("nodecg");\n'); +} + +function copyBundle() { + mkdirSync(runtimeBundleRoot, { recursive: true }); + + for (const entry of bundleEntries) { + copyIfExists(path.join(bundleRoot, entry), path.join(runtimeBundleRoot, entry)); + } +} + +function writeManifest() { + const bundlePackageJson = readJson(path.join(bundleRoot, "package.json")); + const runtimePackageJson = readJson(path.join(runtimeRoot, "package.json")); + + writeFileSync( + path.join(runtimeRoot, ".scoreko-runtime.json"), + `${JSON.stringify( + { + bundleName, + bundleVersion: bundlePackageJson.version ?? "0.0.0", + nodecgVersion: runtimePackageJson.dependencies.nodecg, + generatedAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); +} + +function installRuntimeDependencies() { + if (process.env.SCOREKO_SKIP_RUNTIME_NPM_INSTALL === "1") { + console.log("[prepare-runtime] Skipping runtime npm install by environment request."); + return; + } + + const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + run(npmCommand, ["install", "--omit=dev", "--no-audit", "--no-fund"], runtimeRoot); +} + +function main() { + assertBundleBuildExists(); + + rmSync(runtimeRoot, { recursive: true, force: true }); + mkdirSync(runtimeNodeModules, { recursive: true }); + mkdirSync(path.join(runtimeRoot, "bundles"), { recursive: true }); + + createRuntimePackageJson(); + copyBundle(); + installRuntimeDependencies(); + writeManifest(); + + console.log(`[prepare-runtime] NodeCG runtime ready at ${runtimeRoot}`); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/rebuild-nodecg-native.mjs b/scripts/rebuild-nodecg-native.mjs index a68ffee..9e0d7e7 100644 --- a/scripts/rebuild-nodecg-native.mjs +++ b/scripts/rebuild-nodecg-native.mjs @@ -1,15 +1,20 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { spawn } from "node:child_process"; const root = process.cwd(); const nodecgDir = path.join(root, "lib", "nodecg"); -const sqliteLegacyDir = path.join(nodecgDir, "workspaces", "database-adapter-sqlite-legacy"); +const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); +const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron; +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; -const moduleDirs = [nodecgDir, sqliteLegacyDir].filter((dir) => existsSync(path.join(dir, "package.json"))); +if (!electronVersion) { + console.error("Could not determine Electron version from package.json."); + process.exit(1); +} -if (moduleDirs.length === 0) { - console.error("No NodeCG package folders found. Expected lib/nodecg and/or workspaces."); +if (!existsSync(path.join(nodecgDir, "package.json"))) { + console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first."); process.exit(1); } @@ -23,8 +28,10 @@ function run(command, args, cwd) { env: { ...process.env, npm_config_runtime: "electron", - npm_config_target: "39.5.1", + npm_config_target: electronVersion, npm_config_disturl: "https://electronjs.org/headers", + npm_config_cache: process.env.npm_config_cache ?? path.join(root, ".npm-runtime-cache"), + ELECTRON_CACHE: process.env.ELECTRON_CACHE ?? path.join(root, ".electron-cache"), }, }); @@ -38,19 +45,13 @@ function run(command, args, cwd) { }); } -for (const dir of moduleDirs) { - if (dir === sqliteLegacyDir) { - console.log(`\n[rebuild-native] Ensuring sqlite legacy workspace deps in: ${dir}`); - await run("npm", ["install"], dir); - await run("npm", ["install", "bindings", "--no-save"], dir); - } - - console.log(`\n[rebuild-native] Rebuilding better-sqlite3 in: ${dir}`); - await run( - "npm", - ["rebuild", "better-sqlite3", "--runtime=electron", "--target=39.5.1", "--dist-url=https://electronjs.org/headers"], - dir, - ); -} +console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgDir}`); +await run(npmCommand, [ + "rebuild", + "better-sqlite3", + "--runtime=electron", + `--target=${electronVersion}`, + "--dist-url=https://electronjs.org/headers", +], nodecgDir); console.log("\n[rebuild-native] Done."); diff --git a/src/main/constants.ts b/src/main/constants.ts index 372b3e5..b3eab6d 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -1,4 +1,4 @@ -export const NODE_RUNTIME_NAME = "electron internal node"; +export const NODE_RUNTIME_NAME = "Electron embedded Node.js"; export const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f"; export const DEFAULT_WINDOW_SIZE = { diff --git a/src/main/main.ts b/src/main/main.ts index 37c775f..a78b96d 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3,7 +3,8 @@ import path from "node:path"; import { getRuntimeConfig } from "./config/runtime-config"; import { showFatalError, log } from "./errors/error-presenter"; -import { createNodecgProcessManager } from "./nodecg/process-manager"; +import { createNodecgProcessManager, NodecgProcessManager } from "./nodecg/process-manager"; +import { prepareUserNodecgRuntime } from "./nodecg/runtime-provisioner"; import { getRemainingDelayMs } from "./utils/timing"; import { createLoadingWindow, createMainWindow } from "./windows/window-factory"; @@ -15,7 +16,7 @@ app.setPath("userData", path.join(app.getPath("appData"), appConfig.userDataDire const isDev = !app.isPackaged; const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath; -const nodecgRootPath = path.resolve(rootPath, "lib", "nodecg"); +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}`; @@ -26,18 +27,11 @@ if (!hasSingleInstanceLock) { app.quit(); } -const nodecgManager = createNodecgProcessManager({ - isDev, - nodecgRootPath, - nodecgBaseUrl, - appConfig, - log, -}); - 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 { @@ -56,6 +50,22 @@ function focusExistingWindow(): void { } async function launchApplication(): Promise { + const nodecgRootPath = prepareUserNodecgRuntime({ + sourceRuntimePath: sourceNodecgRuntimePath, + userDataPath: app.getPath("userData"), + appVersion: app.getVersion(), + bundleName: appConfig.bundleName, + log, + }); + + nodecgManager = createNodecgProcessManager({ + isDev, + nodecgRootPath, + 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 }); @@ -110,12 +120,12 @@ function stopNodecgGracefully(): Promise { } if (shutdownState === "stopping") { - return nodecgManager.stopNodecgProcessGracefully(); + return nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve(); } shutdownState = "stopping"; - return nodecgManager.stopNodecgProcessGracefully().finally(() => { + return (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve()).finally(() => { shutdownState = "stopped"; }); } diff --git a/src/main/nodecg/process-manager.ts b/src/main/nodecg/process-manager.ts index 94bc3ed..bdc8e3c 100644 --- a/src/main/nodecg/process-manager.ts +++ b/src/main/nodecg/process-manager.ts @@ -121,7 +121,7 @@ export function createNodecgProcessManager({ exitDetails, stderrDetails, `NodeCG path: ${nodecgRootPath}`, - "Check that lib/nodecg dependencies are installed and the bundle exists.", + "Check that the packaged runtime was installed correctly and the bundle exists.", ].join("\n"), ); } @@ -234,7 +234,7 @@ function validateNodecgInstall( } if (!pathExists(indexPath)) { - throw new Error(`${indexPath} was not found. Copy a full NodeCG installation into lib/nodecg.`); + throw new Error(`${indexPath} was not found. Build the packaged NodeCG runtime before starting Electron.`); } if (!pathExists(nodecgBootstrapPath)) { @@ -242,8 +242,8 @@ function validateNodecgInstall( [ "NodeCG is present but internal dependencies are missing.", `Not found: ${nodecgBootstrapPath}`, - "Solution: enter lib/nodecg and install dependencies:", - " npm install", + "Solution: rebuild the packaged runtime:", + " npm run prepare:runtime", ].join("\n"), ); } @@ -253,7 +253,7 @@ function validateNodecgInstall( [ `Bundle '${bundleName}' was not found.`, `Expected path: ${bundlePath}`, - "Copy/clone your bundle inside lib/nodecg/bundles before running Electron.", + "Build and package the Scoreko bundle before running Electron.", ].join("\n"), ); } diff --git a/src/main/nodecg/runtime-provisioner.ts b/src/main/nodecg/runtime-provisioner.ts new file mode 100644 index 0000000..b0cb798 --- /dev/null +++ b/src/main/nodecg/runtime-provisioner.ts @@ -0,0 +1,179 @@ +import fs from "node:fs"; +import path from "node:path"; + +type RuntimeProvisionerConfig = { + sourceRuntimePath: string; + userDataPath: string; + appVersion: string; + bundleName: string; + log: (...args: unknown[]) => void; + deps?: Partial; +}; + +type RuntimeProvisionerDeps = { + existsSync: (candidatePath: string) => boolean; + mkdirSync: (candidatePath: string, options: { recursive: true }) => unknown; + rmSync: (candidatePath: string, options: { recursive: true; force: true }) => unknown; + cpSync: ( + sourcePath: string, + targetPath: string, + options: { + recursive: true; + force: true; + dereference: true; + filter: (sourcePath: string) => boolean; + }, + ) => unknown; + readFileSync: (filePath: string) => string | Buffer; + writeFileSync: (filePath: string, content: string) => unknown; +}; + +type RuntimeManifest = { + appVersion?: unknown; + bundleName?: unknown; + sourceRuntime?: RuntimeManifest | null; + bundleVersion?: unknown; + nodecgVersion?: unknown; +}; + +const MANAGED_RUNTIME_MARKER = ".scoreko-installed-runtime.json"; +const WRITABLE_NODECG_DIRS = ["cfg", "db", "logs"] as const; +const MANAGED_RUNTIME_ENTRIES = ["index.js", "package.json", "package-lock.json", "node_modules", "bundles"] as const; + +export function prepareUserNodecgRuntime({ + sourceRuntimePath, + userDataPath, + appVersion, + bundleName, + log, + deps, +}: RuntimeProvisionerConfig): string { + 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)) { + log(`Installing managed NodeCG runtime into ${targetRuntimePath}`); + installManagedRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps); + } + + for (const writableDir of WRITABLE_NODECG_DIRS) { + resolvedDeps.mkdirSync(path.join(targetRuntimePath, writableDir), { recursive: true }); + } + + return targetRuntimePath; +} + +function resolveDeps(deps?: Partial): RuntimeProvisionerDeps { + return { + existsSync: deps?.existsSync ?? fs.existsSync, + mkdirSync: deps?.mkdirSync ?? fs.mkdirSync, + rmSync: deps?.rmSync ?? fs.rmSync, + cpSync: deps?.cpSync ?? fs.cpSync, + readFileSync: deps?.readFileSync ?? fs.readFileSync, + writeFileSync: deps?.writeFileSync ?? fs.writeFileSync, + }; +} + +function validateSourceRuntime( + sourceRuntimePath: string, + bundleName: string, + existsSync: RuntimeProvisionerDeps["existsSync"], +): void { + const requiredPaths = [ + sourceRuntimePath, + path.join(sourceRuntimePath, "index.js"), + path.join(sourceRuntimePath, "package.json"), + path.join(sourceRuntimePath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"), + path.join(sourceRuntimePath, "bundles", bundleName, "package.json"), + ]; + + const missingPaths = requiredPaths.filter((candidatePath) => !existsSync(candidatePath)); + + if (missingPaths.length > 0) { + throw new Error( + [ + "The packaged NodeCG runtime is incomplete.", + ...missingPaths.map((missingPath) => `Missing: ${missingPath}`), + "Build the runtime with 'npm run prepare:runtime' before packaging or starting Electron.", + ].join("\n"), + ); + } +} + +function shouldInstallRuntime( + sourceRuntimePath: string, + targetRuntimePath: string, + appVersion: string, + bundleName: string, + deps: RuntimeProvisionerDeps, +): boolean { + const targetBootstrap = path.join(targetRuntimePath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"); + const targetBundlePackage = path.join(targetRuntimePath, "bundles", bundleName, "package.json"); + + if (!deps.existsSync(targetBootstrap) || !deps.existsSync(targetBundlePackage)) { + return true; + } + + const targetMarker = readJson(path.join(targetRuntimePath, MANAGED_RUNTIME_MARKER), deps); + const sourceMarker = readJson(path.join(sourceRuntimePath, ".scoreko-runtime.json"), deps); + + return ( + targetMarker?.appVersion !== appVersion || + targetMarker?.bundleName !== bundleName || + targetMarker?.sourceRuntime?.bundleVersion !== sourceMarker?.bundleVersion || + targetMarker?.sourceRuntime?.nodecgVersion !== sourceMarker?.nodecgVersion + ); +} + +function installManagedRuntime( + sourceRuntimePath: string, + targetRuntimePath: string, + appVersion: string, + bundleName: string, + deps: RuntimeProvisionerDeps, +): void { + for (const entry of MANAGED_RUNTIME_ENTRIES) { + deps.rmSync(path.join(targetRuntimePath, entry), { recursive: true, force: true }); + } + + deps.cpSync(sourceRuntimePath, targetRuntimePath, { + recursive: true, + force: true, + dereference: true, + filter: (sourcePath) => { + const relativePath = path.relative(sourceRuntimePath, sourcePath); + const firstSegment = relativePath.split(path.sep)[0]; + return !WRITABLE_NODECG_DIRS.includes(firstSegment as (typeof WRITABLE_NODECG_DIRS)[number]); + }, + }); + + const sourceRuntime = readJson(path.join(sourceRuntimePath, ".scoreko-runtime.json"), deps); + deps.writeFileSync( + path.join(targetRuntimePath, MANAGED_RUNTIME_MARKER), + `${JSON.stringify( + { + appVersion, + bundleName, + sourceRuntime, + installedAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); +} + +function readJson(filePath: string, deps: Pick): RuntimeManifest | null { + if (!deps.existsSync(filePath)) { + return null; + } + + try { + return JSON.parse(String(deps.readFileSync(filePath))); + } catch { + return null; + } +} diff --git a/src/tests/runtime-provisioner.test.ts b/src/tests/runtime-provisioner.test.ts new file mode 100644 index 0000000..e4dc833 --- /dev/null +++ b/src/tests/runtime-provisioner.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { prepareUserNodecgRuntime } from "../main/nodecg/runtime-provisioner"; + +type FakeFsState = { + paths: Set; + files: Map; + removed: string[]; + copied: Array<{ from: string; to: string }>; +}; + +function createFakeFs(initialPaths: string[] = [], initialFiles: Record = {}) { + const state: FakeFsState = { + paths: new Set(initialPaths.map((candidatePath) => path.normalize(candidatePath))), + files: new Map(Object.entries(initialFiles).map(([filePath, content]) => [path.normalize(filePath), content])), + removed: [], + copied: [], + }; + + for (const filePath of state.files.keys()) { + state.paths.add(filePath); + } + + return { + state, + deps: { + existsSync: (candidatePath: string) => state.paths.has(path.normalize(candidatePath)), + mkdirSync: (candidatePath: string) => { + state.paths.add(path.normalize(candidatePath)); + return undefined; + }, + rmSync: (candidatePath: string) => { + state.removed.push(path.normalize(candidatePath)); + state.paths.delete(path.normalize(candidatePath)); + }, + cpSync: (from: string, to: string) => { + state.copied.push({ from: path.normalize(from), to: path.normalize(to) }); + state.paths.add(path.normalize(to)); + state.paths.add(path.join(path.normalize(to), "index.js")); + state.paths.add(path.join(path.normalize(to), "package.json")); + state.paths.add(path.join(path.normalize(to), "node_modules", "nodecg", "dist", "server", "bootstrap.js")); + state.paths.add(path.join(path.normalize(to), "bundles", "scoreko-dev", "package.json")); + }, + readFileSync: (filePath: string) => state.files.get(path.normalize(filePath)) ?? "{}", + writeFileSync: (filePath: string, content: string) => { + state.files.set(path.normalize(filePath), content); + state.paths.add(path.normalize(filePath)); + }, + }, + }; +} + +function getSourcePaths(source: string) { + return [ + source, + path.join(source, "index.js"), + path.join(source, "package.json"), + path.join(source, "node_modules", "nodecg", "dist", "server", "bootstrap.js"), + path.join(source, "bundles", "scoreko-dev", "package.json"), + path.join(source, ".scoreko-runtime.json"), + ]; +} + +test("prepareUserNodecgRuntime copies the packaged runtime into userData", () => { + const source = path.normalize("/app/lib/nodecg"); + const userData = path.normalize("/user/scoreko"); + const { state, deps } = createFakeFs(getSourcePaths(source), { + [path.join(source, ".scoreko-runtime.json")]: JSON.stringify({ bundleVersion: "0.1.0", nodecgVersion: "2.6.4" }), + }); + + const runtimePath = prepareUserNodecgRuntime({ + sourceRuntimePath: source, + userDataPath: userData, + appVersion: "0.1.0", + bundleName: "scoreko-dev", + log: () => undefined, + deps, + }); + + assert.equal(runtimePath, path.join(userData, "nodecg")); + 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"))); + assert.ok(state.paths.has(path.join(userData, "nodecg", "logs"))); + assert.ok(state.files.has(path.join(userData, "nodecg", ".scoreko-installed-runtime.json"))); +}); + +test("prepareUserNodecgRuntime keeps an up-to-date runtime in place", () => { + const source = path.normalize("/app/lib/nodecg"); + const userData = path.normalize("/user/scoreko"); + const target = path.join(userData, "nodecg"); + const sourceManifest = { bundleVersion: "0.1.0", nodecgVersion: "2.6.4" }; + const targetManifest = { appVersion: "0.1.0", bundleName: "scoreko-dev", sourceRuntime: sourceManifest }; + const { state, deps } = createFakeFs( + [ + ...getSourcePaths(source), + path.join(target, "node_modules", "nodecg", "dist", "server", "bootstrap.js"), + path.join(target, "bundles", "scoreko-dev", "package.json"), + path.join(target, ".scoreko-installed-runtime.json"), + ], + { + [path.join(source, ".scoreko-runtime.json")]: JSON.stringify(sourceManifest), + [path.join(target, ".scoreko-installed-runtime.json")]: JSON.stringify(targetManifest), + }, + ); + + prepareUserNodecgRuntime({ + sourceRuntimePath: source, + userDataPath: userData, + appVersion: "0.1.0", + bundleName: "scoreko-dev", + log: () => undefined, + deps, + }); + + assert.equal(state.copied.length, 0); + assert.equal(state.removed.length, 0); +}); + +test("prepareUserNodecgRuntime refreshes managed files when the app version changes", () => { + const source = path.normalize("/app/lib/nodecg"); + const userData = path.normalize("/user/scoreko"); + const target = path.join(userData, "nodecg"); + const sourceManifest = { bundleVersion: "0.1.0", nodecgVersion: "2.6.4" }; + const targetManifest = { appVersion: "0.0.9", bundleName: "scoreko-dev", sourceRuntime: sourceManifest }; + const { state, deps } = createFakeFs( + [ + ...getSourcePaths(source), + path.join(target, "node_modules", "nodecg", "dist", "server", "bootstrap.js"), + path.join(target, "bundles", "scoreko-dev", "package.json"), + path.join(target, ".scoreko-installed-runtime.json"), + ], + { + [path.join(source, ".scoreko-runtime.json")]: JSON.stringify(sourceManifest), + [path.join(target, ".scoreko-installed-runtime.json")]: JSON.stringify(targetManifest), + }, + ); + + prepareUserNodecgRuntime({ + sourceRuntimePath: source, + userDataPath: userData, + appVersion: "0.1.0", + bundleName: "scoreko-dev", + log: () => undefined, + deps, + }); + + 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"))); + assert.ok(!state.removed.includes(path.join(target, "db"))); +});