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.
This commit is contained in:
2026-05-09 17:45:36 +02:00
parent b10b8adb98
commit 41e4e91c4b
16 changed files with 737 additions and 100 deletions
+6 -1
View File
@@ -1,4 +1,9 @@
node_modules node_modules
dist dist
release release
lib lib
.corepack
.electron-cache
.localappdata
.npm-cache
.npm-runtime-cache
+5
View File
@@ -3,3 +3,8 @@ release
lib/nodecg lib/nodecg
node_modules node_modules
package-lock.json package-lock.json
.corepack
.electron-cache
.localappdata
.npm-cache
.npm-runtime-cache
+34 -36
View File
@@ -1,53 +1,51 @@
# scoreko-electron # 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` From the repository root:
- Dependencies installed with `npm install`
## Available scripts ```powershell
pnpm install
```
### Development Then from `scoreko-electron-dev`:
- `npm run dev`: compiles in watch mode and opens Electron. ```powershell
- `npm run watch`: TypeScript watch mode. npm install
- `npm run dev:electron`: opens Electron when `dist/main/main.js` is ready. npm run dist:win
- `npm run start`: full build and local run. ```
### 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`. ## What the build does
- `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.
### 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`). ## Runtime behavior
- `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.
### 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. ## Useful scripts
- `npm run rebuild:better-sqlite3`: rebuilds only `better-sqlite3` for Electron.
## 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`. ## Configuration
2. Adjust variables for your environment.
3. Run `npm run doctor` before developing or packaging.
## References The defaults match the parent bundle:
- Troubleshooting: `docs/troubleshooting.md` - `NODECG_BUNDLE_NAME=scoreko-dev`
- Architecture: `docs/architecture.md` - `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.
+6 -4
View File
@@ -3,14 +3,16 @@
## 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. Creates windows (`windows/window-factory.ts`). 2. Copies the packaged NodeCG runtime from app resources to user data when needed (`nodecg/runtime-provisioner.ts`).
3. Starts NodeCG with `nodecg/process-manager.ts`. 3. Creates windows (`windows/window-factory.ts`).
4. Waits for HTTP readiness and shows loading -> main dashboard. 4. Starts NodeCG with `nodecg/process-manager.ts`.
5. On shutdown, runs a single graceful-stop flow to avoid orphan processes. 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 ## 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/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.
+12 -7
View File
@@ -1,14 +1,19 @@
# Troubleshooting # Troubleshooting
## `NodeCG folder does not exist` ## `The packaged NodeCG runtime is incomplete`
- Verify `lib/nodecg` exists. - Run `npm run prepare:runtime` from `scoreko-electron-dev`.
- Make sure the project contains a full NodeCG installation. - 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` ## `No read/write permissions on NodeCG`
- Adjust permissions on `lib/nodecg` for the user running Electron. - Installed builds run NodeCG from the user's app data folder, so this usually means the local development copy is locked.
- On Linux/macOS: `chmod -R u+rw lib/nodecg` (according to your local policy). - Close any running Scoreko/NodeCG process and run `npm run start` again.
## `Port <PORT> is already in use` ## `Port <PORT> is already in use`
@@ -17,9 +22,9 @@
## `Timeout while waiting for NodeCG` ## `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. - 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 ## macOS build fails because of icon
+11 -1
View File
@@ -3,7 +3,17 @@ import tsParser from "@typescript-eslint/parser";
export default [ export default [
{ {
ignores: ["dist/**", "release/**", "lib/**"], ignores: [
"dist/**",
"release/**",
"lib/**",
"node_modules/**",
".corepack/**",
".electron-cache/**",
".localappdata/**",
".npm-cache/**",
".npm-runtime-cache/**",
],
}, },
{ {
files: ["**/*.ts"], files: ["**/*.ts"],
+12 -8
View File
@@ -10,9 +10,12 @@
"private": true, "private": true,
"main": "dist/main/main.js", "main": "dist/main/main.js",
"scripts": { "scripts": {
"clean": "rimraf dist release", "clean": "rimraf dist release lib",
"typecheck": "tsc --noEmit", "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 .", "start": "npm run build && electron .",
"dev": "concurrently -k \"npm:watch\" \"npm:dev:electron\"", "dev": "concurrently -k \"npm:watch\" \"npm:dev:electron\"",
"watch": "tsc -p tsconfig.json --watch", "watch": "tsc -p tsconfig.json --watch",
@@ -20,16 +23,16 @@
"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", "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", "doctor": "node scripts/doctor.mjs",
"lint": "eslint . --ext .ts,.js,.mjs", "lint": "eslint . --ext .ts,.js,.mjs",
"lint:fix": "npm run lint -- --fix", "lint:fix": "npm run lint -- --fix",
"format": "prettier --check .", "format": "prettier --check .",
"format:write": "prettier --write .", "format:write": "prettier --write .",
"dist:win": "npm run build && electron-builder --win", "dist:win": "npm run build && npm run rebuild:native && electron-builder --win",
"dist:linux": "npm run build && electron-builder --linux AppImage", "dist:linux": "npm run build && npm run rebuild:native && electron-builder --linux AppImage",
"dist:all": "npm run build && electron-builder --win --linux --mac", "dist:all": "npm run build && npm run rebuild:native && electron-builder --win --linux --mac",
"dist:mac": "npm run build && electron-builder --mac" "dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac"
}, },
"build": { "build": {
"appId": "com.scoreko.desktop", "appId": "com.scoreko.desktop",
@@ -71,7 +74,8 @@
"nsis" "nsis"
], ],
"icon": "static/icons/icon.ico", "icon": "static/icons/icon.ico",
"executableName": "scoreko" "executableName": "scoreko",
"signAndEditExecutable": false
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
+77
View File
@@ -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"]);
+9 -5
View File
@@ -37,16 +37,20 @@ function parseIntInRange(name, fallback, min, max) {
function checkNodecgInstall() { function checkNodecgInstall() {
const indexPath = path.join(nodecgRootPath, "index.js"); 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 bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim();
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName); const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
addCheck(fs.existsSync(nodecgRootPath), "NodeCG root", nodecgRootPath); addCheck(fs.existsSync(nodecgRootPath), "Packaged NodeCG runtime", nodecgRootPath);
addCheck(fs.existsSync(indexPath), "NodeCG index.js", indexPath); addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath);
addCheck(fs.existsSync(bundlePath), `Bundle '${bundleName}'`, bundlePath); addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath);
addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath);
addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath);
try { try {
fs.accessSync(nodecgRootPath, fs.constants.R_OK | fs.constants.W_OK); 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 { } catch {
addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg"); addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg");
} }
@@ -82,7 +86,7 @@ async function main() {
} }
for (const check of checks) { for (const check of checks) {
const icon = check.ok ? "" : ""; const icon = check.ok ? "OK" : "FAIL";
console.log(`${icon} ${check.title}: ${check.details}`); console.log(`${icon} ${check.title}: ${check.details}`);
} }
+183
View File
@@ -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);
}
+21 -20
View File
@@ -1,15 +1,20 @@
import { existsSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
const root = process.cwd(); const root = process.cwd();
const nodecgDir = path.join(root, "lib", "nodecg"); 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) { if (!existsSync(path.join(nodecgDir, "package.json"))) {
console.error("No NodeCG package folders found. Expected lib/nodecg and/or workspaces."); console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first.");
process.exit(1); process.exit(1);
} }
@@ -23,8 +28,10 @@ function run(command, args, cwd) {
env: { env: {
...process.env, ...process.env,
npm_config_runtime: "electron", npm_config_runtime: "electron",
npm_config_target: "39.5.1", npm_config_target: electronVersion,
npm_config_disturl: "https://electronjs.org/headers", 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) { console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgDir}`);
if (dir === sqliteLegacyDir) { await run(npmCommand, [
console.log(`\n[rebuild-native] Ensuring sqlite legacy workspace deps in: ${dir}`); "rebuild",
await run("npm", ["install"], dir); "better-sqlite3",
await run("npm", ["install", "bindings", "--no-save"], dir); "--runtime=electron",
} `--target=${electronVersion}`,
"--dist-url=https://electronjs.org/headers",
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 in: ${dir}`); ], nodecgDir);
await run(
"npm",
["rebuild", "better-sqlite3", "--runtime=electron", "--target=39.5.1", "--dist-url=https://electronjs.org/headers"],
dir,
);
}
console.log("\n[rebuild-native] Done."); console.log("\n[rebuild-native] Done.");
+1 -1
View File
@@ -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_BACKGROUND = "#0f0f0f";
export const DEFAULT_WINDOW_SIZE = { export const DEFAULT_WINDOW_SIZE = {
+22 -12
View File
@@ -3,7 +3,8 @@ import path from "node:path";
import { getRuntimeConfig } from "./config/runtime-config"; import { getRuntimeConfig } from "./config/runtime-config";
import { showFatalError, log } from "./errors/error-presenter"; 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 { getRemainingDelayMs } from "./utils/timing";
import { createLoadingWindow, createMainWindow } from "./windows/window-factory"; 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 isDev = !app.isPackaged;
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath; 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 mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`;
const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`; const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`;
const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`; const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`;
@@ -26,18 +27,11 @@ if (!hasSingleInstanceLock) {
app.quit(); app.quit();
} }
const nodecgManager = createNodecgProcessManager({
isDev,
nodecgRootPath,
nodecgBaseUrl,
appConfig,
log,
});
type AppShutdownState = "running" | "stopping" | "stopped"; type AppShutdownState = "running" | "stopping" | "stopped";
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let loadingWindow: BrowserWindow | null = null; let loadingWindow: BrowserWindow | null = null;
let nodecgManager: NodecgProcessManager | null = null;
let shutdownState: AppShutdownState = "running"; let shutdownState: AppShutdownState = "running";
function focusExistingWindow(): void { function focusExistingWindow(): void {
@@ -56,6 +50,22 @@ function focusExistingWindow(): void {
} }
async function launchApplication(): Promise<void> { async function launchApplication(): Promise<void> {
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. // We create both windows early so startup feels instant while NodeCG is booting in the background.
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl }); mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
loadingWindow = createLoadingWindow({ appConfig, rootPath }); loadingWindow = createLoadingWindow({ appConfig, rootPath });
@@ -110,12 +120,12 @@ function stopNodecgGracefully(): Promise<void> {
} }
if (shutdownState === "stopping") { if (shutdownState === "stopping") {
return nodecgManager.stopNodecgProcessGracefully(); return nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve();
} }
shutdownState = "stopping"; shutdownState = "stopping";
return nodecgManager.stopNodecgProcessGracefully().finally(() => { return (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve()).finally(() => {
shutdownState = "stopped"; shutdownState = "stopped";
}); });
} }
+5 -5
View File
@@ -121,7 +121,7 @@ export function createNodecgProcessManager({
exitDetails, exitDetails,
stderrDetails, stderrDetails,
`NodeCG path: ${nodecgRootPath}`, `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"), ].join("\n"),
); );
} }
@@ -234,7 +234,7 @@ function validateNodecgInstall(
} }
if (!pathExists(indexPath)) { 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)) { if (!pathExists(nodecgBootstrapPath)) {
@@ -242,8 +242,8 @@ function validateNodecgInstall(
[ [
"NodeCG is present but internal dependencies are missing.", "NodeCG is present but internal dependencies are missing.",
`Not found: ${nodecgBootstrapPath}`, `Not found: ${nodecgBootstrapPath}`,
"Solution: enter lib/nodecg and install dependencies:", "Solution: rebuild the packaged runtime:",
" npm install", " npm run prepare:runtime",
].join("\n"), ].join("\n"),
); );
} }
@@ -253,7 +253,7 @@ function validateNodecgInstall(
[ [
`Bundle '${bundleName}' was not found.`, `Bundle '${bundleName}' was not found.`,
`Expected path: ${bundlePath}`, `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"), ].join("\n"),
); );
} }
+179
View File
@@ -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<RuntimeProvisionerDeps>;
};
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>): 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<RuntimeProvisionerDeps, "existsSync" | "readFileSync">): RuntimeManifest | null {
if (!deps.existsSync(filePath)) {
return null;
}
try {
return JSON.parse(String(deps.readFileSync(filePath)));
} catch {
return null;
}
}
+154
View File
@@ -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<string>;
files: Map<string, string>;
removed: string[];
copied: Array<{ from: string; to: string }>;
};
function createFakeFs(initialPaths: string[] = [], initialFiles: Record<string, string> = {}) {
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")));
});