mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
Merge pull request #40 from Pandipipas/refactor-project-scoreko-dev-for-node-24
Refactor: run NodeCG from lib/scoreko-dev and align for Node 24
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# scoreko-electron
|
# scoreko-electron
|
||||||
|
|
||||||
Desktop app (Electron + TypeScript) to run and package a NodeCG installation with the `scoreko-dev` bundle.
|
Desktop app (Electron + TypeScript) to run and package `scoreko-dev` (Electron host + NodeCG started inside `lib/scoreko-dev`).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ Desktop app (Electron + TypeScript) to run and package a NodeCG installation wit
|
|||||||
|
|
||||||
### Native modules
|
### Native modules
|
||||||
|
|
||||||
- `npm run rebuild:native`: rebuilds NodeCG native modules.
|
- `npm run rebuild:native`: rebuilds scoreko-dev native modules (including NodeCG dependency).
|
||||||
- `npm run rebuild:better-sqlite3`: rebuilds only `better-sqlite3` for Electron.
|
- `npm run rebuild:better-sqlite3`: rebuilds only `better-sqlite3` for Electron.
|
||||||
|
|
||||||
## Quick setup
|
## Quick setup
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
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. Creates windows (`windows/window-factory.ts`).
|
||||||
3. Starts NodeCG with `nodecg/process-manager.ts`.
|
3. Starts NodeCG with `nodecg/process-manager.ts` by running `npx nodecg start` from `lib/scoreko-dev`.
|
||||||
4. Waits for HTTP readiness and shows loading -> main dashboard.
|
4. Waits for HTTP readiness and shows loading -> main dashboard.
|
||||||
5. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
|
5. 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/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 in `lib/scoreko-dev`.
|
||||||
- `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.
|
||||||
- `errors/error-presenter.ts`: fatal error presentation.
|
- `errors/error-presenter.ts`: fatal error presentation.
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
## `NodeCG folder does not exist`
|
## `Scoreko app folder does not exist`
|
||||||
|
|
||||||
- Verify `lib/nodecg` exists.
|
- Verify `lib/scoreko-dev` exists.
|
||||||
- Make sure the project contains a full NodeCG installation.
|
- Make sure the project contains the scoreko-dev bundle root with its own `package.json`.
|
||||||
|
|
||||||
## `No read/write permissions on NodeCG`
|
## `No read/write permissions on scoreko app folder`
|
||||||
|
|
||||||
- Adjust permissions on `lib/nodecg` for the user running Electron.
|
- Adjust permissions on `lib/scoreko-dev` for the user running Electron.
|
||||||
- On Linux/macOS: `chmod -R u+rw lib/nodecg` (according to your local policy).
|
- On Linux/macOS: `chmod -R u+rw lib/scoreko-dev` (according to your local policy).
|
||||||
|
|
||||||
## `Port <PORT> is already in use`
|
## `Port <PORT> is already in use`
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
- Check NodeCG logs in standard output.
|
- Check NodeCG logs in standard output.
|
||||||
- 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`).
|
- Verify scoreko-dev dependencies (`cd lib/scoreko-dev && npm install`).
|
||||||
|
|
||||||
## macOS build fails because of icon
|
## macOS build fails because of icon
|
||||||
|
|
||||||
|
|||||||
Generated
+14
-14
@@ -10,11 +10,11 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/rebuild": "^3.7.1",
|
"@electron/rebuild": "^3.7.1",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^24.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||||
"@typescript-eslint/parser": "^8.22.0",
|
"@typescript-eslint/parser": "^8.22.0",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"electron": "39.5.1",
|
"electron": "40.6.1",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^25.1.8",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"wait-on": "^8.0.1"
|
"wait-on": "^8.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=24.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@develar/schema-utils": {
|
"node_modules/@develar/schema-utils": {
|
||||||
@@ -975,13 +975,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.11",
|
"version": "24.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz",
|
||||||
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
"integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/plist": {
|
"node_modules/@types/plist": {
|
||||||
@@ -2913,15 +2913,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron": {
|
"node_modules/electron": {
|
||||||
"version": "39.5.1",
|
"version": "40.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-39.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-40.6.1.tgz",
|
||||||
"integrity": "sha512-6s/sBQar+bbW59XSqohZj04MPic+kdVUAWjLbfQB/uLOeNw9jWX5FHaTxpHK29Xp3mKOHef7wErsjwMyCuWltg==",
|
"integrity": "sha512-u9YfoixttdauciHV9Ut9Zf3YipJoU093kR1GSYTTXTAXqhiXI0G1A0NnL/f0O2m2UULCXaXMf2W71PloR6V9pQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/get": "^2.0.0",
|
"@electron/get": "^2.0.0",
|
||||||
"@types/node": "^22.7.7",
|
"@types/node": "^24.9.0",
|
||||||
"extract-zip": "^2.0.1"
|
"extract-zip": "^2.0.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -6286,9 +6286,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-4
@@ -19,7 +19,7 @@
|
|||||||
"dev:electron": "wait-on dist/main/main.js && electron .",
|
"dev:electron": "wait-on dist/main/main.js && electron .",
|
||||||
"pack": "npm run build && electron-builder --dir",
|
"pack": "npm run build && electron-builder --dir",
|
||||||
"rebuild:native": "node scripts/rebuild-nodecg-native.mjs",
|
"rebuild:native": "node scripts/rebuild-nodecg-native.mjs",
|
||||||
"rebuild:better-sqlite3": "electron-rebuild --version 40.6.1 --module-dir lib/nodecg/workspaces/database-adapter-sqlite-legacy --only better-sqlite3 -f",
|
"rebuild:better-sqlite3": "electron-rebuild --version 40.6.1 --module-dir lib/scoreko-dev/workspaces/database-adapter-sqlite-legacy --only better-sqlite3 -f",
|
||||||
"test": "npm run build && node --test dist/tests/**/*.test.js",
|
"test": "npm run build && 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",
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "lib/nodecg",
|
"from": "lib/scoreko-dev",
|
||||||
"to": "lib/nodecg"
|
"to": "lib/scoreko-dev"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "static",
|
"from": "static",
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/rebuild": "^3.7.1",
|
"@electron/rebuild": "^3.7.1",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^24.3.0",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"electron": "40.6.1",
|
"electron": "40.6.1",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^25.1.8",
|
||||||
|
|||||||
+24
-10
@@ -4,7 +4,7 @@ import net from "node:net";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
const nodecgRootPath = path.resolve(cwd, "lib", "nodecg");
|
const scorekoRootPath = path.resolve(cwd, "lib", "scoreko-dev");
|
||||||
|
|
||||||
const checks = [];
|
const checks = [];
|
||||||
|
|
||||||
@@ -36,19 +36,33 @@ function parseIntInRange(name, fallback, min, max) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkNodecgInstall() {
|
function checkNodecgInstall() {
|
||||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
const packageJsonPath = path.join(scorekoRootPath, "package.json");
|
||||||
const bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim();
|
const nodecgDependencyPath = path.join(scorekoRootPath, "node_modules", "nodecg", "package.json");
|
||||||
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
|
const nodecgCliPath = path.join(
|
||||||
|
scorekoRootPath,
|
||||||
|
"node_modules",
|
||||||
|
".bin",
|
||||||
|
process.platform === "win32" ? "nodecg.cmd" : "nodecg",
|
||||||
|
);
|
||||||
|
const bundleAssetPaths = ["dashboard", "graphics", "extension", "extensions"].map((folder) =>
|
||||||
|
path.join(scorekoRootPath, folder),
|
||||||
|
);
|
||||||
|
|
||||||
addCheck(fs.existsSync(nodecgRootPath), "NodeCG root", nodecgRootPath);
|
addCheck(fs.existsSync(scorekoRootPath), "Scoreko root", scorekoRootPath);
|
||||||
addCheck(fs.existsSync(indexPath), "NodeCG index.js", indexPath);
|
addCheck(fs.existsSync(packageJsonPath), "scoreko-dev package.json", packageJsonPath);
|
||||||
addCheck(fs.existsSync(bundlePath), `Bundle '${bundleName}'`, bundlePath);
|
addCheck(fs.existsSync(nodecgDependencyPath), "NodeCG dependency", nodecgDependencyPath);
|
||||||
|
addCheck(fs.existsSync(nodecgCliPath), "NodeCG CLI", nodecgCliPath);
|
||||||
|
addCheck(
|
||||||
|
bundleAssetPaths.some((candidatePath) => fs.existsSync(candidatePath)),
|
||||||
|
"scoreko-dev bundle assets",
|
||||||
|
`Expected one of: ${bundleAssetPaths.join(", ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.accessSync(nodecgRootPath, fs.constants.R_OK | fs.constants.W_OK);
|
fs.accessSync(scorekoRootPath, fs.constants.R_OK | fs.constants.W_OK);
|
||||||
addCheck(true, "lib/nodecg permissions", "Read/write OK");
|
addCheck(true, "lib/scoreko-dev permissions", "Read/write OK");
|
||||||
} catch {
|
} catch {
|
||||||
addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg");
|
addCheck(false, "lib/scoreko-dev permissions", "No read/write permissions in lib/scoreko-dev");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ 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 scorekoDir = path.join(root, "lib", "scoreko-dev");
|
||||||
const sqliteLegacyDir = path.join(nodecgDir, "workspaces", "database-adapter-sqlite-legacy");
|
const sqliteLegacyDir = path.join(scorekoDir, "workspaces", "database-adapter-sqlite-legacy");
|
||||||
|
|
||||||
const moduleDirs = [nodecgDir, sqliteLegacyDir].filter((dir) => existsSync(path.join(dir, "package.json")));
|
const moduleDirs = [scorekoDir, sqliteLegacyDir].filter((dir) => existsSync(path.join(dir, "package.json")));
|
||||||
|
|
||||||
if (moduleDirs.length === 0) {
|
if (moduleDirs.length === 0) {
|
||||||
console.error("No NodeCG package folders found. Expected lib/nodecg and/or workspaces.");
|
console.error("No package folders found. Expected lib/scoreko-dev and/or workspaces.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
|
|||||||
|
|
||||||
const parsedValue = Number.parseInt(rawValue, 10);
|
const parsedValue = Number.parseInt(rawValue, 10);
|
||||||
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
|
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
|
||||||
throw new Error(`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`);
|
throw new Error(
|
||||||
|
`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsedValue;
|
return parsedValue;
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,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 nodecgRootPath = path.resolve(rootPath, "lib", "scoreko-dev");
|
||||||
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}`;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ type NodecgProcessManagerDeps = {
|
|||||||
pathExists: (candidatePath: string) => boolean;
|
pathExists: (candidatePath: string) => boolean;
|
||||||
fetchUrl: typeof fetch;
|
fetchUrl: typeof fetch;
|
||||||
platform: NodeJS.Platform;
|
platform: NodeJS.Platform;
|
||||||
execPath: string;
|
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
killProcess: (pid: number, signal: NodeJS.Signals) => void;
|
killProcess: (pid: number, signal: NodeJS.Signals) => void;
|
||||||
setTimer: (handler: () => void, timeoutMs: number) => unknown;
|
setTimer: (handler: () => void, timeoutMs: number) => unknown;
|
||||||
@@ -53,10 +52,9 @@ export function createNodecgProcessManager({
|
|||||||
let lastStderrLine: string | null = null;
|
let lastStderrLine: string | null = null;
|
||||||
|
|
||||||
const startNodecgProcess = async (): Promise<ChildProcess> => {
|
const startNodecgProcess = async (): Promise<ChildProcess> => {
|
||||||
// Fail fast with actionable errors before spawning child processes.
|
|
||||||
validateNodecgInstall(
|
validateNodecgInstall(
|
||||||
nodecgRootPath,
|
nodecgRootPath,
|
||||||
appConfig.bundleName,
|
resolvedDeps.platform,
|
||||||
resolvedDeps.pathExists,
|
resolvedDeps.pathExists,
|
||||||
resolvedDeps.hasReadWriteAccess,
|
resolvedDeps.hasReadWriteAccess,
|
||||||
);
|
);
|
||||||
@@ -69,18 +67,17 @@ export function createNodecgProcessManager({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
const command = resolvedDeps.platform === "win32" ? "npx.cmd" : "npx";
|
||||||
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
|
const child = resolvedDeps.spawnProcess(command, ["nodecg", "start"], {
|
||||||
cwd: nodecgRootPath,
|
cwd: nodecgRootPath,
|
||||||
env: {
|
env: {
|
||||||
...resolvedDeps.env,
|
...resolvedDeps.env,
|
||||||
NODE_ENV: isDev ? "development" : "production",
|
NODE_ENV: isDev ? "development" : "production",
|
||||||
NODECG_PORT: appConfig.nodecgPort,
|
NODECG_PORT: appConfig.nodecgPort,
|
||||||
ELECTRON_RUN_AS_NODE: "1",
|
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
detached: resolvedDeps.platform !== "win32",
|
detached: resolvedDeps.platform !== "win32",
|
||||||
shell: resolvedDeps.platform === "win32",
|
shell: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stdout?.on("data", (chunk) => {
|
child.stdout?.on("data", (chunk) => {
|
||||||
@@ -108,7 +105,6 @@ export function createNodecgProcessManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const waitForNodecgReady = async (startTime: number): Promise<void> => {
|
const waitForNodecgReady = async (startTime: number): Promise<void> => {
|
||||||
// Poll the local NodeCG URL until it answers or we hit the configured timeout.
|
|
||||||
while (Date.now() - startTime < appConfig.startupTimeoutMs) {
|
while (Date.now() - startTime < appConfig.startupTimeoutMs) {
|
||||||
if (!nodecgProcess) {
|
if (!nodecgProcess) {
|
||||||
const exitDetails = lastExit
|
const exitDetails = lastExit
|
||||||
@@ -121,7 +117,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 lib/scoreko-dev dependencies are installed and the bundle exists.",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -142,7 +138,6 @@ export function createNodecgProcessManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stopNodecgProcessGracefully = (): Promise<void> => {
|
const stopNodecgProcessGracefully = (): Promise<void> => {
|
||||||
// Reuse the same stop promise to avoid sending multiple kill signals during app shutdown.
|
|
||||||
if (stopNodecgPromise) {
|
if (stopNodecgPromise) {
|
||||||
return stopNodecgPromise;
|
return stopNodecgPromise;
|
||||||
}
|
}
|
||||||
@@ -204,7 +199,6 @@ function resolveDeps(deps?: Partial<NodecgProcessManagerDeps>): NodecgProcessMan
|
|||||||
pathExists: deps?.pathExists ?? fs.existsSync,
|
pathExists: deps?.pathExists ?? fs.existsSync,
|
||||||
fetchUrl: deps?.fetchUrl ?? fetch,
|
fetchUrl: deps?.fetchUrl ?? fetch,
|
||||||
platform: deps?.platform ?? process.platform,
|
platform: deps?.platform ?? process.platform,
|
||||||
execPath: deps?.execPath ?? process.execPath,
|
|
||||||
env: deps?.env ?? process.env,
|
env: deps?.env ?? process.env,
|
||||||
killProcess: deps?.killProcess ?? process.kill,
|
killProcess: deps?.killProcess ?? process.kill,
|
||||||
setTimer: deps?.setTimer ?? setTimeout,
|
setTimer: deps?.setTimer ?? setTimeout,
|
||||||
@@ -217,43 +211,51 @@ function resolveDeps(deps?: Partial<NodecgProcessManagerDeps>): NodecgProcessMan
|
|||||||
|
|
||||||
function validateNodecgInstall(
|
function validateNodecgInstall(
|
||||||
nodecgRootPath: string,
|
nodecgRootPath: string,
|
||||||
bundleName: string,
|
platform: NodeJS.Platform,
|
||||||
pathExists: (candidatePath: string) => boolean,
|
pathExists: (candidatePath: string) => boolean,
|
||||||
hasReadWriteAccessToPath: (candidatePath: string) => boolean,
|
hasReadWriteAccessToPath: (candidatePath: string) => boolean,
|
||||||
): void {
|
): void {
|
||||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
const packageJsonPath = path.join(nodecgRootPath, "package.json");
|
||||||
const nodecgBootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
const nodecgDependencyPath = path.join(nodecgRootPath, "node_modules", "nodecg", "package.json");
|
||||||
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
|
const nodecgCliPath = path.join(
|
||||||
|
nodecgRootPath,
|
||||||
|
"node_modules",
|
||||||
|
".bin",
|
||||||
|
platform === "win32" ? "nodecg.cmd" : "nodecg",
|
||||||
|
);
|
||||||
|
const bundleAssetDirs = ["dashboard", "graphics", "extension", "extensions"].map((dir) =>
|
||||||
|
path.join(nodecgRootPath, dir),
|
||||||
|
);
|
||||||
|
|
||||||
if (!pathExists(nodecgRootPath)) {
|
if (!pathExists(nodecgRootPath)) {
|
||||||
throw new Error(`NodeCG folder does not exist: ${nodecgRootPath}`);
|
throw new Error(`Scoreko app folder does not exist: ${nodecgRootPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasReadWriteAccessToPath(nodecgRootPath)) {
|
if (!hasReadWriteAccessToPath(nodecgRootPath)) {
|
||||||
throw new Error(`No read/write permissions on NodeCG: ${nodecgRootPath}`);
|
throw new Error(`No read/write permissions on scoreko app folder: ${nodecgRootPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pathExists(indexPath)) {
|
if (!pathExists(packageJsonPath)) {
|
||||||
throw new Error(`${indexPath} was not found. Copy a full NodeCG installation into lib/nodecg.`);
|
throw new Error(`${packageJsonPath} was not found. Expected a NodeCG bundle app at lib/scoreko-dev.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pathExists(nodecgBootstrapPath)) {
|
if (!pathExists(nodecgDependencyPath) || !pathExists(nodecgCliPath)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
[
|
[
|
||||||
"NodeCG is present but internal dependencies are missing.",
|
"NodeCG dependency is missing in lib/scoreko-dev.",
|
||||||
`Not found: ${nodecgBootstrapPath}`,
|
`Not found: ${nodecgDependencyPath} and/or ${nodecgCliPath}`,
|
||||||
"Solution: enter lib/nodecg and install dependencies:",
|
"Solution: enter lib/scoreko-dev and install dependencies:",
|
||||||
" npm install",
|
" npm install",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pathExists(bundlePath)) {
|
if (!bundleAssetDirs.some((candidatePath) => pathExists(candidatePath))) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
[
|
[
|
||||||
`Bundle '${bundleName}' was not found.`,
|
"scoreko-dev bundle appears incomplete.",
|
||||||
`Expected path: ${bundlePath}`,
|
`Expected one of: ${bundleAssetDirs.join(", ")}`,
|
||||||
"Copy/clone your bundle inside lib/nodecg/bundles before running Electron.",
|
"Ensure extensions/dashboard/graphics assets exist inside lib/scoreko-dev.",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -270,76 +272,49 @@ function hasReadWriteAccess(candidatePath: string): boolean {
|
|||||||
|
|
||||||
function probePortAvailable(port: number): Promise<boolean> {
|
function probePortAvailable(port: number): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// A successful TCP connection means some process is already listening on the port.
|
const server = net.createServer();
|
||||||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
|
||||||
let resolved = false;
|
|
||||||
|
|
||||||
const complete = (isAvailable: boolean): void => {
|
server.once("error", () => {
|
||||||
if (resolved) {
|
resolve(false);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved = true;
|
|
||||||
socket.destroy();
|
|
||||||
resolve(isAvailable);
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.setTimeout(1000);
|
|
||||||
|
|
||||||
socket.once("connect", () => {
|
|
||||||
complete(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.once("timeout", () => {
|
server.once("listening", () => {
|
||||||
complete(true);
|
server.close(() => resolve(true));
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.once("error", (error: NodeJS.ErrnoException) => {
|
server.listen(port, "127.0.0.1");
|
||||||
if (error.code === "ECONNREFUSED" || error.code === "EHOSTUNREACH") {
|
|
||||||
complete(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
complete(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function killNodecgProcessTree(
|
|
||||||
pid: number,
|
|
||||||
signal: NodeJS.Signals,
|
|
||||||
log: (...args: unknown[]) => void,
|
|
||||||
deps: Pick<NodecgProcessManagerDeps, "platform" | "spawnProcess" | "killProcess">,
|
|
||||||
): boolean {
|
|
||||||
if (deps.platform === "win32") {
|
|
||||||
const force = signal === "SIGKILL" ? "/F" : "";
|
|
||||||
const killer = deps.spawnProcess("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
|
|
||||||
stdio: "ignore",
|
|
||||||
shell: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
killer.on("error", (error) => {
|
|
||||||
log(`taskkill error for pid=${pid}`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
deps.killProcess(-pid, signal);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
deps.killProcess(pid, signal);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
|
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimer(resolve, ms);
|
setTimer(resolve, ms);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function killNodecgProcessTree(
|
||||||
|
pid: number,
|
||||||
|
signal: NodeJS.Signals,
|
||||||
|
log: (...args: unknown[]) => void,
|
||||||
|
deps: Pick<NodecgProcessManagerDeps, "platform" | "killProcess">,
|
||||||
|
): void {
|
||||||
|
if (deps.platform === "win32") {
|
||||||
|
try {
|
||||||
|
deps.killProcess(pid, signal);
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error sending ${signal} to pid=${pid}`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
deps.killProcess(-pid, signal);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
deps.killProcess(pid, signal);
|
||||||
|
} catch (error) {
|
||||||
|
log(`Error sending ${signal} to pid=${pid}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function getBaseConfig(): AppRuntimeConfig {
|
|||||||
test("startNodeCG validates NodeCG installation before starting", async () => {
|
test("startNodeCG validates NodeCG installation before starting", async () => {
|
||||||
const manager = createNodecgProcessManager({
|
const manager = createNodecgProcessManager({
|
||||||
isDev: true,
|
isDev: true,
|
||||||
nodecgRootPath: "/fake/nodecg",
|
nodecgRootPath: "/fake/scoreko-dev",
|
||||||
nodecgBaseUrl: "http://127.0.0.1:9090",
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
appConfig: getBaseConfig(),
|
appConfig: getBaseConfig(),
|
||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
@@ -49,13 +49,13 @@ test("startNodeCG validates NodeCG installation before starting", async () => {
|
|||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await manager.startNodecgProcess();
|
await manager.startNodecgProcess();
|
||||||
}, /NodeCG folder does not exist/);
|
}, /Scoreko app folder does not exist/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("startNodeCG fails when there are no read/write permissions", async () => {
|
test("startNodeCG fails when there are no read/write permissions", async () => {
|
||||||
const manager = createNodecgProcessManager({
|
const manager = createNodecgProcessManager({
|
||||||
isDev: true,
|
isDev: true,
|
||||||
nodecgRootPath: "/fake/nodecg",
|
nodecgRootPath: "/fake/scoreko-dev",
|
||||||
nodecgBaseUrl: "http://127.0.0.1:9090",
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
appConfig: getBaseConfig(),
|
appConfig: getBaseConfig(),
|
||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
@@ -67,14 +67,14 @@ test("startNodeCG fails when there are no read/write permissions", async () => {
|
|||||||
|
|
||||||
await assert.rejects(async () => {
|
await assert.rejects(async () => {
|
||||||
await manager.startNodecgProcess();
|
await manager.startNodecgProcess();
|
||||||
}, /No read\/write permissions on NodeCG/);
|
}, /No read\/write permissions on scoreko app folder/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("waitForNodeCGReady resolves when endpoint returns 404", async () => {
|
test("waitForNodeCGReady resolves when endpoint returns 404", async () => {
|
||||||
const child = new MockChildProcess(4321);
|
const child = new MockChildProcess(4321);
|
||||||
const manager = createNodecgProcessManager({
|
const manager = createNodecgProcessManager({
|
||||||
isDev: true,
|
isDev: true,
|
||||||
nodecgRootPath: "/fake/nodecg",
|
nodecgRootPath: "/fake/scoreko-dev",
|
||||||
nodecgBaseUrl: "http://127.0.0.1:9090",
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
appConfig: getBaseConfig(),
|
appConfig: getBaseConfig(),
|
||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
@@ -107,7 +107,7 @@ test("stopNodeCG sends SIGTERM and then SIGKILL if the process does not exit", a
|
|||||||
|
|
||||||
const manager = createNodecgProcessManager({
|
const manager = createNodecgProcessManager({
|
||||||
isDev: true,
|
isDev: true,
|
||||||
nodecgRootPath: "/fake/nodecg",
|
nodecgRootPath: "/fake/scoreko-dev",
|
||||||
nodecgBaseUrl: "http://127.0.0.1:9090",
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
appConfig: getBaseConfig(),
|
appConfig: getBaseConfig(),
|
||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
@@ -153,7 +153,7 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
|
|||||||
|
|
||||||
const manager = createNodecgProcessManager({
|
const manager = createNodecgProcessManager({
|
||||||
isDev: true,
|
isDev: true,
|
||||||
nodecgRootPath: "/fake/nodecg",
|
nodecgRootPath: "/fake/scoreko-dev",
|
||||||
nodecgBaseUrl: "http://127.0.0.1:9090",
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
appConfig: getBaseConfig(),
|
appConfig: getBaseConfig(),
|
||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
@@ -186,7 +186,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
|
|||||||
|
|
||||||
const manager = createNodecgProcessManager({
|
const manager = createNodecgProcessManager({
|
||||||
isDev: true,
|
isDev: true,
|
||||||
nodecgRootPath: "/fake/nodecg",
|
nodecgRootPath: "/fake/scoreko-dev",
|
||||||
nodecgBaseUrl: "http://127.0.0.1:9090",
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
appConfig: {
|
appConfig: {
|
||||||
...getBaseConfig(),
|
...getBaseConfig(),
|
||||||
@@ -222,7 +222,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
|
|||||||
test("startNodeCG fails if the port is already in use", async () => {
|
test("startNodeCG fails if the port is already in use", async () => {
|
||||||
const manager = createNodecgProcessManager({
|
const manager = createNodecgProcessManager({
|
||||||
isDev: true,
|
isDev: true,
|
||||||
nodecgRootPath: "/fake/nodecg",
|
nodecgRootPath: "/fake/scoreko-dev",
|
||||||
nodecgBaseUrl: "http://127.0.0.1:9090",
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
appConfig: getBaseConfig(),
|
appConfig: getBaseConfig(),
|
||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
@@ -242,7 +242,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
|
|||||||
const child = new MockChildProcess(4242);
|
const child = new MockChildProcess(4242);
|
||||||
const manager = createNodecgProcessManager({
|
const manager = createNodecgProcessManager({
|
||||||
isDev: true,
|
isDev: true,
|
||||||
nodecgRootPath: "/fake/nodecg",
|
nodecgRootPath: "/fake/scoreko-dev",
|
||||||
nodecgBaseUrl: "http://127.0.0.1:9090",
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
appConfig: getBaseConfig(),
|
appConfig: getBaseConfig(),
|
||||||
log: () => undefined,
|
log: () => undefined,
|
||||||
@@ -275,7 +275,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
|
|||||||
assert.ok(error instanceof Error);
|
assert.ok(error instanceof Error);
|
||||||
assert.match(error.message, /NodeCG exited before becoming ready/);
|
assert.match(error.message, /NodeCG exited before becoming ready/);
|
||||||
assert.match(error.message, /Last recorded exit/);
|
assert.match(error.message, /Last recorded exit/);
|
||||||
assert.match(error.message, /NodeCG path: \/fake\/nodecg/);
|
assert.match(error.message, /NodeCG path: \/fake\/scoreko-dev/);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user