Compare commits

..

9 Commits

Author SHA1 Message Date
Pandipipas 707038a22c Merge pull request #41 from Pandipipas/add-lightweight-implementation-ideas
Expose and run preflight checks for NodeCG process manager; add sanity script and test
2026-03-03 00:08:11 +01:00
Pandipipas 9481052749 Add NodeCG preflight step and sanity script 2026-03-03 00:07:12 +01:00
Pandipipas 02b8fe71ac 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
2026-03-03 00:01:18 +01:00
Pandipipas 95791f8aee prettier fix 2026-03-03 00:00:23 +01:00
Pandipipas 73cdde3f5c chore: clean NodeCG install validation checks 2026-03-02 23:57:40 +01:00
Pandipipas eae612cb38 refactor: run NodeCG from lib/scoreko-dev for Node 24 migration 2026-03-02 22:53:22 +01:00
Pandipipas 162b0685c6 Merge pull request #38 from Pandipipas/bump-node-electron
Bump node electron
2026-03-01 15:38:42 +01:00
Pandipipas 069268bb5d fixes 2026-03-01 15:38:01 +01:00
Pandipipas 52ab750fb3 Update Electron and better-sqlite3 dependencies to version 40.6.1; adjust Node.js engine requirement to 24.14.0 in package.json and .nvmrc 2026-03-01 15:28:13 +01:00
54 changed files with 658 additions and 3924 deletions
+6 -17
View File
@@ -1,27 +1,16 @@
# SCOREKO Configuration File Template # Runtime / app
# Copy this file to '.env' in the application root and edit as needed.
# Application Information (Required)
SCOREKO_APP_TITLE=Scoreko SCOREKO_APP_TITLE=Scoreko
SCOREKO_APP_USER_MODEL_ID=com.scoreko.desktop SCOREKO_APP_USER_MODEL_ID=com.scoreko.desktop
SCOREKO_APP_USER_DATA_DIRECTORY=scoreko SCOREKO_APP_USER_DATA_DIRECTORY=scoreko
SCOREKO_APP_ICON_PATH=static/icons/icon.ico # SCOREKO_APP_ICON_PATH=static/icons/icon.ico
# NodeCG Managed Runtime Configuration (Required) # NodeCG
NODECG_BUNDLE_NAME=scoreko-dev NODECG_BUNDLE_NAME=scoreko-dev
NODECG_PORT=9090 NODECG_PORT=9090
SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true
SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true
# Timing & Lifecycles (Required) # Timing
ELECTRON_LOAD_DELAY_MS=10000 ELECTRON_LOAD_DELAY_MS=10000
NODECG_STARTUP_TIMEOUT_MS=120000 NODECG_STARTUP_TIMEOUT_MS=30000
NODECG_KILL_TIMEOUT_MS=2500 NODECG_KILL_TIMEOUT_MS=2500
# Automated Updates Configuration (Required)
SCOREKO_UPDATES_ENABLED=true
SCOREKO_UPDATE_CHECK_DELAY_MS=5000
# Optional Update Release Source (Only required if SCOREKO_UPDATES_ENABLED is true)
SCOREKO_UPDATE_API_URL=http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest
SCOREKO_UPDATE_RELEASE_PAGE_URL=http://gitea.local/OWNER/REPO/releases
SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$
-6
View File
@@ -2,9 +2,3 @@ node_modules
dist dist
release release
lib lib
.corepack
.electron-cache
.localappdata
.npm-cache
.npm-runtime-cache
.env
+1 -1
View File
@@ -1 +1 @@
22 24.14.0
-5
View File
@@ -3,8 +3,3 @@ release
lib/nodecg lib/nodecg
node_modules node_modules
package-lock.json package-lock.json
.corepack
.electron-cache
.localappdata
.npm-cache
.npm-runtime-cache
+38 -49
View File
@@ -1,65 +1,54 @@
# Scoreko Desktop # scoreko-electron
This is the Windows desktop wrapper for Scoreko. It bundles Electron, NodeCG, and our custom `scoreko-dev` bundle into a single standalone executable. Users just double-click the installer and everything works—no Node.js, pnpm, or command line required. Desktop app (Electron + TypeScript) to run and package `scoreko-dev` (Electron host + NodeCG started inside `lib/scoreko-dev`).
## Local Development ## Requirements
If you're working on the app locally, start by installing dependencies at the repository root: - Node.js `>=24`
- Dependencies installed with `npm install`
```powershell ## Available scripts
pnpm install
```
Then, move into the wrapper folder: ### Development
```powershell - `npm run dev`: compiles in watch mode and opens Electron.
cd scoreko-electron-dev - `npm run watch`: TypeScript watch mode.
npm install - `npm run dev:electron`: opens Electron when `dist/main/main.js` is ready.
``` - `npm run start`: full build and local run.
### Useful Commands ### Build and distribution
- `npm run start`: Builds the bundle and launches Electron locally for testing. - `npm run clean`: removes `dist` and `release`.
- `npm run dist:win`: Packages everything and creates the `.exe` Windows installer in the `release/` folder. - `npm run typecheck`: validates types without emitting files.
- `npm run prepare:runtime`: Extracts a fresh NodeCG runtime from the parent bundle (useful if you changed dependencies). - `npm run build`: compiles TypeScript and copies assets.
- `npm run rebuild:native`: Rebuilds native Node modules (like SQLite) specifically for Electron's V8 engine. - `npm run pack`: generates the app without an installer (`electron-builder --dir`).
- `npm run doctor`: Runs a quick sanity check to verify your local configuration and port availability. - `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.
## How it works under the hood ### Quality and diagnostics
When you build the installer, the script automatically compiles the main `scoreko-dev` bundle, provisions a lightweight NodeCG runtime in `lib/nodecg`, and packages it as an external asset alongside the Electron app. - `npm run test`: build and tests (`node:test`).
- `npm run sanity`: runs `typecheck`, `lint`, and `test` as a quick pre-release gate.
- `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.
When a user runs Scoreko for the first time, the app copies this NodeCG runtime directly into their local AppData folder. This is a deliberate choice: it ensures that databases, configs, and logs remain fully writable, even if the user installed the app in restricted directories like `Program Files`. ### Native modules
## Auto-Updates via Gitea - `npm run rebuild:native`: rebuilds scoreko-dev native modules (including NodeCG dependency).
- `npm run rebuild:better-sqlite3`: rebuilds only `better-sqlite3` for Electron.
Scoreko supports seamless, opt-in updates through your Gitea instance. ## Quick setup
Before building your production installer, check `static/updates.json`: 1. Copy `.env.example` to `.env`.
2. Adjust variables for your environment.
3. Run `npm run doctor` before developing or packaging.
```json ## References
{
"enabled": true,
"apiUrl": "http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest",
"releasePageUrl": "http://gitea.local/OWNER/REPO/releases",
"assetPattern": "Scoreko-setup-.*\\.exe$"
}
```
**To ship an update:** - Troubleshooting: `docs/troubleshooting.md`
1. Bump the version in `package.json`. - Architecture: `docs/architecture.md`
2. Run `npm run dist:win` to generate the new installer.
3. Create a new release tag in Gitea (e.g., `v0.2.0`) and attach the `.exe`.
4. The app will detect the new version, notify the user, and handle the installation safely.
## Environment Configuration
The app ships with sensible defaults that match our development bundle:
- `NODECG_BUNDLE_NAME=scoreko-dev`
- `NODECG_PORT=9090`
- `SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true`
- `SCOREKO_UPDATES_ENABLED=true`
- `SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$`
You only need to mess with `.env.example` if you want to override these values locally while testing.
+18 -21
View File
@@ -1,27 +1,24 @@
# Main Process Architecture # Main process architecture
This document breaks down how the Electron main process is structured and what happens when the app launches. ## Startup flow
## 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` by running `npx nodecg start` from `lib/scoreko-dev`.
4. Waits for HTTP readiness and shows loading -> main dashboard.
5. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
When a user opens Scoreko, the app goes through a precise sequence to ensure NodeCG starts reliably: ## Main modules
1. **Configuration:** `src/main/main.ts` kicks things off by loading `appConfig` via `config/runtime-config.ts`. - `config/runtime-config.ts`: read/validate env vars.
2. **Runtime Provisioning:** The app checks the user's AppData directory. If the packaged NodeCG runtime is missing or outdated, it extracts a fresh copy (`nodecg/runtime-provisioner.ts`). - `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation in `lib/scoreko-dev`.
3. **Window Creation:** The initial windows (like the loading screen) are instantiated via `windows/window-factory.ts`. - `windows/window-factory.ts`: window creation and navigation policy.
4. **NodeCG Boot:** `nodecg/process-manager.ts` spawns the NodeCG process in the background. - `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes.
5. **Readiness Check:** The app continuously polls NodeCG until the HTTP server responds. Once ready, it transitions the UI from the loading screen to the main dashboard. - `errors/error-presenter.ts`: fatal error presentation.
6. **Update Check:** If updates are enabled, the app checks the configured Gitea endpoint in the background to see if a newer version is available. - `errors/logger.ts`: structured logging (`info/warn/error/debug`).
7. **Graceful Shutdown:** When the user closes the app, it triggers a unified teardown sequence to cleanly kill the NodeCG child process, preventing zombie processes from lingering in the background.
## Core Modules ## Principles
Here is where the heavy lifting happens: - Mechanical refactors first.
- Incremental hardening with conservative fallback.
- **`config/runtime-config.ts`**: Handles environment variables and defaults. - Automated validation via `typecheck`, `build`, `test`, `doctor`, `lint`.
- **`nodecg/runtime-provisioner.ts`**: Manages copying the NodeCG runtime out of the read-only Electron package into the writable user data folder.
- **`nodecg/process-manager.ts`**: Handles starting, polling, and killing the NodeCG server. It also validates ports and permissions before launching.
- **`updates/update-manager.ts`**: Coordinates the Gitea update flow (checking versions, downloading installers, prompting the user).
- **`windows/window-factory.ts`**: Centralizes window configuration and security defaults.
- **`windows/navigation-security.ts`**: Intercepts navigation events to block unauthorized domains and safely hand off external links (like docs or emails) to the user's default browser.
- **`errors/error-presenter.ts` & `errors/logger.ts`**: Manages structured logging (`electron-log`) and displaying the fallback error screen if boot fails.
+18 -34
View File
@@ -1,43 +1,27 @@
# Troubleshooting Guide # Troubleshooting
Here are some common issues you might run into while developing or using the Scoreko desktop app, along with quick fixes. ## `Scoreko app folder does not exist`
## The app says the NodeCG runtime is incomplete - Verify `lib/scoreko-dev` exists.
This usually means you haven't bundled the runtime yet. - Make sure the project contains the scoreko-dev bundle root with its own `package.json`.
- Run `npm run prepare:runtime` in the `scoreko-electron-dev` folder.
- If you haven't even installed the parent bundle, go up to the repository root and run `pnpm install` first.
## NodeCG is present but internal dependencies are missing ## `No read/write permissions on scoreko app folder`
This happens if dependencies changed or the initial copy was interrupted.
- Re-run `npm run prepare:runtime` to get a fresh copy.
- If you're seeing SQLite errors when the app launches, you probably need to run `npm run rebuild:native` to compile it for Electron's V8 engine.
## "No read/write permissions on NodeCG" - Adjust permissions on `lib/scoreko-dev` for the user running Electron.
In production, Scoreko runs NodeCG out of your AppData folder to ensure it has write access. During local development, it runs directly from the repo. - On Linux/macOS: `chmod -R u+rw lib/scoreko-dev` (according to your local policy).
If you see this permission error locally, another process probably has a file locked. Close any zombie Scoreko or Node processes and try `npm run start` again.
## Port 9090 is already in use ## `Port <PORT> is already in use`
You have another instance of NodeCG (or another web server) running on port 9090.
- Find and kill the process using the port, or change `NODECG_PORT` in your `.env` file to something else (like 9091).
- You can use `npm run doctor` to quickly test port availability.
## Timeout while waiting for NodeCG - Free the port or set `NODECG_PORT` in `.env`.
The app waits for the NodeCG HTTP server to respond. If it times out: - Use `npm run doctor` to validate availability before startup.
- Check your terminal output. NodeCG might be crashing or hanging on startup due to a bundle error.
- If your machine is just slow, you can increase `NODECG_STARTUP_TIMEOUT_MS` in the `.env` file.
## The app crashes immediately on a fresh install ## `Timeout while waiting for NodeCG`
Scoreko copies the runtime to `%AppData%\scoreko\nodecg` and relaunches itself on the very first run.
If it gets stuck in a loop or fails immediately:
- Check if your antivirus or Windows Search Indexer is aggressively locking the files in AppData as they are being copied.
- Try running `npm run rebuild:native` and then repackaging the app with `npm run dist:win`.
## macOS builds are failing complaining about an icon - Check NodeCG logs in standard output.
The `electron-builder` config explicitly looks for a Mac icon at `static/icons/icon.icns`. If you don't have one, generate it and place it there before running the macOS build. - Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow.
- Verify scoreko-dev dependencies (`cd lib/scoreko-dev && npm install`).
## Auto-updates aren't triggering ## macOS build fails because of icon
If you published a new release on Gitea but the app ignores it:
- Double check that `static/updates.json` has `"enabled": true` before you build the installer. - The configuration expects `static/icons/icon.icns`.
- Ensure your `apiUrl` points exactly to the Gitea API: `http://gitea.../api/v1/repos/<owner>/<repo>/releases/latest`. - Create that file before running macOS packaging.
- The git tag you created (e.g., `v0.2.0`) must be semantically higher than the version currently in your `package.json`.
- Make sure the installer `.exe` you uploaded to Gitea actually matches the regex in `assetPattern` (default is `Scoreko-setup-.*\.exe$`).
+1 -11
View File
@@ -3,17 +3,7 @@ import tsParser from "@typescript-eslint/parser";
export default [ export default [
{ {
ignores: [ ignores: ["dist/**", "release/**", "lib/**"],
"dist/**",
"release/**",
"lib/**",
"node_modules/**",
".corepack/**",
".electron-cache/**",
".localappdata/**",
".npm-cache/**",
".npm-runtime-cache/**",
],
}, },
{ {
files: ["**/*.ts"], files: ["**/*.ts"],
+79 -26
View File
@@ -8,15 +8,13 @@
"name": "scoreko-electron", "name": "scoreko-electron",
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": {
"electron-log": "^5.4.4"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@electron/rebuild": "^3.7.1",
"@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",
@@ -25,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": {
@@ -184,6 +182,31 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/@electron/node-gyp": {
"version": "10.2.0-electron.1",
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"glob": "^8.1.0",
"graceful-fs": "^4.2.6",
"make-fetch-happen": "^10.2.1",
"nopt": "^6.0.0",
"proc-log": "^2.0.1",
"semver": "^7.3.5",
"tar": "^6.2.1",
"which": "^2.0.2"
},
"bin": {
"node-gyp": "bin/node-gyp.js"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/@electron/notarize": { "node_modules/@electron/notarize": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
@@ -250,6 +273,35 @@
"url": "https://github.com/sponsors/gjtorikian/" "url": "https://github.com/sponsors/gjtorikian/"
} }
}, },
"node_modules/@electron/rebuild": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.2.tgz",
"integrity": "sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"@malept/cross-spawn-promise": "^2.0.0",
"chalk": "^4.0.0",
"debug": "^4.1.1",
"detect-libc": "^2.0.1",
"fs-extra": "^10.0.0",
"got": "^11.7.0",
"node-abi": "^3.45.0",
"node-api-version": "^0.2.0",
"ora": "^5.1.0",
"read-binary-file-arch": "^1.0.6",
"semver": "^7.3.5",
"tar": "^6.0.5",
"yargs": "^17.0.1"
},
"bin": {
"electron-rebuild": "lib/cli.js"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/@electron/universal": { "node_modules/@electron/universal": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz",
@@ -923,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": {
@@ -2861,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": {
@@ -2919,15 +2971,6 @@
"fs-extra": "^10.1.0" "fs-extra": "^10.1.0"
} }
}, },
"node_modules/electron-log": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.4.tgz",
"integrity": "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/electron-publish": { "node_modules/electron-publish": {
"version": "25.1.7", "version": "25.1.7",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz",
@@ -5340,6 +5383,16 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/proc-log": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz",
"integrity": "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -6233,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"
}, },
+22 -34
View File
@@ -10,31 +10,29 @@
"private": true, "private": true,
"main": "dist/main/main.js", "main": "dist/main/main.js",
"scripts": { "scripts": {
"clean": "rimraf dist release lib", "clean": "rimraf dist release",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build:bundle": "node scripts/build-scoreko-bundle.mjs", "build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-assets.mjs",
"build:main": "tsc -p tsconfig.json",
"prepare:runtime": "node scripts/prepare-nodecg-runtime.mjs",
"build": "npm run clean && npm run build:bundle && npm run build:main && npm run prepare:runtime",
"start": "npm run build && electron .", "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",
"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",
"test": "rimraf dist && npm run build:main && node --test dist/tests/**/*.test.js", "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",
"sanity": "npm run typecheck && npm run lint && npm run test",
"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 && npm run rebuild:native && electron-builder --win", "dist:win": "npm run build && electron-builder --win",
"dist:linux": "npm run build && npm run rebuild:native && electron-builder --linux AppImage", "dist:linux": "npm run build && electron-builder --linux AppImage",
"dist:all": "npm run build && npm run rebuild:native && electron-builder --win --linux --mac", "dist:all": "npm run build && electron-builder --win --linux --mac",
"dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac" "dist:mac": "npm run build && electron-builder --mac"
}, },
"build": { "build": {
"beforePack": "./scripts/before-pack.mjs",
"appId": "com.scoreko.desktop", "appId": "com.scoreko.desktop",
"productName": "Scoreko", "productName": "Scoreko",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
@@ -48,16 +46,12 @@
], ],
"extraResources": [ "extraResources": [
{ {
"from": "lib/nodecg", "from": "lib/scoreko-dev",
"to": "lib/nodecg" "to": "lib/scoreko-dev"
}, },
{ {
"from": "static", "from": "static",
"to": "static" "to": "static"
},
{
"from": ".env",
"to": ".env"
} }
], ],
"mac": { "mac": {
@@ -78,11 +72,9 @@
"nsis" "nsis"
], ],
"icon": "static/icons/icon.ico", "icon": "static/icons/icon.ico",
"executableName": "scoreko", "executableName": "scoreko"
"signAndEditExecutable": true
}, },
"nsis": { "nsis": {
"include": "static/installer.nsh",
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"artifactName": "${productName}-setup-${version}.${ext}", "artifactName": "${productName}-setup-${version}.${ext}",
@@ -90,29 +82,25 @@
"uninstallerIcon": "static/icons/icon.ico", "uninstallerIcon": "static/icons/icon.ico",
"installerHeaderIcon": "static/icons/icon.ico", "installerHeaderIcon": "static/icons/icon.ico",
"shortcutName": "Scoreko", "shortcutName": "Scoreko",
"useZip": false, "useZip": false
"deleteAppDataOnUninstall": true,
"createDesktopShortcut": "always"
}, },
"compression": "normal" "compression": "normal"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=24.14.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@electron/rebuild": "^3.7.1",
"@typescript-eslint/eslint-plugin": "^8.22.0", "@types/node": "^24.3.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",
"prettier": "^3.4.2",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"wait-on": "^8.0.1" "wait-on": "^8.0.1",
}, "eslint": "^9.19.0",
"dependencies": { "@typescript-eslint/parser": "^8.22.0",
"electron-log": "^5.4.4" "@typescript-eslint/eslint-plugin": "^8.22.0",
"prettier": "^3.4.2"
} }
} }
-13
View File
@@ -1,13 +0,0 @@
// scripts/beforePack.mjs
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default async function () {
const src = path.resolve(__dirname, '../static/installSection.nsh');
const dest = path.resolve(__dirname, '../node_modules/app-builder-lib/templates/nsis/installSection.nsh');
fs.copyFileSync(src, dest);
console.log('✅ installSection.nsh parcheado');
}
-56
View File
@@ -1,56 +0,0 @@
import path from "node:path";
export const electronRoot = process.cwd();
export const bundleRoot = path.resolve(electronRoot, "..");
export const nodecgRuntimeRoot = path.join(electronRoot, "lib", "nodecg");
export const nodecgRuntimeNodeModules = path.join(nodecgRuntimeRoot, "node_modules");
export const bundleName = process.env.NODECG_BUNDLE_NAME?.trim() || "scoreko-dev";
export const runtimeBundleRoot = path.join(nodecgRuntimeRoot, "bundles", bundleName);
export const runtimeNpmCache = process.env.npm_config_cache ?? path.join(electronRoot, ".npm-runtime-cache");
export const electronCache = process.env.ELECTRON_CACHE ?? path.join(electronRoot, ".electron-cache");
export const bundleRootMarkers = ["package.json", "pnpm-lock.yaml"];
export const generatedBundleEntries = ["extension", "node_modules/.vite", "shared/dist", "dashboard", "graphics"];
export const preparedBundleEntries = [
"assets",
"dashboard",
"extension",
"graphics",
"nodecg",
"schemas",
"shared",
"configschema.json",
"LICENSE",
"package.json",
"README.md",
];
export const requiredPreparedBundleEntries = [
"dashboard",
"extension",
"graphics",
"nodecg",
"schemas",
"shared",
"package.json",
];
export function getNpmCommand() {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
export function getLocalBinPath(commandName) {
const extension = process.platform === "win32" ? ".CMD" : "";
return path.join(bundleRoot, "node_modules", ".bin", `${commandName}${extension}`);
}
export function getPathInside(rootPath, relativePath) {
const resolvedRoot = path.resolve(rootPath);
const targetPath = path.resolve(resolvedRoot, relativePath);
const pathFromRoot = path.relative(resolvedRoot, targetPath);
if (!pathFromRoot || pathFromRoot.startsWith("..") || path.isAbsolute(pathFromRoot)) {
throw new Error(`Refusing to access path outside ${resolvedRoot}: ${targetPath}`);
}
return targetPath;
}
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env node
import { existsSync, mkdirSync, rmSync } from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import {
bundleRoot,
bundleRootMarkers,
electronRoot,
generatedBundleEntries,
getLocalBinPath,
getPathInside,
} from "./build-config.mjs";
const nodeModulesPath = path.join(bundleRoot, "node_modules");
const missingMarkers = bundleRootMarkers
.map((entry) => path.join(bundleRoot, entry))
.filter((candidatePath) => !existsSync(candidatePath));
if (missingMarkers.length > 0) {
console.error(
[
`Scoreko bundle root was not found at: ${bundleRoot}`,
"This Electron package expects to live inside the Scoreko repository with the bundle project as its parent.",
...missingMarkers.map((candidatePath) => `Missing: ${candidatePath}`),
].join("\n"),
);
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 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 = getPathInside(bundleRoot, relativePath);
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);
}
}
for (const entry of generatedBundleEntries) {
removeGeneratedOutput(entry);
}
for (const entry of ["shared/dist", "dashboard", "graphics", "extension"]) {
mkdirSync(path.join(bundleRoot, entry), { recursive: true });
}
runCommand(getLocalBinPath("vite"), ["build", "--configLoader", "runner"]);
runCommand(getLocalBinPath("tsc"), ["-b", "tsconfig.extension.json"]);
+15
View File
@@ -0,0 +1,15 @@
import { cpSync, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const distStatic = path.join(root, "dist", "static");
const sourceStatic = path.join(root, "static");
mkdirSync(distStatic, { recursive: true });
if (existsSync(sourceStatic)) {
cpSync(sourceStatic, distStatic, { recursive: true });
console.log("Copied static assets to dist/static");
} else {
console.warn("No static folder found, skipping copy-assets step");
}
+34 -47
View File
@@ -3,35 +3,17 @@ import fs from "node:fs";
import net from "node:net"; import net from "node:net";
import path from "node:path"; import path from "node:path";
import { bundleName, nodecgRuntimeRoot } from "./build-config.mjs"; const cwd = process.cwd();
const scorekoRootPath = path.resolve(cwd, "lib", "scoreko-dev");
const checks = []; const checks = [];
function loadEnv() {
if (!fs.existsSync(".env")) {
console.error("FAIL Configuración: Archivo .env obligatorio no encontrado.");
console.error("Por favor, crea un archivo .env basado en .env.example en la raíz del proyecto.");
process.exit(1);
}
try {
process.loadEnvFile(".env");
console.log("OK Configuración: Archivo .env cargado correctamente.\n");
} catch (error) {
console.error(`FAIL Configuración: Error al leer el archivo .env: ${error.message}`);
process.exit(1);
}
}
function addCheck(ok, title, details) { function addCheck(ok, title, details) {
checks.push({ ok, title, details }); checks.push({ ok, title, details });
} }
function parsePort(name) { function parsePort(name, fallback) {
const raw = process.env[name]; const raw = process.env[name] ?? fallback;
if (!raw) {
addCheck(false, `${name} missing`, `The required environment variable ${name} is not defined in the .env file.`);
return null;
}
const parsed = Number.parseInt(raw, 10); const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
addCheck(false, `${name} invalid`, `It must be an integer between 1 and 65535. Received value: '${raw}'.`); addCheck(false, `${name} invalid`, `It must be an integer between 1 and 65535. Received value: '${raw}'.`);
@@ -42,12 +24,8 @@ function parsePort(name) {
return parsed; return parsed;
} }
function parseIntInRange(name, min, max) { function parseIntInRange(name, fallback, min, max) {
const raw = process.env[name]; const raw = process.env[name] ?? String(fallback);
if (!raw) {
addCheck(false, `${name} missing`, `The required environment variable ${name} is not defined in the .env file.`);
return;
}
const parsed = Number.parseInt(raw, 10); const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) { if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
addCheck(false, `${name} invalid`, `It must be an integer between ${min} and ${max}. Received value: '${raw}'.`); addCheck(false, `${name} invalid`, `It must be an integer between ${min} and ${max}. Received value: '${raw}'.`);
@@ -58,22 +36,33 @@ function parseIntInRange(name, min, max) {
} }
function checkNodecgInstall() { function checkNodecgInstall() {
const indexPath = path.join(nodecgRuntimeRoot, "index.js"); const packageJsonPath = path.join(scorekoRootPath, "package.json");
const bootstrapPath = path.join(nodecgRuntimeRoot, "node_modules", "nodecg", "dist", "server", "bootstrap.js"); const nodecgDependencyPath = path.join(scorekoRootPath, "node_modules", "nodecg", "package.json");
const manifestPath = path.join(nodecgRuntimeRoot, ".scoreko-runtime.json"); const nodecgCliPath = path.join(
const bundlePath = path.join(nodecgRuntimeRoot, "bundles", bundleName); 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(nodecgRuntimeRoot), "Packaged NodeCG runtime", nodecgRuntimeRoot); addCheck(fs.existsSync(scorekoRootPath), "Scoreko root", scorekoRootPath);
addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath); addCheck(fs.existsSync(packageJsonPath), "scoreko-dev package.json", packageJsonPath);
addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath); addCheck(fs.existsSync(nodecgDependencyPath), "NodeCG dependency", nodecgDependencyPath);
addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath); addCheck(fs.existsSync(nodecgCliPath), "NodeCG CLI", nodecgCliPath);
addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath); addCheck(
bundleAssetPaths.some((candidatePath) => fs.existsSync(candidatePath)),
"scoreko-dev bundle assets",
`Expected one of: ${bundleAssetPaths.join(", ")}`,
);
try { try {
fs.accessSync(nodecgRuntimeRoot, 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 for local development"); 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");
} }
} }
@@ -96,12 +85,10 @@ function checkPortAvailability(port) {
} }
async function main() { async function main() {
loadEnv(); const port = parsePort("NODECG_PORT", "9090");
parseIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000);
const port = parsePort("NODECG_PORT"); parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000);
parseIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000); parseIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000);
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000);
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000);
checkNodecgInstall(); checkNodecgInstall();
if (port) { if (port) {
@@ -109,7 +96,7 @@ async function main() {
} }
for (const check of checks) { for (const check of checks) {
const icon = check.ok ? "OK" : "FAIL"; const icon = check.ok ? "" : "";
console.log(`${icon} ${check.title}: ${check.details}`); console.log(`${icon} ${check.title}: ${check.details}`);
} }
-172
View File
@@ -1,172 +0,0 @@
#!/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";
import {
bundleName,
bundleRoot,
getNpmCommand,
nodecgRuntimeNodeModules,
nodecgRuntimeRoot,
preparedBundleEntries,
requiredPreparedBundleEntries,
runtimeBundleRoot,
runtimeNpmCache,
} from "./build-config.mjs";
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: runtimeNpmCache,
},
});
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 requiredPreparedBundleEntries) {
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(nodecgRuntimeRoot, "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(nodecgRuntimeRoot, "index.js"), 'require("nodecg");\n');
}
function copyBundle() {
mkdirSync(runtimeBundleRoot, { recursive: true });
for (const entry of preparedBundleEntries) {
copyIfExists(path.join(bundleRoot, entry), path.join(runtimeBundleRoot, entry));
}
}
function writeManifest() {
const bundlePackageJson = readJson(path.join(bundleRoot, "package.json"));
const runtimePackageJson = readJson(path.join(nodecgRuntimeRoot, "package.json"));
writeFileSync(
path.join(nodecgRuntimeRoot, ".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;
}
run(getNpmCommand(), ["install", "--omit=dev", "--no-audit", "--no-fund"], nodecgRuntimeRoot);
}
function main() {
assertBundleBuildExists();
rmSync(nodecgRuntimeRoot, { recursive: true, force: true });
mkdirSync(nodecgRuntimeNodeModules, { recursive: true });
mkdirSync(path.join(nodecgRuntimeRoot, "bundles"), { recursive: true });
createRuntimePackageJson();
copyBundle();
installRuntimeDependencies();
writeManifest();
console.log(`[prepare-runtime] NodeCG runtime ready at ${nodecgRuntimeRoot}`);
}
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
+22 -22
View File
@@ -1,19 +1,15 @@
import { existsSync, readFileSync } from "node:fs"; import { existsSync } 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";
import { electronCache, electronRoot, getNpmCommand, nodecgRuntimeRoot, runtimeNpmCache } from "./build-config.mjs"; const root = process.cwd();
const scorekoDir = path.join(root, "lib", "scoreko-dev");
const sqliteLegacyDir = path.join(scorekoDir, "workspaces", "database-adapter-sqlite-legacy");
const packageJson = JSON.parse(readFileSync(path.join(electronRoot, "package.json"), "utf8")); const moduleDirs = [scorekoDir, sqliteLegacyDir].filter((dir) => existsSync(path.join(dir, "package.json")));
const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron;
if (!electronVersion) { if (moduleDirs.length === 0) {
console.error("Could not determine Electron version from package.json."); console.error("No package folders found. Expected lib/scoreko-dev and/or workspaces.");
process.exit(1);
}
if (!existsSync(path.join(nodecgRuntimeRoot, "package.json"))) {
console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first.");
process.exit(1); process.exit(1);
} }
@@ -27,10 +23,8 @@ function run(command, args, cwd) {
env: { env: {
...process.env, ...process.env,
npm_config_runtime: "electron", npm_config_runtime: "electron",
npm_config_target: electronVersion, npm_config_target: "40.6.1",
npm_config_disturl: "https://electronjs.org/headers", npm_config_disturl: "https://electronjs.org/headers",
npm_config_cache: runtimeNpmCache,
ELECTRON_CACHE: electronCache,
}, },
}); });
@@ -44,13 +38,19 @@ function run(command, args, cwd) {
}); });
} }
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgRuntimeRoot}`); for (const dir of moduleDirs) {
await run(getNpmCommand(), [ if (dir === sqliteLegacyDir) {
"rebuild", console.log(`\n[rebuild-native] Ensuring sqlite legacy workspace deps in: ${dir}`);
"better-sqlite3", await run("npm", ["install"], dir);
"--runtime=electron", await run("npm", ["install", "bindings", "--no-save"], dir);
`--target=${electronVersion}`, }
"--dist-url=https://electronjs.org/headers",
], nodecgRuntimeRoot); console.log(`\n[rebuild-native] Rebuilding better-sqlite3 in: ${dir}`);
await run(
"npm",
["rebuild", "better-sqlite3", "--runtime=electron", "--target=40.6.1", "--dist-url=https://electronjs.org/headers"],
dir,
);
}
console.log("\n[rebuild-native] Done."); console.log("\n[rebuild-native] Done.");
-243
View File
@@ -1,243 +0,0 @@
import { AppRuntimeConfig } from "../config/runtime-config";
import { NodecgProcessManager } from "../nodecg/process-manager";
import { PreparedNodecgRuntime } from "../nodecg/runtime-setup";
import { getRemainingDelayMs } from "../utils/timing";
import { ApplicationPaths } from "./paths";
import { createShutdownService, ShutdownService } from "./shutdown-service";
type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed";
export type ApplicationWindow = {
close: () => void;
focus: () => void;
isDestroyed: () => boolean;
isMinimized: () => boolean;
loadURL: (url: string) => Promise<unknown>;
loadFile: (filePath: string) => Promise<unknown>;
restore: () => void;
show: () => void;
};
export type ApplicationControllerConfig = {
appConfig: AppRuntimeConfig;
appVersion: string;
isPackaged: boolean;
isWindows: boolean;
paths: ApplicationPaths;
deps: {
createLoadingWindow: () => ApplicationWindow;
createMainWindow: () => ApplicationWindow;
createNodecgProcessManager: (runtimePath: string) => NodecgProcessManager;
getAllWindows: () => ApplicationWindow[];
log: (...args: unknown[]) => void;
prepareRuntime: (config: {
sourceRuntimePath: string;
userDataPath: string;
appVersion: string;
bundleName: string;
log: (...args: unknown[]) => void;
}) => PreparedNodecgRuntime;
scheduleUpdateCheck: (config: {
getParentWindow: () => ApplicationWindow | null;
beforeInstall: () => Promise<void>;
}) => void;
setAppUserModelId: (userModelId: string) => void;
exit: (code: number) => void;
now?: () => number;
sleep?: (ms: number) => Promise<void>;
};
};
export type ApplicationController = {
activate: () => Promise<void>;
focusExistingWindow: () => void;
getState: () => ApplicationState;
launch: () => Promise<void>;
showErrorScreen: (error: unknown) => Promise<void>;
stopNodecgGracefully: () => Promise<void>;
};
export function createApplicationController({
appConfig,
appVersion,
deps,
isPackaged: _isPackaged,
isWindows,
paths,
}: ApplicationControllerConfig): ApplicationController {
let state: ApplicationState = "idle";
let mainWindow: ApplicationWindow | null = null;
let loadingWindow: ApplicationWindow | null = null;
let nodecgManager: NodecgProcessManager | null = null;
let launchPromise: Promise<void> | null = null;
const shutdownService: ShutdownService = createShutdownService(async () => {
await (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve());
});
const now = deps.now ?? Date.now;
const sleep = deps.sleep ?? defaultSleep;
const closeLoadingWindow = (): void => {
if (!loadingWindow || loadingWindow.isDestroyed()) {
return;
}
loadingWindow.close();
loadingWindow = null;
};
const focusExistingWindow = (): void => {
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow;
if (!targetWindow || targetWindow.isDestroyed()) {
return;
}
if (targetWindow.isMinimized()) {
targetWindow.restore();
}
targetWindow.show();
targetWindow.focus();
};
const startNodecg = async (): Promise<void> => {
if (!nodecgManager) {
throw new Error("NodeCG process manager is not initialized.");
}
await nodecgManager.startNodecgProcess();
await nodecgManager.waitForNodecgReady(now());
};
const launch = async (): Promise<void> => {
if (launchPromise) {
return launchPromise;
}
launchPromise = (async () => {
if (isWindows) {
deps.setAppUserModelId(appConfig.userModelId);
}
mainWindow = deps.createMainWindow();
loadingWindow = deps.createLoadingWindow();
await loadingWindow.loadFile(paths.staticLoadingHtmlPath);
loadingWindow.show();
await sleep(50);
state = "preparing";
const preparedRuntime = deps.prepareRuntime({
sourceRuntimePath: paths.sourceNodecgRuntimePath,
userDataPath: paths.userDataPath,
appVersion,
bundleName: appConfig.bundleName,
log: deps.log,
});
nodecgManager = deps.createNodecgProcessManager(preparedRuntime.runtimePath);
state = "starting";
await startNodecg();
if (!loadingWindow || loadingWindow.isDestroyed()) {
state = "ready";
return;
}
const loadingShownAt = now();
if (!mainWindow) {
state = "ready";
return;
}
await mainWindow.loadURL(paths.mainDashboardUrl);
const remainingLoadingDelay = getRemainingDelayMs(appConfig.loadDelayMs, loadingShownAt, now());
if (remainingLoadingDelay > 0) {
await sleep(remainingLoadingDelay);
}
mainWindow.show();
closeLoadingWindow();
deps.scheduleUpdateCheck({
getParentWindow: () => mainWindow,
beforeInstall: stopNodecgGracefully,
});
state = "ready";
})();
try {
await launchPromise;
} catch (error) {
state = "failed";
launchPromise = null;
await showErrorScreen(error);
throw error;
}
};
const activate = async (): Promise<void> => {
if (deps.getAllWindows().length > 0) {
focusExistingWindow();
return;
}
if (state !== "ready") {
await launch();
return;
}
mainWindow = deps.createMainWindow();
await mainWindow.loadURL(paths.mainDashboardUrl);
mainWindow.show();
};
const stopNodecgGracefully = async (): Promise<void> => {
if (shutdownService.getState() === "running") {
state = "stopping";
}
await shutdownService.stop();
state = "stopped";
};
const showErrorScreen = async (error: unknown): Promise<void> => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
const encodedMsg = encodeURIComponent(`msg=${encodeURIComponent(message)}`);
const errorUrl = `file://${paths.staticErrorHtmlPath}#${encodedMsg}`;
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow;
if (!targetWindow || targetWindow.isDestroyed()) {
return;
}
try {
await targetWindow.loadURL(errorUrl);
targetWindow.show();
} catch {
// If even the error screen fails to load, nothing more can be done.
}
};
return {
activate,
focusExistingWindow,
getState: () => state,
launch,
showErrorScreen,
stopNodecgGracefully,
};
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
-152
View File
@@ -1,152 +0,0 @@
import { app, BrowserWindow } from "electron";
import path from "node:path";
import { getRuntimeConfig, loadEnvFile, AppRuntimeConfig } from "../config/runtime-config";
import { showFatalError, log } from "../errors/error-handler";
import { logger } from "../logging/logger";
import { createNodecgProcessManager } from "../nodecg/process-manager";
import { prepareUserNodecgRuntime } from "../nodecg/runtime-setup";
import { scheduleUpdateCheck } from "../updates/update-service";
import { createLoadingWindow, createMainWindow } from "../windows/window-service";
import { createApplicationController } from "./application-controller";
import { getApplicationPaths, getRootPath } from "./paths";
export function bootstrap(): void {
const isDev = !app.isPackaged;
const compiledMainDir = path.resolve(__dirname, "..");
const resourcesPath = process.resourcesPath;
const rootPath = getRootPath(isDev, compiledMainDir, resourcesPath);
const envFilePath = path.join(rootPath, ".env");
let appConfig: AppRuntimeConfig;
try {
loadEnvFile(envFilePath);
appConfig = getRuntimeConfig();
} catch (error: unknown) {
app.on("ready", () => {
showFatalError("No se pudo cargar la configuración de la aplicación.", error);
app.exit(1);
});
return;
}
const paths = getApplicationPaths({
appConfig,
appDataPath: app.getPath("appData"),
compiledMainDir,
isDev,
resourcesPath,
});
app.setName(appConfig.title);
app.setPath("userData", paths.userDataPath);
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.quit();
}
const controller = createApplicationController({
appConfig,
appVersion: app.getVersion(),
isPackaged: app.isPackaged,
isWindows: process.platform === "win32",
paths,
deps: {
createLoadingWindow: () =>
createLoadingWindow({
allowDevTools: isDev,
appConfig,
rootPath: paths.rootPath,
}),
createMainWindow: () =>
createMainWindow({
allowDevTools: isDev,
appConfig,
rootPath: paths.rootPath,
mainDashboardUrl: paths.mainDashboardUrl,
}),
createNodecgProcessManager: (runtimePath) =>
createNodecgProcessManager({
isDev,
nodecgRootPath: runtimePath,
nodecgBaseUrl: paths.nodecgBaseUrl,
appConfig,
log,
}),
getAllWindows: () => BrowserWindow.getAllWindows(),
log,
prepareRuntime: prepareUserNodecgRuntime,
scheduleUpdateCheck: ({ getParentWindow, beforeInstall }) => {
scheduleUpdateCheck({
appConfig,
rootPath: paths.rootPath,
getParentWindow: () => getParentWindow() as BrowserWindow | null,
beforeInstall,
log,
});
},
setAppUserModelId: (userModelId) => app.setAppUserModelId(userModelId),
exit: (code) => app.exit(code),
},
});
app.on("ready", () => {
if (!hasSingleInstanceLock) {
return;
}
controller.launch().catch((error: unknown) => {
logger.error("launch-failed", { error: error instanceof Error ? error.stack : String(error) });
});
});
app.on("second-instance", () => {
controller.focusExistingWindow();
});
app.on("activate", () => {
controller.activate().catch((error: unknown) => {
showFatalError("No se pudo reactivar Scoreko.", error);
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", (event) => {
if (controller.getState() === "stopping" || controller.getState() === "stopped") {
return;
}
event.preventDefault();
controller.stopNodecgGracefully().finally(() => {
app.quit();
});
});
app.on("will-quit", () => {
if (controller.getState() !== "stopping" && controller.getState() !== "stopped") {
void controller.stopNodecgGracefully();
}
});
process.on("exit", () => {
if (controller.getState() !== "stopping" && controller.getState() !== "stopped") {
void controller.stopNodecgGracefully();
}
});
process.on("uncaughtException", (error) => {
showFatalError("Unexpected error in Electron main process.", error);
});
process.on("unhandledRejection", (reason) => {
showFatalError("Unhandled promise in Electron main process.", reason);
});
}
-84
View File
@@ -1,84 +0,0 @@
import path from "node:path";
import { AppRuntimeConfig } from "../config/runtime-config";
export type ApplicationPaths = {
rootPath: string;
sourceNodecgRuntimePath: string;
userDataPath: string;
nodecgBaseUrl: string;
mainDashboardUrl: string;
staticLoadingHtmlPath: string;
staticErrorHtmlPath: string;
};
export function getRootPath(isDev: boolean, compiledMainDir: string, resourcesPath: string): string {
return isDev ? path.resolve(compiledMainDir, "../..") : resourcesPath;
}
export function getUserDataPath(appDataPath: string, userDataDirectoryName: string): string {
return path.join(appDataPath, userDataDirectoryName);
}
export function getManagedNodecgRuntimePath(userDataPath: string): string {
return path.join(userDataPath, "nodecg");
}
export function getSourceNodecgRuntimePath(rootPath: string): string {
return path.resolve(rootPath, "lib", "nodecg");
}
export function getUpdateDownloadDirectory(tempDirectory: string): string {
return path.join(tempDirectory, "scoreko-updates");
}
export function getSafeChildPath(parentDirectory: string, fileName: string): string {
const resolvedParent = path.resolve(parentDirectory);
const resolvedChild = path.resolve(resolvedParent, fileName);
const relativePath = path.relative(resolvedParent, resolvedChild);
const isInsideParent =
relativePath.length > 0 && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
if (!isInsideParent) {
throw new Error(`Refusing to build a path outside ${resolvedParent}: ${fileName}`);
}
return resolvedChild;
}
export function getNodecgBaseUrl(nodecgPort: string): string {
return `http://127.0.0.1:${nodecgPort}`;
}
export function getDashboardUrl(nodecgPort: string, bundleName: string, dashboardRoute: string): string {
return `http://localhost:${nodecgPort}/bundles/${bundleName}/${dashboardRoute}`;
}
export function getApplicationPaths({
appConfig,
appDataPath,
compiledMainDir,
isDev,
resourcesPath,
}: {
appConfig: Pick<
AppRuntimeConfig,
"bundleName" | "mainDashboardRoute" | "nodecgPort" | "userDataDirectoryName"
>;
appDataPath: string;
compiledMainDir: string;
isDev: boolean;
resourcesPath: string;
}): ApplicationPaths {
const rootPath = getRootPath(isDev, compiledMainDir, resourcesPath);
return {
rootPath,
sourceNodecgRuntimePath: getSourceNodecgRuntimePath(rootPath),
userDataPath: getUserDataPath(appDataPath, appConfig.userDataDirectoryName),
nodecgBaseUrl: getNodecgBaseUrl(appConfig.nodecgPort),
mainDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.mainDashboardRoute),
staticLoadingHtmlPath: path.join(rootPath, "static", "loading.html"),
staticErrorHtmlPath: path.join(rootPath, "static", "error.html"),
};
}
-32
View File
@@ -1,32 +0,0 @@
type AppShutdownState = "running" | "stopping" | "stopped";
export type ShutdownService = {
getState: () => AppShutdownState;
stop: () => Promise<void>;
};
export function createShutdownService(stopRuntime: () => Promise<void>): ShutdownService {
let state: AppShutdownState = "running";
let stopPromise: Promise<void> | null = null;
return {
getState: () => state,
stop: () => {
if (state === "stopped") {
return Promise.resolve();
}
if (stopPromise) {
return stopPromise;
}
state = "stopping";
stopPromise = stopRuntime().finally(() => {
state = "stopped";
stopPromise = null;
});
return stopPromise;
},
};
}
+20 -126
View File
@@ -1,6 +1,3 @@
import fs from "node:fs";
import path from "node:path";
export type AppRuntimeConfig = { export type AppRuntimeConfig = {
title: string; title: string;
userModelId: string; userModelId: string;
@@ -9,74 +6,32 @@ export type AppRuntimeConfig = {
nodecgPort: string; nodecgPort: string;
bundleName: string; bundleName: string;
mainDashboardRoute: string; mainDashboardRoute: string;
loadingDashboardRoute: string;
loadDelayMs: number; loadDelayMs: number;
startupTimeoutMs: number; startupTimeoutMs: number;
nodecgKillTimeoutMs: number; nodecgKillTimeoutMs: number;
updatesEnabled: boolean;
updateApiUrl?: string;
updateReleasePageUrl?: string;
updateAssetPattern?: string;
updateCheckDelayMs: number;
}; };
const MIN_TCP_PORT = 1; const MIN_TCP_PORT = 1;
const MAX_TCP_PORT = 65535; const MAX_TCP_PORT = 65535;
export function loadEnvFile(envFilePath: string): void {
const resolvedPath = resolveEnvFilePath(envFilePath);
try {
process.loadEnvFile(resolvedPath);
} catch (error) {
throw new Error(
`Error al leer el archivo de configuración .env: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
function resolveEnvFilePath(envFilePath: string): string {
if (fs.existsSync(envFilePath)) {
return envFilePath;
}
const dir = path.dirname(envFilePath);
const fallbackPath = path.join(dir, ".env.example");
if (fs.existsSync(fallbackPath)) {
return fallbackPath;
}
throw new Error(
`Archivo de configuración obligatorio no encontrado: ${envFilePath}\n\nPor favor, crea un archivo .env basado en .env.example en la raíz de la aplicación.`,
);
}
export function getRuntimeConfig(): AppRuntimeConfig { export function getRuntimeConfig(): AppRuntimeConfig {
// Centralized defaults keep local development and packaged builds consistent.
return { return {
title: getRequiredEnv("SCOREKO_APP_TITLE"), title: getEnv("SCOREKO_APP_TITLE", "Scoreko"),
userModelId: getRequiredEnv("SCOREKO_APP_USER_MODEL_ID"), userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"),
userDataDirectoryName: getRequiredEnv("SCOREKO_APP_USER_DATA_DIRECTORY"), userDataDirectoryName: getEnv("SCOREKO_APP_USER_DATA_DIRECTORY", "scoreko"),
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"), iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
nodecgPort: parseRequiredEnvPort("NODECG_PORT"), nodecgPort: parseEnvPort("NODECG_PORT", "9090"),
bundleName: getRequiredEnv("NODECG_BUNDLE_NAME"), bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"),
mainDashboardRoute: getRequiredEnv("SCOREKO_DASHBOARD_ROUTE"), mainDashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"),
loadDelayMs: parseRequiredEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000), loadingDashboardRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"),
startupTimeoutMs: parseRequiredEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000), loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000),
nodecgKillTimeoutMs: parseRequiredEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000), startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000),
updatesEnabled: parseRequiredEnvBool("SCOREKO_UPDATES_ENABLED"), nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000),
updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"),
updateReleasePageUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_RELEASE_PAGE_URL"),
updateAssetPattern: getOptionalEnv("SCOREKO_UPDATE_ASSET_PATTERN"),
updateCheckDelayMs: parseRequiredEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 0, 600000),
}; };
} }
export function getRequiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value || value.length === 0) {
throw new Error(`La variable de entorno requerida '${name}' no está definida en el archivo .env.`);
}
return value;
}
export function getOptionalEnv(name: string): string | undefined { export function getOptionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim(); const value = process.env[name]?.trim();
return value && value.length > 0 ? value : undefined; return value && value.length > 0 ? value : undefined;
@@ -86,18 +41,18 @@ export function getEnv(name: string, fallback: string): string {
return getOptionalEnv(name) ?? fallback; return getOptionalEnv(name) ?? fallback;
} }
export function parseRequiredEnvIntInRange(name: string, min: number, max: number): number { export function parseEnvInt(name: string, fallback: number): number {
const rawValue = getRequiredEnv(name); const rawValue = process.env[name];
const parsedValue = Number.parseInt(rawValue, 10); if (!rawValue) {
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) { return fallback;
throw new Error(
`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`,
);
} }
return parsedValue;
const parsedValue = Number.parseInt(rawValue, 10);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
} }
export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number { export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number {
// We throw here instead of silently coercing to avoid hidden misconfiguration in production.
const rawValue = process.env[name]; const rawValue = process.env[name];
if (!rawValue) { if (!rawValue) {
return fallback; return fallback;
@@ -113,67 +68,6 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
return parsedValue; return parsedValue;
} }
export function parseRequiredEnvBool(name: string): boolean {
const rawValue = getRequiredEnv(name).toLowerCase();
if (["1", "true", "yes", "on"].includes(rawValue)) {
return true;
}
if (["0", "false", "no", "off"].includes(rawValue)) {
return false;
}
throw new Error(`The ${name} variable must be a boolean. Received value: '${rawValue}'.`);
}
export function parseEnvBool(name: string, fallback: boolean): boolean {
const rawValue = process.env[name]?.trim().toLowerCase();
if (!rawValue) {
return fallback;
}
if (["1", "true", "yes", "on"].includes(rawValue)) {
return true;
}
if (["0", "false", "no", "off"].includes(rawValue)) {
return false;
}
throw new Error(`The ${name} variable must be a boolean. Received value: '${process.env[name]}'.`);
}
export function parseOptionalHttpUrl(name: string): string | undefined {
const rawValue = getOptionalEnv(name);
if (!rawValue) {
return undefined;
}
try {
const url = new URL(rawValue);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("unsupported protocol");
}
return url.toString();
} catch {
throw new Error(`The ${name} variable must be a valid HTTP(S) URL. Received value: '${rawValue}'.`);
}
}
export function parseRequiredEnvPort(name: string): string {
const rawValue = getRequiredEnv(name);
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue) || parsedValue < MIN_TCP_PORT || parsedValue > MAX_TCP_PORT) {
throw new Error(
`The ${name} variable must be a valid TCP port (${MIN_TCP_PORT}-${MAX_TCP_PORT}). Received value: '${rawValue}'.`,
);
}
return String(parsedValue);
}
export function parseEnvPort(name: string, fallback: string): string { export function parseEnvPort(name: string, fallback: string): string {
const rawValue = getEnv(name, fallback); const rawValue = getEnv(name, fallback);
const parsedValue = Number.parseInt(rawValue, 10); const parsedValue = Number.parseInt(rawValue, 10);
+1 -1
View File
@@ -1,4 +1,4 @@
export const NODE_RUNTIME_NAME = "Electron embedded Node.js"; export const NODE_RUNTIME_NAME = "electron internal node";
export const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f"; export const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f";
export const DEFAULT_WINDOW_SIZE = { export const DEFAULT_WINDOW_SIZE = {
@@ -1,12 +1,12 @@
import { app, dialog } from "electron"; import { app, dialog } from "electron";
import { logger } from "../logging/logger"; import { logger } from "./logger";
export function log(...args: unknown[]): void { export function log(...args: unknown[]): void {
logger.info("runtime", { args }); logger.info("runtime", { args });
} }
function formatErrorMessage(error: unknown): string { export function formatErrorMessage(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
const stack = error.stack?.trim(); const stack = error.stack?.trim();
return stack && stack.length > 0 ? stack : error.message; return stack && stack.length > 0 ? stack : error.message;
@@ -1,14 +1,7 @@
import electronLog from "electron-log";
export type LogLevel = "debug" | "info" | "warn" | "error"; export type LogLevel = "debug" | "info" | "warn" | "error";
type LogContext = Record<string, unknown>; type LogContext = Record<string, unknown>;
// Configure electron-log: write to file and (in dev) also to console.
electronLog.initialize();
electronLog.transports.file.level = "debug";
electronLog.transports.console.level = process.env["NODE_ENV"] === "development" ? "debug" : false;
function write(level: LogLevel, message: string, context?: LogContext): void { function write(level: LogLevel, message: string, context?: LogContext): void {
const payload = { const payload = {
ts: new Date().toISOString(), ts: new Date().toISOString(),
@@ -20,7 +13,17 @@ function write(level: LogLevel, message: string, context?: LogContext): void {
const line = JSON.stringify(payload); const line = JSON.stringify(payload);
electronLog[level](line); if (level === "error") {
console.error(line);
return;
}
if (level === "warn") {
console.warn(line);
return;
}
console.log(line);
} }
export const logger = { export const logger = {
+189 -2
View File
@@ -1,3 +1,190 @@
import { bootstrap } from "./app/bootstrap"; import { app, BrowserWindow } from "electron";
import path from "node:path";
bootstrap(); import { getRuntimeConfig } from "./config/runtime-config";
import { showFatalError, log } from "./errors/error-presenter";
import { createNodecgProcessManager } from "./nodecg/process-manager";
import { getRemainingDelayMs } from "./utils/timing";
import { createLoadingWindow, createMainWindow } from "./windows/window-factory";
const appConfig = getRuntimeConfig();
// Force a stable userData folder name; overridable via SCOREKO_APP_USER_DATA_DIRECTORY.
app.setName(appConfig.title);
app.setPath("userData", path.join(app.getPath("appData"), appConfig.userDataDirectoryName));
const isDev = !app.isPackaged;
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath;
const nodecgRootPath = path.resolve(rootPath, "lib", "scoreko-dev");
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}`;
const hasSingleInstanceLock = app.requestSingleInstanceLock();
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 shutdownState: AppShutdownState = "running";
function focusExistingWindow(): void {
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow;
if (!targetWindow || targetWindow.isDestroyed()) {
return;
}
if (targetWindow.isMinimized()) {
targetWindow.restore();
}
targetWindow.show();
targetWindow.focus();
}
async function launchApplication(): Promise<void> {
await nodecgManager.runPreflightChecks();
// 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 });
await nodecgManager.startNodecgProcess();
await nodecgManager.waitForNodecgReady(Date.now());
if (!loadingWindow || loadingWindow.isDestroyed()) {
return;
}
await loadingWindow.loadURL(loadingDashboardUrl);
loadingWindow.show();
const loadingShownAt = Date.now();
if (!mainWindow) {
return;
}
await mainWindow.loadURL(mainDashboardUrl);
// Keep the loading overlay visible for a minimum amount of time to avoid abrupt flashes.
const remainingLoadingDelay = getRemainingDelayMs(appConfig.loadDelayMs, loadingShownAt);
if (remainingLoadingDelay > 0) {
await sleep(remainingLoadingDelay);
}
mainWindow.show();
closeLoadingWindow();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function closeLoadingWindow(): void {
if (!loadingWindow || loadingWindow.isDestroyed()) {
return;
}
loadingWindow.close();
loadingWindow = null;
}
function stopNodecgGracefully(): Promise<void> {
if (shutdownState === "stopped") {
return Promise.resolve();
}
if (shutdownState === "stopping") {
return nodecgManager.stopNodecgProcessGracefully();
}
shutdownState = "stopping";
return nodecgManager.stopNodecgProcessGracefully().finally(() => {
shutdownState = "stopped";
});
}
app.on("ready", () => {
if (!hasSingleInstanceLock) {
return;
}
if (process.platform === "win32") {
app.setAppUserModelId(appConfig.userModelId);
}
launchApplication().catch((error: unknown) => {
showFatalError("No se pudo iniciar Scoreko.", error);
closeLoadingWindow();
app.exit(1);
});
});
app.on("second-instance", () => {
focusExistingWindow();
});
app.on("activate", async () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
await mainWindow.loadURL(mainDashboardUrl);
mainWindow.show();
}
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", (event) => {
if (shutdownState !== "running") {
return;
}
// Block the default quit flow until we ask NodeCG to stop cleanly.
event.preventDefault();
stopNodecgGracefully().finally(() => {
app.quit();
});
});
app.on("will-quit", () => {
if (shutdownState === "running") {
void stopNodecgGracefully();
}
});
process.on("exit", () => {
if (shutdownState === "running") {
void stopNodecgGracefully();
}
});
process.on("uncaughtException", (error) => {
showFatalError("Unexpected error in Electron main process.", error);
});
process.on("unhandledRejection", (reason) => {
showFatalError("Unhandled promise in Electron main process.", reason);
});
-58
View File
@@ -1,58 +0,0 @@
import { SpawnOptions } from "node:child_process";
export type PlatformProcessKillerDeps = {
platform: NodeJS.Platform;
spawnProcess: (command: string, args: string[], options: SpawnOptions) => SpawnedKillerProcess;
killProcess: (pid: number, signal: NodeJS.Signals) => void;
log: (...args: unknown[]) => void;
};
type SpawnedKillerProcess = {
on: (event: "error", listener: (error: Error) => void) => unknown;
};
export function killProcessTree(pid: number, signal: NodeJS.Signals, deps: PlatformProcessKillerDeps): boolean {
if (!Number.isSafeInteger(pid) || pid <= 0) {
deps.log(`Invalid pid for process tree termination: ${pid}`);
return false;
}
if (deps.platform === "win32") {
return killWindowsProcessTree(pid, signal, deps);
}
return killPosixProcessTree(pid, signal, deps.killProcess);
}
function killWindowsProcessTree(
pid: number,
signal: NodeJS.Signals,
deps: Pick<PlatformProcessKillerDeps, "spawnProcess" | "log">,
): boolean {
const args = ["/pid", String(pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])];
const killer = deps.spawnProcess("taskkill", args, {
stdio: "ignore",
shell: false,
windowsHide: true,
});
killer.on("error", (error) => {
deps.log(`taskkill error for pid=${pid}`, error);
});
return true;
}
function killPosixProcessTree(pid: number, signal: NodeJS.Signals, killProcess: PlatformProcessKillerDeps["killProcess"]): boolean {
try {
killProcess(-pid, signal);
return true;
} catch {
try {
killProcess(pid, signal);
return true;
} catch {
return false;
}
}
}
+78 -141
View File
@@ -1,11 +1,10 @@
import { spawn, SpawnOptions } from "node:child_process"; import { ChildProcess, spawn, SpawnOptions } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import net from "node:net"; import net from "node:net";
import path from "node:path"; import path from "node:path";
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { NODE_RUNTIME_NAME } from "../constants"; import { NODE_RUNTIME_NAME } from "../constants";
import { killProcessTree } from "./process-killer";
type NodecgProcessManagerConfig = { type NodecgProcessManagerConfig = {
isDev: boolean; isDev: boolean;
@@ -17,11 +16,10 @@ type NodecgProcessManagerConfig = {
}; };
type NodecgProcessManagerDeps = { type NodecgProcessManagerDeps = {
spawnProcess: (command: string, args: string[], options: SpawnOptions) => NodecgChildProcess; spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
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;
@@ -31,31 +29,14 @@ type NodecgProcessManagerDeps = {
hasReadWriteAccess: (candidatePath: string) => boolean; hasReadWriteAccess: (candidatePath: string) => boolean;
}; };
type NodecgChildProcess = {
pid?: number;
killed: boolean;
exitCode: number | null;
signalCode: NodeJS.Signals | null;
stdout?: ProcessOutputStream | null;
stderr?: ProcessOutputStream | null;
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
on(event: "error", listener: (error: Error) => void): unknown;
once(event: "exit", listener: () => void): unknown;
};
type ProcessOutputStream = {
on(event: "data", listener: (chunk: unknown) => void): unknown;
};
export type NodecgProcessManager = { export type NodecgProcessManager = {
startNodecgProcess: () => Promise<void>; runPreflightChecks: () => Promise<void>;
startNodecgProcess: () => Promise<ChildProcess>;
waitForNodecgReady: (startTime: number) => Promise<void>; waitForNodecgReady: (startTime: number) => Promise<void>;
stopNodecgProcessGracefully: () => Promise<void>; stopNodecgProcessGracefully: () => Promise<void>;
getState: () => NodecgProcessState; getProcess: () => ChildProcess | null;
}; };
type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
export function createNodecgProcessManager({ export function createNodecgProcessManager({
isDev, isDev,
nodecgRootPath, nodecgRootPath,
@@ -66,32 +47,15 @@ export function createNodecgProcessManager({
}: NodecgProcessManagerConfig): NodecgProcessManager { }: NodecgProcessManagerConfig): NodecgProcessManager {
const resolvedDeps = resolveDeps(deps); const resolvedDeps = resolveDeps(deps);
let nodecgProcess: NodecgChildProcess | null = null; let nodecgProcess: ChildProcess | null = null;
let nodecgState: NodecgProcessState = "idle";
let startNodecgPromise: Promise<void> | null = null;
let stopNodecgPromise: Promise<void> | null = null; let stopNodecgPromise: Promise<void> | null = null;
let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
let lastStderrLine: string | null = null; let lastStderrLine: string | null = null;
const startNodecgProcess = (): Promise<void> => { const runPreflightChecks = async (): Promise<void> => {
if (nodecgProcess && nodecgState === "running") {
return Promise.resolve();
}
if (startNodecgPromise) {
return startNodecgPromise;
}
if (nodecgState === "stopping") {
return Promise.reject(new Error("Cannot start NodeCG while shutdown is in progress."));
}
nodecgState = "starting";
startNodecgPromise = (async () => {
// 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,
); );
@@ -103,20 +67,22 @@ export function createNodecgProcessManager({
`Port ${appConfig.nodecgPort} is already in use. Stop the process using it or set NODECG_PORT before starting.`, `Port ${appConfig.nodecgPort} is already in use. Stop the process using it or set NODECG_PORT before starting.`,
); );
} }
};
const indexPath = path.join(nodecgRootPath, "index.js"); const startNodecgProcess = async (): Promise<ChildProcess> => {
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], { await runPreflightChecks();
const command = resolvedDeps.platform === "win32" ? "npx.cmd" : "npx";
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: false, shell: false,
windowsHide: true,
}); });
child.stdout?.on("data", (chunk) => { child.stdout?.on("data", (chunk) => {
@@ -134,34 +100,16 @@ export function createNodecgProcessManager({
child.on("exit", (code, signal) => { child.on("exit", (code, signal) => {
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`); log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
lastExit = { code, signal }; lastExit = { code, signal };
if (nodecgProcess === child) {
nodecgProcess = null; nodecgProcess = null;
}
if (nodecgState !== "stopping") {
nodecgState = code === 0 ? "stopped" : "failed";
}
}); });
lastExit = null; lastExit = null;
lastStderrLine = null; lastStderrLine = null;
nodecgProcess = child; nodecgProcess = child;
nodecgState = "running"; return child;
})()
.catch((error: unknown) => {
nodecgState = "failed";
throw error;
})
.finally(() => {
startNodecgPromise = null;
});
return startNodecgPromise;
}; };
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
@@ -174,7 +122,7 @@ export function createNodecgProcessManager({
exitDetails, exitDetails,
stderrDetails, stderrDetails,
`NodeCG path: ${nodecgRootPath}`, `NodeCG path: ${nodecgRootPath}`,
"Check that the packaged runtime was installed correctly and the bundle exists.", "Check that lib/scoreko-dev dependencies are installed and the bundle exists.",
].join("\n"), ].join("\n"),
); );
} }
@@ -195,13 +143,11 @@ 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;
} }
if (!nodecgProcess || nodecgProcess.killed) { if (!nodecgProcess || nodecgProcess.killed) {
nodecgState = "stopped";
return Promise.resolve(); return Promise.resolve();
} }
@@ -210,35 +156,18 @@ export function createNodecgProcessManager({
if (typeof pid !== "number") { if (typeof pid !== "number") {
log("NodeCG pid unavailable, skipping graceful stop"); log("NodeCG pid unavailable, skipping graceful stop");
nodecgProcess = null;
nodecgState = "stopped";
return Promise.resolve(); return Promise.resolve();
} }
nodecgState = "stopping";
log(`Stopping NodeCG pid=${pid}`); log(`Stopping NodeCG pid=${pid}`);
killProcessTree(pid, "SIGTERM", { killNodecgProcessTree(pid, "SIGTERM", log, resolvedDeps);
platform: resolvedDeps.platform,
spawnProcess: resolvedDeps.spawnProcess,
killProcess: resolvedDeps.killProcess,
log,
});
stopNodecgPromise = new Promise((resolve) => { stopNodecgPromise = new Promise((resolve) => {
let completed = false;
const complete = () => { const complete = () => {
if (completed) {
return;
}
completed = true;
if (nodecgProcess === processToStop) { if (nodecgProcess === processToStop) {
nodecgProcess = null; nodecgProcess = null;
} }
nodecgState = "stopped";
stopNodecgPromise = null; stopNodecgPromise = null;
resolve(); resolve();
}; };
@@ -251,13 +180,7 @@ export function createNodecgProcessManager({
() => { () => {
if (processToStop.exitCode === null && processToStop.signalCode === null) { if (processToStop.exitCode === null && processToStop.signalCode === null) {
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`); log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
killProcessTree(pid, "SIGKILL", { killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps);
platform: resolvedDeps.platform,
spawnProcess: resolvedDeps.spawnProcess,
killProcess: resolvedDeps.killProcess,
log,
});
complete();
} }
}, },
Math.max(0, appConfig.nodecgKillTimeoutMs), Math.max(0, appConfig.nodecgKillTimeoutMs),
@@ -268,10 +191,11 @@ export function createNodecgProcessManager({
}; };
return { return {
runPreflightChecks,
startNodecgProcess, startNodecgProcess,
waitForNodecgReady, waitForNodecgReady,
stopNodecgProcessGracefully, stopNodecgProcessGracefully,
getState: () => nodecgState, getProcess: () => nodecgProcess,
}; };
} }
@@ -281,7 +205,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,
@@ -294,43 +217,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. Build the packaged NodeCG runtime before starting Electron.`); 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: rebuild the packaged runtime:", "Solution: enter lib/scoreko-dev and install dependencies:",
" npm run prepare:runtime", " 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(", ")}`,
"Build and package the Scoreko bundle before running Electron.", "Ensure extensions/dashboard/graphics assets exist inside lib/scoreko-dev.",
].join("\n"), ].join("\n"),
); );
} }
@@ -347,38 +278,17 @@ 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);
});
}); });
} }
@@ -387,3 +297,30 @@ function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) =>
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);
}
}
}
-201
View File
@@ -1,201 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { getManagedNodecgRuntimePath } from "../app/paths";
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;
statSync: (filePath: string) => { isDirectory: () => boolean };
symlinkSync: (target: string, path: string, type: "junction") => unknown;
};
export type PreparedNodecgRuntime = {
runtimePath: string;
installed: boolean;
};
type RuntimeManifest = {
appVersion?: unknown;
bundleName?: unknown;
sourceRuntime?: RuntimeManifest | null;
bundleVersion?: unknown;
generatedAt?: 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): PreparedNodecgRuntime {
const resolvedDeps = resolveDeps(deps);
const targetRuntimePath = getManagedNodecgRuntimePath(userDataPath);
validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync);
resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true });
const installed = shouldInstallRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps);
if (installed) {
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 { runtimePath: targetRuntimePath, installed };
}
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,
statSync: deps?.statSync ?? fs.statSync,
symlinkSync: deps?.symlinkSync ?? fs.symlinkSync,
};
}
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?.generatedAt !== sourceMarker?.generatedAt ||
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 });
}
for (const entry of MANAGED_RUNTIME_ENTRIES) {
const sourcePath = path.join(sourceRuntimePath, entry);
const targetPath = path.join(targetRuntimePath, entry);
if (!deps.existsSync(sourcePath)) {
continue;
}
if (deps.statSync(sourcePath).isDirectory()) {
deps.symlinkSync(sourcePath, targetPath, "junction");
} else {
deps.cpSync(sourcePath, targetPath, { recursive: true, force: true, dereference: true });
}
}
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;
}
}
-47
View File
@@ -1,47 +0,0 @@
import { AppRuntimeConfig } from "../config/runtime-config";
import { readNonEmptyString } from "../utils/unknown-values";
import { validateHttpUrl } from "./update-schema";
const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$";
export type UpdateSettings = {
enabled: boolean;
apiUrl?: string;
releasePageUrl?: string;
assetPattern: string;
};
type UpdateConfigOptions = {
allowInsecureHttp: boolean;
};
type UpdateRuntimeConfig = Pick<
AppRuntimeConfig,
"updateApiUrl" | "updateAssetPattern" | "updateReleasePageUrl" | "updatesEnabled"
>;
export function loadUpdateSettings(
appConfig: UpdateRuntimeConfig,
rootPath: string,
log: (...args: unknown[]) => void,
options: UpdateConfigOptions = { allowInsecureHttp: true },
): UpdateSettings {
const apiUrl = readOptionalHttpUrl(appConfig.updateApiUrl, options);
const releasePageUrl = readOptionalHttpUrl(appConfig.updateReleasePageUrl, options);
return {
enabled: appConfig.updatesEnabled && Boolean(apiUrl),
...(apiUrl ? { apiUrl } : {}),
...(releasePageUrl ? { releasePageUrl } : {}),
assetPattern: appConfig.updateAssetPattern || DEFAULT_UPDATE_ASSET_PATTERN,
};
}
function readOptionalHttpUrl(value: unknown, options: UpdateConfigOptions): string | undefined {
const rawValue = readNonEmptyString(value);
if (!rawValue) {
return undefined;
}
return validateHttpUrl(rawValue, options) ?? undefined;
}
-65
View File
@@ -1,65 +0,0 @@
import { BrowserWindow, dialog } from "electron";
import type { MessageBoxOptions, MessageBoxReturnValue } from "electron";
import { ReleaseUpdate } from "./update-schema";
export type DownloadUpdateChoice = "download" | "open-release" | "dismiss";
export async function askToDownloadUpdate(
update: ReleaseUpdate,
releasePageUrl: string | undefined,
parentWindow: BrowserWindow | null,
): Promise<DownloadUpdateChoice> {
const result = await showMessageBox(parentWindow, {
type: "info",
title: "Actualización disponible",
message: `Scoreko ${update.version} está disponible.`,
detail: "Puedes descargarla ahora o seguir usando esta versión.",
buttons: releasePageUrl ? ["Descargar", "Ver release", "Ahora no"] : ["Descargar", "Ahora no"],
defaultId: 0,
cancelId: releasePageUrl ? 2 : 1,
});
if (releasePageUrl && result.response === 1) {
return "open-release";
}
return result.response === 0 ? "download" : "dismiss";
}
export async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: BrowserWindow | null): Promise<boolean> {
const result = await showMessageBox(parentWindow, {
type: "question",
title: "Actualización descargada",
message: `Scoreko ${update.version} se ha descargado.`,
detail: "Para instalarla se cerrará Scoreko y se abrirá el instalador.",
buttons: ["Instalar y cerrar", "Luego"],
defaultId: 0,
cancelId: 1,
});
return result.response === 0;
}
export async function showDownloadFailedDialog(
update: ReleaseUpdate,
error: unknown,
parentWindow: BrowserWindow | null,
): Promise<void> {
const errorMessage = error instanceof Error ? error.message : String(error);
await showMessageBox(parentWindow, {
type: "error",
title: "Error de descarga",
message: `No se pudo descargar la actualización para Scoreko ${update.version}.`,
detail: `Detalles del error: ${errorMessage}\n\nPor favor, comprueba tu conexión a internet e inténtalo de nuevo.`,
buttons: ["Aceptar"],
defaultId: 0,
});
}
function showMessageBox(
parentWindow: BrowserWindow | null,
options: MessageBoxOptions,
): Promise<MessageBoxReturnValue> {
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
}
-110
View File
@@ -1,110 +0,0 @@
import fs from "node:fs";
import { Writable } from "node:stream";
import { getSafeChildPath, getUpdateDownloadDirectory } from "../app/paths";
import { ReleaseUpdate, sanitizeFileName, validateHttpUrl } from "./update-schema";
type UpdateDownloadConfig = {
tempDirectory: string;
allowInsecureHttp: boolean;
};
export async function downloadInstaller(update: ReleaseUpdate, config: UpdateDownloadConfig): Promise<string> {
const downloadUrl = validateHttpUrl(update.installer.downloadUrl, {
allowInsecureHttp: config.allowInsecureHttp,
});
if (!downloadUrl) {
throw new Error("Update installer URL is invalid or uses an unsupported protocol.");
}
const safeFileName = sanitizeFileName(update.installer.name);
const downloadDirectory = getUpdateDownloadDirectory(config.tempDirectory);
const targetPath = getSafeChildPath(downloadDirectory, safeFileName);
const stagingPath = getSafeChildPath(downloadDirectory, `${safeFileName}.${process.pid}.${Date.now()}.download`);
if (fs.existsSync(targetPath)) {
const stats = fs.statSync(targetPath);
if (typeof update.installer.size === "number" && stats.size === update.installer.size) {
return targetPath;
}
}
fs.mkdirSync(downloadDirectory, { recursive: true });
fs.rmSync(stagingPath, { force: true });
const response = await fetch(downloadUrl);
if (!response.ok || !response.body) {
throw new Error(`Could not download update installer. HTTP ${response.status}.`);
}
try {
await writeResponseBodyToFile(response.body, stagingPath);
fs.renameSync(stagingPath, targetPath);
} catch (error) {
fs.rmSync(stagingPath, { force: true });
throw error;
}
return targetPath;
}
async function writeResponseBodyToFile(body: ReadableStream<Uint8Array>, filePath: string): Promise<void> {
const reader = body.getReader();
const fileStream = fs.createWriteStream(filePath, { flags: "wx" });
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) {
break;
}
await writeChunk(fileStream, chunk.value);
}
await endStream(fileStream);
} catch (error) {
fileStream.destroy();
throw error;
} finally {
reader.releaseLock();
}
}
function writeChunk(stream: Writable, chunk: Uint8Array): Promise<void> {
if (stream.write(chunk)) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const cleanup = (): void => {
stream.off("drain", onDrain);
stream.off("error", onError);
};
const onDrain = (): void => {
cleanup();
resolve();
};
const onError = (error: Error): void => {
cleanup();
reject(error);
};
stream.once("drain", onDrain);
stream.once("error", onError);
});
}
function endStream(stream: Writable): Promise<void> {
return new Promise((resolve, reject) => {
stream.end((error?: Error | null) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
-193
View File
@@ -1,193 +0,0 @@
import { isRecord, readNonEmptyString } from "../utils/unknown-values";
type GiteaReleaseAsset = {
name: string;
browserDownloadUrl: string;
size?: number;
};
export type GiteaRelease = {
tagName: string;
title?: string;
pageUrl?: string;
assets: GiteaReleaseAsset[];
};
export type InstallerAsset = {
name: string;
downloadUrl: string;
size?: number;
};
export type ReleaseUpdate = {
version: string;
title: string;
pageUrl?: string;
installer: InstallerAsset;
};
type UrlPolicy = {
allowInsecureHttp: boolean;
};
export function parseGiteaRelease(value: unknown): GiteaRelease | null {
if (!isRecord(value)) {
return null;
}
const tagName = readRequiredString(value.tag_name);
const assets = Array.isArray(value.assets) ? value.assets.map(parseGiteaReleaseAsset).filter(isPresent) : null;
if (!tagName || !assets) {
return null;
}
const title = readNonEmptyString(value.name);
const pageUrl = readOptionalUrlString(value.html_url);
return {
tagName,
assets,
...(title ? { title } : {}),
...(pageUrl ? { pageUrl } : {}),
};
}
export function isVersionNewer(candidateVersion: string, currentVersion: string): boolean {
const candidate = normalizeVersion(candidateVersion);
const current = normalizeVersion(currentVersion);
for (let index = 0; index < Math.max(candidate.length, current.length); index += 1) {
const candidatePart = candidate[index] ?? 0;
const currentPart = current[index] ?? 0;
if (candidatePart > currentPart) {
return true;
}
if (candidatePart < currentPart) {
return false;
}
}
return false;
}
export function selectInstallerAsset(
release: GiteaRelease,
assetPattern: string,
policy: UrlPolicy = { allowInsecureHttp: true },
): InstallerAsset | null {
const matcher = new RegExp(assetPattern, "i");
for (const asset of release.assets) {
if (!matcher.test(asset.name)) {
continue;
}
const downloadUrl = validateHttpUrl(asset.browserDownloadUrl, policy);
if (!downloadUrl) {
continue;
}
return {
name: asset.name,
downloadUrl,
...(typeof asset.size === "number" ? { size: asset.size } : {}),
};
}
return null;
}
export function buildReleaseUpdate(
release: GiteaRelease,
currentVersion: string,
assetPattern: string,
policy: UrlPolicy = { allowInsecureHttp: true },
): ReleaseUpdate | null {
const version = release.tagName.replace(/^v/i, "");
if (!version || !isVersionNewer(version, currentVersion)) {
return null;
}
const installer = selectInstallerAsset(release, assetPattern, policy);
if (!installer) {
return null;
}
const pageUrl = release.pageUrl ? validateHttpUrl(release.pageUrl, policy) ?? undefined : undefined;
return {
version,
title: release.title ?? `Scoreko ${version}`,
...(pageUrl ? { pageUrl } : {}),
installer,
};
}
export function sanitizeFileName(fileName: string): string {
const sanitized = fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim();
return sanitized.length > 0 ? sanitized : "scoreko-update-installer";
}
export function validateHttpUrl(value: string, policy: UrlPolicy): string | null {
try {
const url = new URL(value);
if (url.protocol === "https:" || (policy.allowInsecureHttp && url.protocol === "http:")) {
return url.toString();
}
return null;
} catch {
return null;
}
}
function parseGiteaReleaseAsset(value: unknown): GiteaReleaseAsset | null {
if (!isRecord(value)) {
return null;
}
const name = readRequiredString(value.name);
const browserDownloadUrl = readRequiredString(value.browser_download_url);
if (!name || !browserDownloadUrl) {
return null;
}
return {
name,
browserDownloadUrl,
...(typeof value.size === "number" && value.size >= 0 ? { size: value.size } : {}),
};
}
function normalizeVersion(version: string): number[] {
return version
.trim()
.replace(/^v/i, "")
.split(/[+-]/)[0]
.split(".")
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0));
}
function readRequiredString(value: unknown): string | null {
const text = readNonEmptyString(value);
return text && text.length > 0 ? text : null;
}
function readOptionalUrlString(value: unknown): string | undefined {
const rawValue = readNonEmptyString(value);
if (!rawValue) {
return undefined;
}
return validateHttpUrl(rawValue, { allowInsecureHttp: true }) ?? undefined;
}
function isPresent<T>(value: T | null): value is T {
return value !== null;
}
-137
View File
@@ -1,137 +0,0 @@
import { app, BrowserWindow, shell } from "electron";
import { AppRuntimeConfig } from "../config/runtime-config";
import { askToDownloadUpdate, askToInstallUpdate, showDownloadFailedDialog } from "./update-dialogs";
import { loadUpdateSettings, UpdateSettings } from "./update-config";
import { downloadInstaller } from "./update-download";
import { buildReleaseUpdate, GiteaRelease, parseGiteaRelease } from "./update-schema";
type UpdateServiceConfig = {
appConfig: AppRuntimeConfig;
rootPath: string;
getParentWindow: () => BrowserWindow | null;
beforeInstall: () => Promise<void>;
log: (...args: unknown[]) => void;
};
type UpdateProtocolPolicy = {
allowInsecureHttp: boolean;
};
export function scheduleUpdateCheck({
appConfig,
rootPath,
getParentWindow,
beforeInstall,
log,
}: UpdateServiceConfig): void {
const protocolPolicy = getUpdateProtocolPolicy();
const settings = loadUpdateSettings(appConfig, rootPath, log, protocolPolicy);
if (!settings.enabled || !settings.apiUrl) {
log("Update checks disabled or not configured.");
return;
}
setTimeout(() => {
void checkForUpdates({ settings, getParentWindow, beforeInstall, log, protocolPolicy });
}, appConfig.updateCheckDelayMs);
}
async function checkForUpdates({
settings,
getParentWindow,
beforeInstall,
log,
protocolPolicy,
}: {
settings: UpdateSettings;
getParentWindow: () => BrowserWindow | null;
beforeInstall: () => Promise<void>;
log: (...args: unknown[]) => void;
protocolPolicy: UpdateProtocolPolicy;
}): Promise<void> {
try {
if (!settings.apiUrl) {
return;
}
const release = await fetchLatestRelease(settings.apiUrl);
const update = buildReleaseUpdate(release, app.getVersion(), settings.assetPattern, protocolPolicy);
if (!update) {
log("No Scoreko update available.");
return;
}
const releasePageUrl = settings.releasePageUrl ?? update.pageUrl;
const downloadChoice = await askToDownloadUpdate(update, releasePageUrl, getParentWindow());
if (downloadChoice === "open-release") {
await openReleasePage(releasePageUrl);
return;
}
if (downloadChoice !== "download") {
return;
}
let installerPath: string;
try {
installerPath = await downloadInstaller(update, {
tempDirectory: app.getPath("temp"),
allowInsecureHttp: protocolPolicy.allowInsecureHttp,
});
} catch (error) {
log("Update installer download failed.", error);
await showDownloadFailedDialog(update, error, getParentWindow());
return;
}
const shouldInstall = await askToInstallUpdate(update, getParentWindow());
if (!shouldInstall) {
await shell.showItemInFolder(installerPath);
return;
}
await beforeInstall();
const openError = await shell.openPath(installerPath);
if (openError) {
throw new Error(openError);
}
app.exit(0);
} catch (error) {
log("Update check failed.", error);
}
}
async function fetchLatestRelease(apiUrl: string): Promise<GiteaRelease> {
const response = await fetch(apiUrl, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Gitea update check failed with HTTP ${response.status}.`);
}
const release = parseGiteaRelease(await response.json());
if (!release) {
throw new Error("Gitea update metadata is invalid.");
}
return release;
}
async function openReleasePage(releasePageUrl: string | undefined): Promise<void> {
if (releasePageUrl) {
await shell.openExternal(releasePageUrl);
}
}
function getUpdateProtocolPolicy(): UpdateProtocolPolicy {
return {
allowInsecureHttp: !app.isPackaged,
};
}
-7
View File
@@ -1,7 +0,0 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
@@ -1,28 +1,19 @@
import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron"; import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron";
import electronLog from "electron-log";
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants"; import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
import { resolveAppIconPath } from "./icon-path"; import { resolveAppIconPath } from "./icon-path";
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation"; import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-security";
type WindowServiceDependencies = { type WindowFactoryDependencies = {
appConfig: AppRuntimeConfig; appConfig: AppRuntimeConfig;
allowDevTools: boolean;
rootPath: string; rootPath: string;
mainDashboardUrl: string; mainDashboardUrl: string;
}; };
export function createMainWindow({ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: WindowFactoryDependencies): BrowserWindow {
allowDevTools, const windowOptions = createWindowOptions({ appConfig, rootPath, isLoadingWindow: false });
appConfig,
rootPath,
mainDashboardUrl,
}: WindowServiceDependencies): BrowserWindow {
const windowOptions = createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: false });
const window = new BrowserWindow(windowOptions); const window = new BrowserWindow(windowOptions);
applySecurityPolicies(window, allowDevTools);
window.setMenuBarVisibility(false); window.setMenuBarVisibility(false);
window.webContents.setWindowOpenHandler(({ url }) => { window.webContents.setWindowOpenHandler(({ url }) => {
@@ -34,12 +25,6 @@ export function createMainWindow({
}); });
window.webContents.on("will-navigate", (event, url) => { window.webContents.on("will-navigate", (event, url) => {
if (url.startsWith("app://open-logs")) {
event.preventDefault();
void shell.showItemInFolder(electronLog.transports.file.getFile().path);
return;
}
if (shouldAllowInternalNavigation(url, mainDashboardUrl)) { if (shouldAllowInternalNavigation(url, mainDashboardUrl)) {
return; return;
} }
@@ -59,13 +44,10 @@ export function createMainWindow({
} }
export function createLoadingWindow({ export function createLoadingWindow({
allowDevTools,
appConfig, appConfig,
rootPath, rootPath,
}: Omit<WindowServiceDependencies, "mainDashboardUrl">): BrowserWindow { }: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
const window = new BrowserWindow(createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: true })); const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true }));
applySecurityPolicies(window, allowDevTools);
window.on("page-title-updated", (event) => { window.on("page-title-updated", (event) => {
event.preventDefault(); event.preventDefault();
@@ -75,12 +57,10 @@ export function createLoadingWindow({
} }
function createWindowOptions({ function createWindowOptions({
allowDevTools,
appConfig, appConfig,
rootPath, rootPath,
isLoadingWindow, isLoadingWindow,
}: { }: {
allowDevTools: boolean;
appConfig: AppRuntimeConfig; appConfig: AppRuntimeConfig;
rootPath: string; rootPath: string;
isLoadingWindow: boolean; isLoadingWindow: boolean;
@@ -94,10 +74,8 @@ function createWindowOptions({
backgroundColor: DEFAULT_WINDOW_BACKGROUND, backgroundColor: DEFAULT_WINDOW_BACKGROUND,
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
devTools: allowDevTools,
nodeIntegration: false,
sandbox: true, sandbox: true,
webSecurity: true, ...(isLoadingWindow ? {} : { nodeIntegration: false }),
}, },
}; };
@@ -122,26 +100,3 @@ function createWindowOptions({
minHeight: DEFAULT_WINDOW_SIZE.minHeight, minHeight: DEFAULT_WINDOW_SIZE.minHeight,
}; };
} }
function applySecurityPolicies(window: BrowserWindow, allowDevTools: boolean): void {
window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false);
});
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: http://localhost:* http://127.0.0.1:*; connect-src * ws: wss:; img-src * data: blob:; media-src * data: blob:; font-src * data:;"
]
}
});
});
if (!allowDevTools) {
window.webContents.on("devtools-opened", () => {
window.webContents.closeDevTools();
});
}
}
-56
View File
@@ -1,56 +0,0 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import {
getApplicationPaths,
getDashboardUrl,
getManagedNodecgRuntimePath,
getNodecgBaseUrl,
getRootPath,
getSafeChildPath,
getSourceNodecgRuntimePath,
getUpdateDownloadDirectory,
getUserDataPath,
} from "../main/app/paths";
test("app path helpers build deterministic development paths and URLs", () => {
const compiledMainDir = path.join("repo", "dist", "main");
const rootPath = getRootPath(true, compiledMainDir, "/resources");
assert.equal(rootPath, path.resolve(compiledMainDir, "../.."));
assert.equal(getSourceNodecgRuntimePath(rootPath), path.resolve(rootPath, "lib", "nodecg"));
assert.equal(getUserDataPath("/app-data", "scoreko"), path.join("/app-data", "scoreko"));
assert.equal(getManagedNodecgRuntimePath("/app-data/scoreko"), path.join("/app-data/scoreko", "nodecg"));
assert.equal(getUpdateDownloadDirectory("/tmp"), path.join("/tmp", "scoreko-updates"));
assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090");
assert.equal(
getDashboardUrl("9090", "scoreko-dev", "dashboard/main.html?standalone=true"),
"http://localhost:9090/bundles/scoreko-dev/dashboard/main.html?standalone=true",
);
});
test("getApplicationPaths keeps packaged root under Electron resources", () => {
const paths = getApplicationPaths({
appConfig: {
userDataDirectoryName: "scoreko",
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
},
appDataPath: "/users/test/AppData/Roaming",
compiledMainDir: "/app/dist/main",
isDev: false,
resourcesPath: "/opt/Scoreko/resources",
});
assert.equal(paths.rootPath, "/opt/Scoreko/resources");
assert.equal(paths.sourceNodecgRuntimePath, path.resolve("/opt/Scoreko/resources", "lib", "nodecg"));
assert.equal(paths.userDataPath, path.join("/users/test/AppData/Roaming", "scoreko"));
assert.equal(paths.nodecgBaseUrl, "http://127.0.0.1:9090");
});
test("getSafeChildPath rejects path traversal", () => {
assert.equal(getSafeChildPath("/tmp/scoreko-updates", "setup.exe"), path.resolve("/tmp/scoreko-updates/setup.exe"));
assert.throws(() => getSafeChildPath("/tmp/scoreko-updates", "../setup.exe"), /outside/);
});
-289
View File
@@ -1,289 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createApplicationController, ApplicationWindow } from "../main/app/application-controller";
import { AppRuntimeConfig } from "../main/config/runtime-config";
import { NodecgProcessManager } from "../main/nodecg/process-manager";
class MockWindow implements ApplicationWindow {
private destroyed = false;
private minimized = false;
constructor(
private readonly name: string,
private readonly events: string[],
) {}
close(): void {
this.events.push(`${this.name}:close`);
this.destroyed = true;
}
focus(): void {
this.events.push(`${this.name}:focus`);
}
isDestroyed(): boolean {
return this.destroyed;
}
isMinimized(): boolean {
return this.minimized;
}
async loadURL(url: string): Promise<void> {
this.events.push(`${this.name}:load:${url}`);
}
async loadFile(filePath: string): Promise<void> {
this.events.push(`${this.name}:loadFile:${filePath}`);
}
restore(): void {
this.events.push(`${this.name}:restore`);
this.minimized = false;
}
show(): void {
this.events.push(`${this.name}:show`);
}
}
function getBaseConfig(): AppRuntimeConfig {
return {
title: "Scoreko",
userModelId: "com.scoreko.desktop",
userDataDirectoryName: "scoreko",
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadDelayMs: 0,
startupTimeoutMs: 100,
nodecgKillTimeoutMs: 10,
updatesEnabled: true,
updateAssetPattern: "Scoreko-setup-.*\\.exe$",
updateCheckDelayMs: 5000,
};
}
function createMockManager(events: string[]): NodecgProcessManager {
return {
startNodecgProcess: async () => {
events.push("start-nodecg");
},
waitForNodecgReady: async () => {
events.push("wait-nodecg");
},
stopNodecgProcessGracefully: async () => {
events.push("stop-nodecg");
},
getState: () => "running",
};
}
test("ApplicationController preserves startup ordering and schedules updates after main window is shown", async () => {
const events: string[] = [];
const paths = {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html?standalone=true",
staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
};
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: false,
isWindows: true,
paths,
deps: {
createLoadingWindow: () => {
events.push("create-loading");
return new MockWindow("loading", events);
},
createMainWindow: () => {
events.push("create-main");
return new MockWindow("main", events);
},
createNodecgProcessManager: () => {
events.push("create-manager");
return createMockManager(events);
},
getAllWindows: () => [],
log: () => undefined,
prepareRuntime: () => {
events.push("prepare-runtime");
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
},
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
now: () => 0,
sleep: async (ms) => {
events.push(`sleep:${ms}`);
},
},
});
await controller.launch();
assert.equal(controller.getState(), "ready");
assert.deepEqual(events, [
"set-app-user-model-id",
"create-main",
"create-loading",
`loading:loadFile:${paths.staticLoadingHtmlPath}`,
"loading:show",
"sleep:50",
"prepare-runtime",
"create-manager",
"start-nodecg",
"wait-nodecg",
`main:load:${paths.mainDashboardUrl}`,
"main:show",
"loading:close",
"schedule-update",
]);
});
test("ApplicationController directly launches packaged app after runtime install without relaunching", async () => {
const events: string[] = [];
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: true,
isWindows: false,
paths: {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
},
deps: {
createLoadingWindow: () => {
events.push("create-loading");
return new MockWindow("loading", events);
},
createMainWindow: () => {
events.push("create-main");
return new MockWindow("main", events);
},
createNodecgProcessManager: () => {
events.push("create-manager");
return createMockManager(events);
},
getAllWindows: () => [],
log: (...args) => events.push(String(args[0])),
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }),
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
now: () => 0,
sleep: async (ms) => {
events.push(`sleep:${ms}`);
},
},
});
await controller.launch();
assert.equal(controller.getState(), "ready");
assert.deepEqual(events, [
"create-main",
"create-loading",
"loading:loadFile:/app/static/loading.html",
"loading:show",
"sleep:50",
"create-manager",
"start-nodecg",
"wait-nodecg",
"main:load:http://localhost:9090/main",
"main:show",
"loading:close",
"schedule-update",
]);
});
test("ApplicationController activation before readiness routes through launch", async () => {
const events: string[] = [];
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: false,
isWindows: false,
paths: {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
},
deps: {
createLoadingWindow: () => new MockWindow("loading", events),
createMainWindow: () => new MockWindow("main", events),
createNodecgProcessManager: () => createMockManager(events),
getAllWindows: () => [],
log: () => undefined,
prepareRuntime: () => {
events.push("prepare-runtime");
return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
},
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
now: () => 0,
},
});
await controller.activate();
assert.equal(controller.getState(), "ready");
assert.ok(events.includes("prepare-runtime"));
assert.ok(events.includes("start-nodecg"));
assert.ok(events.includes("wait-nodecg"));
});
test("ApplicationController shutdown is idempotent", async () => {
const events: string[] = [];
const controller = createApplicationController({
appConfig: getBaseConfig(),
appVersion: "0.1.0",
isPackaged: false,
isWindows: false,
paths: {
rootPath: "/app",
sourceNodecgRuntimePath: "/app/lib/nodecg",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
},
deps: {
createLoadingWindow: () => new MockWindow("loading", events),
createMainWindow: () => new MockWindow("main", events),
createNodecgProcessManager: () => createMockManager(events),
getAllWindows: () => [],
log: () => undefined,
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: false }),
scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`),
now: () => 0,
},
});
await controller.launch();
await Promise.all([controller.stopNodecgGracefully(), controller.stopNodecgGracefully()]);
assert.equal(controller.getState(), "stopped");
assert.equal(events.filter((event) => event === "stop-nodecg").length, 1);
});
@@ -1,48 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
const FORBIDDEN_MAIN_SURFACE_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
{ label: "ipcMain", pattern: /\bipcMain\b/ },
{ label: "ipcRenderer", pattern: /\bipcRenderer\b/ },
{ label: "contextBridge", pattern: /\bcontextBridge\b/ },
{ label: "preload", pattern: /\bpreload\b/ },
];
test("main source does not expose IPC or preload surface", () => {
const sourceRoot = path.join(process.cwd(), "src", "main");
const failures: string[] = [];
for (const filePath of readTypeScriptFiles(sourceRoot)) {
const contents = fs.readFileSync(filePath, "utf8");
for (const { label, pattern } of FORBIDDEN_MAIN_SURFACE_PATTERNS) {
if (pattern.test(contents)) {
failures.push(`${path.relative(process.cwd(), filePath)} contains ${label}`);
}
}
}
assert.deepEqual(failures, []);
});
function readTypeScriptFiles(directoryPath: string): string[] {
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const entryPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
files.push(...readTypeScriptFiles(entryPath));
continue;
}
if (entry.isFile() && entry.name.endsWith(".ts")) {
files.push(entryPath);
}
}
return files;
}
+1 -3
View File
@@ -13,12 +13,10 @@ function getBaseConfig(): AppRuntimeConfig {
nodecgPort: "9090", nodecgPort: "9090",
bundleName: "scoreko-dev", bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true", mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 10000, loadDelayMs: 10000,
startupTimeoutMs: 30000, startupTimeoutMs: 30000,
nodecgKillTimeoutMs: 2500, nodecgKillTimeoutMs: 2500,
updatesEnabled: true,
updateAssetPattern: "Scoreko-setup-.*\\.exe$",
updateCheckDelayMs: 5000,
}; };
} }
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation"; import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation-security";
const dashboardUrl = "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html"; const dashboardUrl = "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html";
-65
View File
@@ -1,65 +0,0 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { SpawnOptions } from "node:child_process";
import test from "node:test";
import { killProcessTree } from "../main/nodecg/process-killer";
test("killProcessTree validates pid before building Windows taskkill command", () => {
const spawnCalls: Array<{ command: string; args: string[]; options: SpawnOptions }> = [];
const killed = killProcessTree(Number.NaN, "SIGTERM", {
platform: "win32",
spawnProcess: (command, args, options) => {
spawnCalls.push({ command, args, options });
return new EventEmitter() as import("node:child_process").ChildProcess;
},
killProcess: () => undefined,
log: () => undefined,
});
assert.equal(killed, false);
assert.deepEqual(spawnCalls, []);
});
test("killProcessTree builds a narrow Windows taskkill invocation", () => {
const spawnCalls: Array<{ command: string; args: string[]; options: SpawnOptions }> = [];
const killed = killProcessTree(1234, "SIGKILL", {
platform: "win32",
spawnProcess: (command, args, options) => {
spawnCalls.push({ command, args, options });
return new EventEmitter() as import("node:child_process").ChildProcess;
},
killProcess: () => undefined,
log: () => undefined,
});
assert.equal(killed, true);
assert.equal(spawnCalls[0]?.command, "taskkill");
assert.deepEqual(spawnCalls[0]?.args, ["/pid", "1234", "/T", "/F"]);
assert.equal(spawnCalls[0]?.options.shell, false);
assert.equal(spawnCalls[0]?.options.windowsHide, true);
});
test("killProcessTree falls back from POSIX process group to child pid", () => {
const killCalls: Array<{ pid: number; signal: NodeJS.Signals }> = [];
const killed = killProcessTree(1234, "SIGTERM", {
platform: "linux",
spawnProcess: () => new EventEmitter() as import("node:child_process").ChildProcess,
killProcess: (pid, signal) => {
killCalls.push({ pid, signal });
if (pid < 0) {
throw new Error("process group unavailable");
}
},
log: () => undefined,
});
assert.equal(killed, true);
assert.deepEqual(killCalls, [
{ pid: -1234, signal: "SIGTERM" },
{ pid: 1234, signal: "SIGTERM" },
]);
});
+43 -100
View File
@@ -1,6 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { SpawnOptions } from "node:child_process";
import test from "node:test"; import test from "node:test";
import { AppRuntimeConfig } from "../main/config/runtime-config"; import { AppRuntimeConfig } from "../main/config/runtime-config";
@@ -26,19 +25,17 @@ function getBaseConfig(): AppRuntimeConfig {
nodecgPort: "9090", nodecgPort: "9090",
bundleName: "scoreko-dev", bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true", mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 10000, loadDelayMs: 10000,
startupTimeoutMs: 100, startupTimeoutMs: 100,
nodecgKillTimeoutMs: 10, nodecgKillTimeoutMs: 10,
updatesEnabled: true,
updateAssetPattern: "Scoreko-setup-.*\\.exe$",
updateCheckDelayMs: 5000,
}; };
} }
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,
@@ -52,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,
@@ -70,21 +67,47 @@ 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("runPreflightChecks validates install and port availability without starting NodeCG", async () => {
let spawnCalls = 0;
const manager = createNodecgProcessManager({
isDev: true,
nodecgRootPath: "/fake/scoreko-dev",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
deps: {
pathExists: () => true,
hasReadWriteAccess: () => true,
probePortAvailable: async () => true,
spawnProcess: () => {
spawnCalls += 1;
throw new Error("should not spawn during preflight");
},
},
});
await assert.doesNotReject(async () => {
await manager.runPreflightChecks();
});
assert.equal(spawnCalls, 0);
});
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,
deps: { deps: {
platform: "linux", platform: "linux",
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child, spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => { setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
handler(); handler();
@@ -110,14 +133,14 @@ 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,
deps: { deps: {
platform: "linux", platform: "linux",
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child, spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
killProcess: (pid, signal) => { killProcess: (pid, signal) => {
killSignals.push({ pid, signal }); killSignals.push({ pid, signal });
@@ -156,13 +179,13 @@ 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,
deps: { deps: {
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child, spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
killProcess: () => undefined, killProcess: () => undefined,
setTimer: () => 0, setTimer: () => 0,
@@ -183,55 +206,13 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
await firstStop; await firstStop;
}); });
test("startNodeCG reuses the same promise while startup is in progress", async () => {
const child = new MockChildProcess(2468);
let spawnCalls = 0;
let resolveProbe: (isAvailable: boolean) => void = () => {
throw new Error("probe promise was not created");
};
const manager = createNodecgProcessManager({
isDev: true,
nodecgRootPath: "/fake/nodecg",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
deps: {
pathExists: () => true,
hasReadWriteAccess: () => true,
probePortAvailable: () =>
new Promise<boolean>((resolve) => {
resolveProbe = resolve;
}),
spawnProcess: () => {
spawnCalls += 1;
return child;
},
stdoutWrite: () => undefined,
stderrWrite: () => undefined,
},
});
const firstStart = manager.startNodecgProcess();
const secondStart = manager.startNodecgProcess();
assert.equal(firstStart, secondStart);
assert.equal(manager.getState(), "starting");
resolveProbe(true);
await firstStart;
assert.equal(spawnCalls, 1);
assert.equal(manager.getState(), "running");
});
test("stopNodeCG normalizes negative timeout to zero", async () => { test("stopNodeCG normalizes negative timeout to zero", async () => {
const child = new MockChildProcess(7777); const child = new MockChildProcess(7777);
const timeouts: number[] = []; const timeouts: number[] = [];
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(),
@@ -240,7 +221,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
log: () => undefined, log: () => undefined,
deps: { deps: {
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child, spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
killProcess: () => undefined, killProcess: () => undefined,
setTimer: (handler, timeoutMs) => { setTimer: (handler, timeoutMs) => {
@@ -267,7 +248,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,
@@ -283,56 +264,18 @@ test("startNodeCG fails if the port is already in use", async () => {
}, /is already in use/); }, /is already in use/);
}); });
test("startNodeCG spawns Electron directly on Windows", async () => {
const child = new MockChildProcess(3210);
let capturedCommand: string | null = null;
let capturedArgs: string[] | null = null;
const capturedOptions: SpawnOptions[] = [];
const manager = createNodecgProcessManager({
isDev: false,
nodecgRootPath: "C:\\Users\\tester\\AppData\\Roaming\\scoreko\\nodecg",
nodecgBaseUrl: "http://127.0.0.1:9090",
appConfig: getBaseConfig(),
log: () => undefined,
deps: {
platform: "win32",
execPath: "C:\\Program Files\\Scoreko\\scoreko.exe",
pathExists: () => true,
hasReadWriteAccess: () => true,
probePortAvailable: async () => true,
spawnProcess: (command, args, options) => {
capturedCommand = command;
capturedArgs = args;
capturedOptions.push(options);
return child;
},
stdoutWrite: () => undefined,
stderrWrite: () => undefined,
},
});
await manager.startNodecgProcess();
assert.equal(capturedCommand, "C:\\Program Files\\Scoreko\\scoreko.exe");
assert.deepEqual(capturedArgs, ["C:\\Users\\tester\\AppData\\Roaming\\scoreko\\nodecg\\index.js"]);
assert.equal(capturedOptions[0]?.shell, false);
assert.equal(capturedOptions[0]?.windowsHide, true);
assert.equal(capturedOptions[0]?.env?.ELECTRON_RUN_AS_NODE, "1");
});
test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness", async () => { test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness", async () => {
const child = new MockChildProcess(4242); const child = new MockChildProcess(4242);
const manager = createNodecgProcessManager({ const manager = createNodecgProcessManager({
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,
deps: { deps: {
pathExists: () => true, pathExists: () => true,
platform: "linux", platform: "linux",
spawnProcess: () => child, spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
fetchUrl: async () => { fetchUrl: async () => {
child.emit("exit", 1, null); child.emit("exit", 1, null);
throw new Error("still starting"); throw new Error("still starting");
@@ -358,7 +301,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;
}, },
); );
+13 -187
View File
@@ -1,21 +1,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import path from "node:path";
import { import { getEnv, getOptionalEnv, parseEnvInt, parseEnvIntInRange, parseEnvPort } from "../main/config/runtime-config";
getEnv,
getOptionalEnv,
parseEnvBool,
parseEnvIntInRange,
parseEnvPort,
parseOptionalHttpUrl,
loadEnvFile,
getRuntimeConfig,
getRequiredEnv,
parseRequiredEnvIntInRange,
parseRequiredEnvBool,
parseRequiredEnvPort,
} from "../main/config/runtime-config";
function withEnv(name: string, value: string | undefined, run: () => void): void { function withEnv(name: string, value: string | undefined, run: () => void): void {
const previousValue = process.env[name]; const previousValue = process.env[name];
@@ -38,30 +24,6 @@ function withEnv(name: string, value: string | undefined, run: () => void): void
} }
} }
function withEnvs(envs: Record<string, string | undefined>, run: () => void): void {
const previousValues: Record<string, string | undefined> = {};
for (const name of Object.keys(envs)) {
previousValues[name] = process.env[name];
if (envs[name] === undefined) {
delete process.env[name];
} else {
process.env[name] = envs[name];
}
}
try {
run();
} finally {
for (const name of Object.keys(envs)) {
if (previousValues[name] === undefined) {
delete process.env[name];
} else {
process.env[name] = previousValues[name];
}
}
}
}
test("getOptionalEnv returns undefined for missing variable", () => { test("getOptionalEnv returns undefined for missing variable", () => {
withEnv("TEST_OPTIONAL_ENV", undefined, () => { withEnv("TEST_OPTIONAL_ENV", undefined, () => {
assert.equal(getOptionalEnv("TEST_OPTIONAL_ENV"), undefined); assert.equal(getOptionalEnv("TEST_OPTIONAL_ENV"), undefined);
@@ -86,6 +48,18 @@ test("getEnv returns the value when present", () => {
}); });
}); });
test("parseEnvInt returns fallback for invalid values", () => {
withEnv("TEST_ENV_INT", "abc", () => {
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 100);
});
});
test("parseEnvInt parses valid integers", () => {
withEnv("TEST_ENV_INT", "4500", () => {
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 4500);
});
});
test("parseEnvIntInRange hard-fails for out-of-range values", () => { test("parseEnvIntInRange hard-fails for out-of-range values", () => {
withEnv("TEST_ENV_INT_RANGE", "999", () => { withEnv("TEST_ENV_INT_RANGE", "999", () => {
assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /must be an integer/); assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /must be an integer/);
@@ -109,151 +83,3 @@ test("parseEnvPort normalizes valid port", () => {
assert.equal(parseEnvPort("TEST_ENV_PORT", "9090"), "9090"); assert.equal(parseEnvPort("TEST_ENV_PORT", "9090"), "9090");
}); });
}); });
test("parseEnvBool accepts common true and false values", () => {
withEnv("TEST_ENV_BOOL", "yes", () => {
assert.equal(parseEnvBool("TEST_ENV_BOOL", false), true);
});
withEnv("TEST_ENV_BOOL", "off", () => {
assert.equal(parseEnvBool("TEST_ENV_BOOL", true), false);
});
});
test("parseEnvBool rejects invalid values", () => {
withEnv("TEST_ENV_BOOL", "maybe", () => {
assert.throws(() => parseEnvBool("TEST_ENV_BOOL", true), /must be a boolean/);
});
});
test("parseOptionalHttpUrl accepts HTTP and HTTPS urls", () => {
withEnv("TEST_UPDATE_URL", "http://gitea.local/api/v1/repos/owner/repo/releases/latest", () => {
assert.equal(parseOptionalHttpUrl("TEST_UPDATE_URL"), "http://gitea.local/api/v1/repos/owner/repo/releases/latest");
});
});
test("parseOptionalHttpUrl rejects unsupported protocols", () => {
withEnv("TEST_UPDATE_URL", "file:///tmp/latest", () => {
assert.throws(() => parseOptionalHttpUrl("TEST_UPDATE_URL"), /valid HTTP\(S\) URL/);
});
});
test("loadEnvFile throws on non-existent file", () => {
const missingPath = path.join(__dirname, "does-not-exist-.env");
assert.throws(() => loadEnvFile(missingPath), /Archivo de configuración obligatorio no encontrado/);
});
test("getRequiredEnv throws on missing or empty variable", () => {
withEnv("TEST_REQUIRED_ENV", undefined, () => {
assert.throws(() => getRequiredEnv("TEST_REQUIRED_ENV"), /no está definida/);
});
withEnv("TEST_REQUIRED_ENV", " ", () => {
assert.throws(() => getRequiredEnv("TEST_REQUIRED_ENV"), /no está definida/);
});
});
test("getRequiredEnv returns trimmed value when present", () => {
withEnv("TEST_REQUIRED_ENV", " scoreko-app ", () => {
assert.equal(getRequiredEnv("TEST_REQUIRED_ENV"), "scoreko-app");
});
});
test("parseRequiredEnvIntInRange validates required integer and throws if missing", () => {
withEnv("TEST_REQ_INT", undefined, () => {
assert.throws(() => parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), /no está definida/);
});
withEnv("TEST_REQ_INT", "150", () => {
assert.throws(() => parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), /must be an integer/);
});
withEnv("TEST_REQ_INT", "42", () => {
assert.equal(parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), 42);
});
});
test("parseRequiredEnvBool validates required boolean and throws if missing", () => {
withEnv("TEST_REQ_BOOL", undefined, () => {
assert.throws(() => parseRequiredEnvBool("TEST_REQ_BOOL"), /no está definida/);
});
withEnv("TEST_REQ_BOOL", "maybe", () => {
assert.throws(() => parseRequiredEnvBool("TEST_REQ_BOOL"), /must be a boolean/);
});
withEnv("TEST_REQ_BOOL", "true", () => {
assert.equal(parseRequiredEnvBool("TEST_REQ_BOOL"), true);
});
withEnv("TEST_REQ_BOOL", "off", () => {
assert.equal(parseRequiredEnvBool("TEST_REQ_BOOL"), false);
});
});
test("parseRequiredEnvPort validates required port and throws if missing", () => {
withEnv("TEST_REQ_PORT", undefined, () => {
assert.throws(() => parseRequiredEnvPort("TEST_REQ_PORT"), /no está definida/);
});
withEnv("TEST_REQ_PORT", "70000", () => {
assert.throws(() => parseRequiredEnvPort("TEST_REQ_PORT"), /valid TCP port/);
});
withEnv("TEST_REQ_PORT", "9090", () => {
assert.equal(parseRequiredEnvPort("TEST_REQ_PORT"), "9090");
});
});
test("getRuntimeConfig throws if required variables are missing", () => {
withEnvs(
{
SCOREKO_APP_TITLE: undefined,
SCOREKO_APP_USER_MODEL_ID: "com.scoreko.desktop",
SCOREKO_APP_USER_DATA_DIRECTORY: "scoreko",
NODECG_PORT: "9090",
NODECG_BUNDLE_NAME: "scoreko-dev",
SCOREKO_DASHBOARD_ROUTE: "dashboard/scoreko-dev/main.html?standalone=true",
ELECTRON_LOAD_DELAY_MS: "10000",
NODECG_STARTUP_TIMEOUT_MS: "120000",
NODECG_KILL_TIMEOUT_MS: "2500",
SCOREKO_UPDATES_ENABLED: "true",
SCOREKO_UPDATE_CHECK_DELAY_MS: "5000",
},
() => {
assert.throws(() => getRuntimeConfig(), /SCOREKO_APP_TITLE/);
},
);
});
test("getRuntimeConfig parses successfully when all required variables are set", () => {
withEnvs(
{
SCOREKO_APP_TITLE: "Scoreko Test App",
SCOREKO_APP_USER_MODEL_ID: "com.scoreko.test",
SCOREKO_APP_USER_DATA_DIRECTORY: "scoreko-test",
NODECG_PORT: "9191",
NODECG_BUNDLE_NAME: "scoreko-dev-test",
SCOREKO_DASHBOARD_ROUTE: "dashboard/scoreko-dev/test.html",
ELECTRON_LOAD_DELAY_MS: "5000",
NODECG_STARTUP_TIMEOUT_MS: "60000",
NODECG_KILL_TIMEOUT_MS: "1500",
SCOREKO_UPDATES_ENABLED: "false",
SCOREKO_UPDATE_CHECK_DELAY_MS: "3000",
},
() => {
const config = getRuntimeConfig();
assert.equal(config.title, "Scoreko Test App");
assert.equal(config.userModelId, "com.scoreko.test");
assert.equal(config.userDataDirectoryName, "scoreko-test");
assert.equal(config.nodecgPort, "9191");
assert.equal(config.bundleName, "scoreko-dev-test");
assert.equal(config.mainDashboardRoute, "dashboard/scoreko-dev/test.html");
assert.equal(config.loadDelayMs, 5000);
assert.equal(config.startupTimeoutMs, 60000);
assert.equal(config.nodecgKillTimeoutMs, 1500);
assert.equal(config.updatesEnabled, false);
assert.equal(config.updateCheckDelayMs, 3000);
},
);
});
-209
View File
@@ -1,209 +0,0 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import { prepareUserNodecgRuntime } from "../main/nodecg/runtime-setup";
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));
},
statSync: (filePath: string) => ({
isDirectory: () => {
const normalized = path.normalize(filePath);
return normalized.endsWith("node_modules") || normalized.endsWith("bundles");
},
}),
symlinkSync: (target: string, linkPath: string, _type: string) => {
state.copied.push({ from: path.normalize(target), to: path.normalize(linkPath) });
state.paths.add(path.normalize(linkPath));
if (target.endsWith("node_modules")) {
state.paths.add(path.join(path.normalize(linkPath), "nodecg", "dist", "server", "bootstrap.js"));
} else if (target.endsWith("bundles")) {
state.paths.add(path.join(path.normalize(linkPath), "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"),
path.join(source, "node_modules", "nodecg", "dist", "server", "bootstrap.js"),
path.join(source, "bundles"),
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 preparedRuntime = prepareUserNodecgRuntime({
sourceRuntimePath: source,
userDataPath: userData,
appVersion: "0.1.0",
bundleName: "scoreko-dev",
log: () => undefined,
deps,
});
assert.equal(preparedRuntime.runtimePath, path.join(userData, "nodecg"));
assert.equal(preparedRuntime.installed, true);
assert.equal(state.copied.length, 4);
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", generatedAt: "2026-05-24T00:00:00.000Z", 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),
},
);
const preparedRuntime = prepareUserNodecgRuntime({
sourceRuntimePath: source,
userDataPath: userData,
appVersion: "0.1.0",
bundleName: "scoreko-dev",
log: () => undefined,
deps,
});
assert.equal(preparedRuntime.installed, false);
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", generatedAt: "2026-05-24T00:00:00.000Z", 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),
},
);
const preparedRuntime = prepareUserNodecgRuntime({
sourceRuntimePath: source,
userDataPath: userData,
appVersion: "0.1.0",
bundleName: "scoreko-dev",
log: () => undefined,
deps,
});
assert.equal(preparedRuntime.installed, true);
assert.equal(state.copied.length, 4);
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")));
});
test("prepareUserNodecgRuntime refreshes managed files when the source runtime was regenerated", () => {
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", generatedAt: "2026-05-24T01:00:00.000Z", nodecgVersion: "2.6.4" };
const targetSourceManifest = {
bundleVersion: "0.1.0",
generatedAt: "2026-05-24T00:00:00.000Z",
nodecgVersion: "2.6.4",
};
const targetManifest = { appVersion: "0.1.0", bundleName: "scoreko-dev", sourceRuntime: targetSourceManifest };
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),
},
);
const preparedRuntime = prepareUserNodecgRuntime({
sourceRuntimePath: source,
userDataPath: userData,
appVersion: "0.1.0",
bundleName: "scoreko-dev",
log: () => undefined,
deps,
});
assert.equal(preparedRuntime.installed, true);
assert.equal(state.copied.length, 4);
assert.ok(state.removed.includes(path.join(target, "bundles")));
assert.ok(!state.removed.includes(path.join(target, "cfg")));
});
-32
View File
@@ -1,32 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createShutdownService } from "../main/app/shutdown-service";
test("shutdown service reuses the same stop promise while stopping", async () => {
let stopCalls = 0;
let releaseStop: () => void = () => {
throw new Error("stop promise was not created");
};
const service = createShutdownService(
() =>
new Promise<void>((resolve) => {
stopCalls += 1;
releaseStop = resolve;
}),
);
const first = service.stop();
const second = service.stop();
assert.equal(first, second);
assert.equal(stopCalls, 1);
assert.equal(service.getState(), "stopping");
releaseStop();
await first;
assert.equal(service.getState(), "stopped");
await service.stop();
assert.equal(stopCalls, 1);
});
-69
View File
@@ -1,69 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { AppRuntimeConfig } from "../main/config/runtime-config";
import { loadUpdateSettings } from "../main/updates/update-config";
const baseConfig: AppRuntimeConfig = {
title: "Scoreko",
userModelId: "com.scoreko.desktop",
userDataDirectoryName: "scoreko",
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadDelayMs: 0,
startupTimeoutMs: 30000,
nodecgKillTimeoutMs: 2500,
updatesEnabled: true,
updateCheckDelayMs: 5000,
};
test("loadUpdateSettings keeps updates disabled when the runtime config disables them", () => {
const settings = loadUpdateSettings(
{
...baseConfig,
updatesEnabled: false,
updateApiUrl: "https://gitea.local/releases/latest",
},
"",
() => undefined,
);
assert.equal(settings.enabled, false);
assert.equal(settings.apiUrl, "https://gitea.local/releases/latest");
});
test("loadUpdateSettings fails closed on insecure production update URLs", () => {
const settings = loadUpdateSettings(
{
...baseConfig,
updateApiUrl: "http://gitea.local/releases/latest",
},
"",
() => undefined,
{ allowInsecureHttp: false },
);
assert.equal(settings.enabled, false);
assert.equal(settings.apiUrl, undefined);
});
test("loadUpdateSettings lets runtime config specify settings", () => {
const settings = loadUpdateSettings(
{
...baseConfig,
updateApiUrl: "https://env.local/releases/latest",
updateReleasePageUrl: "https://env.local/releases",
updateAssetPattern: "Env-.*\\.exe$",
},
"",
() => undefined,
);
assert.deepEqual(settings, {
enabled: true,
apiUrl: "https://env.local/releases/latest",
releasePageUrl: "https://env.local/releases",
assetPattern: "Env-.*\\.exe$",
});
});
-93
View File
@@ -1,93 +0,0 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { downloadInstaller } from "../main/updates/update-download";
test("downloadInstaller writes into the update temp directory and removes staging files", async () => {
const previousFetch = globalThis.fetch;
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-download-"));
globalThis.fetch = async () => new Response("installer-bytes");
try {
const installerPath = await downloadInstaller(
{
version: "0.2.0",
title: "Scoreko 0.2.0",
installer: {
name: "Scoreko/setup:0.2.0.exe",
downloadUrl: "https://updates.local/Scoreko-setup-0.2.0.exe",
},
},
{ tempDirectory, allowInsecureHttp: false },
);
const downloadDirectory = path.join(tempDirectory, "scoreko-updates");
assert.equal(installerPath, path.join(downloadDirectory, "Scoreko_setup_0.2.0.exe"));
assert.equal(fs.readFileSync(installerPath, "utf8"), "installer-bytes");
assert.deepEqual(
fs.readdirSync(downloadDirectory).filter((entry) => entry.endsWith(".download")),
[],
);
} finally {
globalThis.fetch = previousFetch;
}
});
test("downloadInstaller rejects insecure production download URLs", async () => {
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-download-"));
await assert.rejects(
() =>
downloadInstaller(
{
version: "0.2.0",
title: "Scoreko 0.2.0",
installer: {
name: "Scoreko-setup-0.2.0.exe",
downloadUrl: "http://updates.local/Scoreko-setup-0.2.0.exe",
},
},
{ tempDirectory, allowInsecureHttp: false },
),
/unsupported protocol/,
);
});
test("downloadInstaller reuses existing file if size matches and does not download again", async () => {
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-download-"));
const downloadDirectory = path.join(tempDirectory, "scoreko-updates");
fs.mkdirSync(downloadDirectory, { recursive: true });
const installerPath = path.join(downloadDirectory, "Scoreko_setup_0.2.0.exe");
fs.writeFileSync(installerPath, "cached-installer-bytes");
const cachedSize = fs.statSync(installerPath).size;
const previousFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error("Should not fetch when using cached file!");
};
try {
const resultPath = await downloadInstaller(
{
version: "0.2.0",
title: "Scoreko 0.2.0",
installer: {
name: "Scoreko/setup:0.2.0.exe",
downloadUrl: "https://updates.local/Scoreko-setup-0.2.0.exe",
size: cachedSize,
},
},
{ tempDirectory, allowInsecureHttp: false },
);
assert.equal(resultPath, installerPath);
assert.equal(fs.readFileSync(resultPath, "utf8"), "cached-installer-bytes");
} finally {
globalThis.fetch = previousFetch;
}
});
-89
View File
@@ -1,89 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildReleaseUpdate,
isVersionNewer,
parseGiteaRelease,
sanitizeFileName,
selectInstallerAsset,
} from "../main/updates/update-schema";
test("isVersionNewer compares semantic versions with optional v prefix", () => {
assert.equal(isVersionNewer("v0.2.0", "0.1.9"), true);
assert.equal(isVersionNewer("0.1.0", "0.1.0"), false);
assert.equal(isVersionNewer("0.1.0", "0.2.0"), false);
});
test("selectInstallerAsset picks the first matching exe asset", () => {
const asset = selectInstallerAsset(
{
tagName: "v0.2.0",
assets: [
{ name: "latest.yml", browserDownloadUrl: "http://gitea/latest.yml" },
{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 },
],
},
"Scoreko-setup-.*\\.exe$",
);
assert.deepEqual(asset, {
name: "Scoreko-setup-0.2.0.exe",
downloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe",
size: 100,
});
});
test("buildReleaseUpdate returns null when the release is not newer", () => {
const update = buildReleaseUpdate(
{
tagName: "v0.1.0",
assets: [{ name: "Scoreko-setup-0.1.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.1.0.exe" }],
},
"0.1.0",
"Scoreko-setup-.*\\.exe$",
);
assert.equal(update, null);
});
test("buildReleaseUpdate builds update info for newer releases", () => {
const update = buildReleaseUpdate(
{
tagName: "v0.2.0",
title: "Scoreko 0.2.0",
pageUrl: "http://gitea/releases/v0.2.0",
assets: [{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe" }],
},
"0.1.0",
"Scoreko-setup-.*\\.exe$",
);
assert.equal(update?.version, "0.2.0");
assert.equal(update?.title, "Scoreko 0.2.0");
assert.equal(update?.pageUrl, "http://gitea/releases/v0.2.0");
assert.equal(update?.installer.name, "Scoreko-setup-0.2.0.exe");
});
test("sanitizeFileName removes Windows-unsafe characters", () => {
assert.equal(sanitizeFileName('Scoreko:setup*"0.2.0.exe'), "Scoreko_setup__0.2.0.exe");
});
test("parseGiteaRelease rejects malformed remote metadata", () => {
assert.equal(parseGiteaRelease({ name: "missing tag", assets: [] }), null);
assert.equal(parseGiteaRelease({ tag_name: "v0.2.0", assets: "wrong" }), null);
});
test("buildReleaseUpdate rejects insecure download URLs when policy forbids them", () => {
const update = buildReleaseUpdate(
{
tagName: "v0.2.0",
assets: [{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe" }],
},
"0.1.0",
"Scoreko-setup-.*\\.exe$",
{ allowInsecureHttp: false },
);
assert.equal(update, null);
});
-174
View File
@@ -1,174 +0,0 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline'">
<title>Scoreko - Error al iniciar</title>
<style>
body {
background-color: #121212;
color: #f5f5f5;
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
overflow: hidden;
user-select: none;
}
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
box-sizing: border-box;
}
.error-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 560px;
width: 100%;
}
.error-icon {
width: 56px;
height: 56px;
color: #ef5350;
flex-shrink: 0;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #f5f5f5;
}
.error-message {
font-size: 0.875rem;
line-height: 1.6;
color: rgba(245, 245, 245, 0.65);
margin: 0;
word-break: break-word;
max-height: 220px;
overflow-y: auto;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 12px 16px;
text-align: left;
font-family: ui-monospace, "Cascadia Code", "Consolas", monospace;
white-space: pre-wrap;
width: 100%;
box-sizing: border-box;
}
.hint {
font-size: 0.8125rem;
color: rgba(245, 245, 245, 0.45);
margin: 0;
}
.actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
button {
appearance: none;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s ease;
}
button:hover {
opacity: 0.85;
}
button:active {
opacity: 0.7;
}
#btn-logs {
background: rgba(255,255,255,0.08);
color: #f5f5f5;
border: 1px solid rgba(255,255,255,0.12);
}
#btn-quit {
background: #1976d2;
color: #fff;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.15);
border-radius: 3px;
}
</style>
</head>
<body>
<div class="error-page">
<div class="error-content">
<svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<h1 id="error-title">Scoreko no pudo iniciar</h1>
<pre class="error-message" id="error-detail">Se produjo un error inesperado al iniciar el servidor interno.</pre>
<p class="hint">Revisa los logs para más detalles o cierra y vuelve a abrir la aplicación.</p>
<div class="actions">
<button id="btn-logs">Ver logs</button>
<button id="btn-quit">Cerrar Scoreko</button>
</div>
</div>
</div>
<script>
// Read optional error detail injected via URL hash: error.html#msg=...
try {
const hash = decodeURIComponent(window.location.hash.slice(1));
const params = new URLSearchParams(hash);
const msg = params.get('msg');
if (msg) {
document.getElementById('error-detail').textContent = msg;
}
} catch (_) {
// ignore parse errors
}
document.getElementById('btn-quit').addEventListener('click', () => {
window.close();
});
// btn-logs: the main process listens for this via ipc if a preload is wired,
// otherwise we just note the action (no-op in sandbox mode).
document.getElementById('btn-logs').addEventListener('click', () => {
// Signal to main process via hash navigation that the user wants to open logs.
// The main process's will-navigate handler opens external URLs, so we use a
// custom app:// scheme that is caught and handled there.
window.location.href = 'app://open-logs';
});
</script>
</body>
</html>
-110
View File
@@ -1,110 +0,0 @@
!include installer.nsh
InitPluginsDir
${IfNot} ${Silent}
SetDetailsPrint both
${endif}
StrCpy $appExe "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
# must be called before uninstallOldVersion
!insertmacro setLinkVars
!ifdef ONE_CLICK
!ifdef HEADER_ICO
File /oname=$PLUGINSDIR\installerHeaderico.ico "${HEADER_ICO}"
!endif
${IfNot} ${Silent}
!ifdef HEADER_ICO
SpiderBanner::Show /MODERN /ICON "$PLUGINSDIR\installerHeaderico.ico"
!else
SpiderBanner::Show /MODERN
!endif
FindWindow $0 "#32770" "" $hwndparent
FindWindow $0 "#32770" "" $hwndparent $0
GetDlgItem $0 $0 1000
SendMessage $0 ${WM_SETTEXT} 0 "STR:$(installing)"
StrCpy $1 $hwndparent
System::Call 'user32::ShutdownBlockReasonCreate(${SYSTYPE_PTR}r1, w "$(installing)")'
${endif}
!insertmacro CHECK_APP_RUNNING
!else
${ifNot} ${UAC_IsInnerInstance}
!insertmacro CHECK_APP_RUNNING
${endif}
!endif
Var /GLOBAL keepShortcuts
StrCpy $keepShortcuts "false"
!insertMacro setIsTryToKeepShortcuts
${if} $isTryToKeepShortcuts == "true"
ReadRegStr $R1 SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" KeepShortcuts
${if} $R1 == "true"
${andIf} ${FileExists} "$appExe"
StrCpy $keepShortcuts "true"
${endIf}
${endif}
!insertmacro uninstallOldVersion SHELL_CONTEXT
!insertmacro handleUninstallResult SHELL_CONTEXT
${if} $installMode == "all"
!insertmacro uninstallOldVersion HKEY_CURRENT_USER
!insertmacro handleUninstallResult HKEY_CURRENT_USER
${endIf}
SetOutPath $INSTDIR
!ifdef UNINSTALLER_ICON
File /oname=uninstallerIcon.ico "${UNINSTALLER_ICON}"
!endif
!insertmacro installApplicationFiles
!insertmacro registryAddInstallInfo
!insertmacro addStartMenuLink $keepShortcuts
!insertmacro addDesktopLink $keepShortcuts
${if} ${FileExists} "$newStartMenuLink"
StrCpy $launchLink "$newStartMenuLink"
${else}
StrCpy $launchLink "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
${endIf}
!ifmacrodef registerFileAssociations
!insertmacro registerFileAssociations
!endif
!ifmacrodef customInstall
!insertmacro customInstall
!endif
!macro doStartApp
# otherwise app window will be in background
HideWindow
!insertmacro StartApp
!macroend
!ifdef ONE_CLICK
# https://github.com/electron-userland/electron-builder/pull/3093#issuecomment-403734568
!ifdef RUN_AFTER_FINISH
${ifNot} ${Silent}
${orIf} ${isForceRun}
!insertmacro doStartApp
${endIf}
!else
${if} ${isForceRun}
!insertmacro doStartApp
${endIf}
!endif
!insertmacro quitSuccess
!else
# for assisted installer run only if silent, because assisted installer has run after finish option
${if} ${isForceRun}
${andIf} ${Silent}
!insertmacro doStartApp
${endIf}
!endif
-4
View File
@@ -1,4 +0,0 @@
!macro customHeader
ShowInstDetails hide
ShowUninstDetails hide
!macroend
-101
View File
@@ -1,101 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline';">
<title>Scoreko</title>
<style>
body {
background-color: #121212;
color: #f5f5f5;
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
overflow: hidden;
user-select: none;
}
.loading-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.q-spinner {
animation: q-spin 2s linear infinite;
transform-origin: center center;
width: 50px;
height: 50px;
color: #1976d2;
}
.q-spinner circle {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
animation: q-spin-dash 1.5s ease-in-out infinite;
stroke-linecap: round;
}
@keyframes q-spin {
100% { transform: rotate(360deg); }
}
@keyframes q-spin-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}
.quote {
max-width: 520px;
margin-top: 16px;
font-size: 1rem;
line-height: 1.75rem;
font-weight: 400;
}
.status {
font-style: italic;
opacity: 0.7;
font-size: 0.875rem;
font-weight: 500;
}
::-webkit-scrollbar {
display: none;
}
</style>
</head>
<body>
<div class="loading-page">
<div class="loading-content">
<svg class="q-spinner" viewBox="25 25 50 50">
<circle cx="50" cy="50" r="20" fill="none" stroke="currentColor" stroke-width="5" stroke-miterlimit="10"></circle>
</svg>
<div class="quote" id="quoteText"></div>
<div class="status">Loading...</div>
</div>
</div>
<script>
const loadQuotes = [
"Complaining about Paul's damage",
'Nerfing Gigas',
'Mashing hopkick',
'Sidestepping your electric',
'Punishing hellsweep with 1,1,2',
'Emailing Harada',
];
const randomIndex = Math.floor(Math.random() * loadQuotes.length);
document.getElementById('quoteText').textContent = loadQuotes[randomIndex];
</script>
</body>
</html>