Compare commits

...

33 Commits

Author SHA1 Message Date
Pandipipas 03446a3b4b Merge pull request #47 from Pandipipas/dev
Dev
2026-06-04 17:24:55 +02:00
Pandipipas f0a35bf655 feat: add option to always create desktop shortcut on installation 2026-06-04 15:00:37 +02:00
Pandipipas 934500a1db refactor: remove loading dashboard route references from configuration and tests 2026-06-04 14:51:21 +02:00
Pandipipas 2496f13055 refactor: update security policies in window creation and enhance loading page CSP 2026-06-04 14:42:32 +02:00
Pandipipas 982c771e82 feat: add error handling screen and logging functionality 2026-06-04 14:09:27 +02:00
Pandipipas 6952a9954f refactor: remove outdated phase summary documents and restructure error handling
- Deleted obsolete phase summary documents (PHASE_1_FIX_SUMMARY.md, PHASE_1_SUMMARY.md, PHASE_2_SUMMARY.md, PHASE_3_SUMMARY.md, PHASE_4_SUMMARY.md, SESSION_HANDOFF.md, TARGET_ARCHITECTURE.md) to streamline documentation.
- Introduced error handling module (error-handler.ts) to centralize error logging and presentation.
- Updated bootstrap and application controller to utilize the new error handling module.
- Refactored runtime provisioning logic into a dedicated module (runtime-setup.ts) for better organization.
- Implemented platform-specific process termination logic in process-killer.ts to enhance process management.
- Enhanced navigation policy with a new module (navigation.ts) to improve URL handling and security.
- Updated window service to integrate new navigation logic for internal and external URL handling.
2026-06-04 04:51:03 +02:00
Pandipipas 7102e3dd01 Refactor loading screen with updated styles and dynamic quotes 2026-06-04 04:09:00 +02:00
Pandipipas 5da609cce4 Add loading window functionality with HTML and update application controller 2026-06-04 03:07:40 +02:00
Pandipipas 0ea4c6e01b Enhance application controller and runtime provisioner with loading window visibility and improved file handling 2026-06-04 01:28:28 +02:00
Pandipipas 143ff7e8db Merge pull request #46 from Pandipipas/dev
Dev
2026-06-04 01:27:44 +02:00
Pandipipas beb22cb438 Hide installation and uninstallation details in the custom header of the installer script 2026-06-02 19:36:30 +02:00
Pandipipas 88223d744c Add before-pack script and installer configuration for NSIS 2026-06-01 11:16:07 +02:00
Pandipipas ed5a7d0994 b 2026-05-31 20:39:55 +02:00
Pandipipas d01ae1fa6b Fix complete. Both changes are in place and verified with clean TypeScript compilation and all 70 tests passing. 2026-05-31 19:47:30 +02:00
Pandipipas 3f756feca6 a 2026-05-31 18:57:18 +02:00
Pandipipas ca74a23d19 Improving Installer and Updater Process 2026-05-31 18:52:51 +02:00
Pandipipas 8e6b79ca68 deleted unnecesary 2026-05-31 18:45:57 +02:00
Pandipipas ce59c5db89 env config 2026-05-31 18:35:59 +02:00
Pandipipas 92e2da1758 Augmented NODECG_STARTUP_TIMEOUT_MS 2026-05-31 17:50:54 +02:00
Pandipipas 42a298925b Investigating Electron Startup Failures 2026-05-31 16:24:14 +02:00
Pandipipas b0e0fdb9a1 Merge pull request #45 from Pandipipas/dev
Dev
2026-05-31 15:16:52 +02:00
Pandipipas 33665ed896 Cleanup final 2026-05-31 14:50:32 +02:00
Pandipipas 865c3589bd Refactor NodeCG runtime preparation and update handling
- Updated paths and configurations in doctor.mjs and prepare-nodecg-runtime.mjs to use new build-config.mjs imports.
- Enhanced runtime installation checks and permissions validation.
- Introduced new update configuration management in update-config.ts, including loading and validating update settings.
- Implemented update service for managing update checks and downloads in update-service.ts.
- Replaced update-utils.ts with update-schema.ts for better structure and clarity in update handling.
- Added comprehensive tests for update download and settings management.
- Ensured secure handling of download URLs and improved error handling in update processes.
2026-05-24 23:20:59 +02:00
Pandipipas c8e2edc0c0 feat: Implement update management refactor with new dialog and settings handling 2026-05-24 22:31:18 +02:00
Pandipipas 54ab1fcb9f feat: Enhance NodeCG process management and add IPC security tests 2026-05-24 22:13:04 +02:00
Pandipipas 2e1d3a170c feat: Restore Electron renderer and enhance NodeCG runtime management 2026-05-24 16:49:02 +02:00
Pandipipas e3d3936156 feat: Implement application bootstrap and window management
- Added bootstrap functionality to initialize the Electron application.
- Created a new paths module to manage application paths and URLs.
- Introduced a shutdown service to handle graceful application shutdowns.
- Refactored error logging to use a dedicated logger module.
- Implemented process killing logic for NodeCG processes across platforms.
- Established navigation policies for internal and external URL handling in windows.
- Developed window service for creating and managing application windows.
- Added tests for application paths, application controller, navigation policies, process killer, and shutdown service.
2026-05-24 16:14:23 +02:00
Pandipipas c168c3b84a feat: add comprehensive architecture documentation and migration plan for refactor sessions 2026-05-24 15:38:15 +02:00
Pandipipas 67f3e60953 fix: disable signing option for NSIS executable 2026-05-24 15:24:09 +02:00
Pandipipas d4dd77151c fix: remove unnecessary signing option from NSIS configuration 2026-05-17 14:05:14 +02:00
Pandipipas fbc709463f feat: implement Gitea update checks and installer management 2026-05-16 23:10:05 +02:00
Pandipipas 955a1f7116 feat: improve NodeCG runtime installation and relaunch behavior 2026-05-16 22:22:30 +02:00
Pandipipas 41e4e91c4b feat: enhance NodeCG runtime management and packaging
- Update .gitignore and .prettierignore to exclude additional cache and configuration files.
- Revise README.md for clarity on build processes and runtime behavior.
- Improve architecture documentation to reflect changes in startup flow and module responsibilities.
- Modify troubleshooting guide to address common runtime issues and installation steps.
- Enhance ESLint configuration to ignore more directories.
- Update package.json scripts for better build and distribution processes.
- Introduce build-scoreko-bundle.mjs for building the Scoreko bundle.
- Implement prepare-nodecg-runtime.mjs for managing NodeCG runtime installation and updates.
- Add runtime-provisioner.ts to handle user-specific NodeCG runtime provisioning.
- Create tests for runtime provisioning to ensure correct behavior.
- Refactor process-manager.ts and main.ts to integrate new runtime management logic.
2026-05-09 17:45:36 +02:00
53 changed files with 3853 additions and 560 deletions
+17 -6
View File
@@ -1,16 +1,27 @@
# Runtime / app # SCOREKO Configuration File Template
# 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 # NodeCG Managed Runtime Configuration (Required)
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 # Timing & Lifecycles (Required)
ELECTRON_LOAD_DELAY_MS=10000 ELECTRON_LOAD_DELAY_MS=10000
NODECG_STARTUP_TIMEOUT_MS=30000 NODECG_STARTUP_TIMEOUT_MS=120000
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,3 +2,9 @@ node_modules
dist dist
release release
lib lib
.corepack
.electron-cache
.localappdata
.npm-cache
.npm-runtime-cache
.env
+5
View File
@@ -3,3 +3,8 @@ release
lib/nodecg lib/nodecg
node_modules node_modules
package-lock.json package-lock.json
.corepack
.electron-cache
.localappdata
.npm-cache
.npm-runtime-cache
+49 -37
View File
@@ -1,53 +1,65 @@
# scoreko-electron # Scoreko Desktop
Desktop app (Electron + TypeScript) to run and package a NodeCG installation with the `scoreko-dev` bundle. 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.
## Requirements ## Local Development
- Node.js `>=22` If you're working on the app locally, start by installing dependencies at the repository root:
- Dependencies installed with `npm install`
## Available scripts ```powershell
pnpm install
```
### Development Then, move into the wrapper folder:
- `npm run dev`: compiles in watch mode and opens Electron. ```powershell
- `npm run watch`: TypeScript watch mode. cd scoreko-electron-dev
- `npm run dev:electron`: opens Electron when `dist/main/main.js` is ready. npm install
- `npm run start`: full build and local run. ```
### Build and distribution ### Useful Commands
- `npm run clean`: removes `dist` and `release`. - `npm run start`: Builds the bundle and launches Electron locally for testing.
- `npm run typecheck`: validates types without emitting files. - `npm run dist:win`: Packages everything and creates the `.exe` Windows installer in the `release/` folder.
- `npm run build`: compiles TypeScript and copies assets. - `npm run prepare:runtime`: Extracts a fresh NodeCG runtime from the parent bundle (useful if you changed dependencies).
- `npm run pack`: generates the app without an installer (`electron-builder --dir`). - `npm run rebuild:native`: Rebuilds native Node modules (like SQLite) specifically for Electron's V8 engine.
- `npm run dist:win`: builds a Windows installer. - `npm run doctor`: Runs a quick sanity check to verify your local configuration and port availability.
- `npm run dist:linux`: builds a Linux AppImage.
- `npm run dist:mac`: builds a macOS package.
- `npm run dist:all`: builds artifacts for Windows, Linux, and macOS.
### Quality and diagnostics ## How it works under the hood
- `npm run test`: build and tests (`node:test`). 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 doctor`: environment/configuration diagnostics.
- `npm run lint`: lint with ESLint.
- `npm run lint:fix`: lint with auto-fix.
- `npm run format`: checks formatting with Prettier.
- `npm run format:write`: applies formatting with Prettier.
### Native modules 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`.
- `npm run rebuild:native`: rebuilds NodeCG native modules. ## Auto-Updates via Gitea
- `npm run rebuild:better-sqlite3`: rebuilds only `better-sqlite3` for Electron.
## Quick setup Scoreko supports seamless, opt-in updates through your Gitea instance.
1. Copy `.env.example` to `.env`. Before building your production installer, check `static/updates.json`:
2. Adjust variables for your environment.
3. Run `npm run doctor` before developing or packaging.
## References ```json
{
"enabled": true,
"apiUrl": "http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest",
"releasePageUrl": "http://gitea.local/OWNER/REPO/releases",
"assetPattern": "Scoreko-setup-.*\\.exe$"
}
```
- Troubleshooting: `docs/troubleshooting.md` **To ship an update:**
- Architecture: `docs/architecture.md` 1. Bump the version in `package.json`.
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.
+21 -18
View File
@@ -1,24 +1,27 @@
# Main process architecture # Main Process Architecture
## Startup flow This document breaks down how the Electron main process is structured and what happens when the app launches.
1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`. ## Startup Flow
2. Creates windows (`windows/window-factory.ts`).
3. Starts NodeCG with `nodecg/process-manager.ts`.
4. Waits for HTTP readiness and shows loading -> main dashboard.
5. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
## Main modules When a user opens Scoreko, the app goes through a precise sequence to ensure NodeCG starts reliably:
- `config/runtime-config.ts`: read/validate env vars. 1. **Configuration:** `src/main/main.ts` kicks things off by loading `appConfig` via `config/runtime-config.ts`.
- `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation. 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`).
- `windows/window-factory.ts`: window creation and navigation policy. 3. **Window Creation:** The initial windows (like the loading screen) are instantiated via `windows/window-factory.ts`.
- `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes. 4. **NodeCG Boot:** `nodecg/process-manager.ts` spawns the NodeCG process in the background.
- `errors/error-presenter.ts`: fatal error presentation. 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/logger.ts`: structured logging (`info/warn/error/debug`). 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.
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.
## Principles ## Core Modules
- Mechanical refactors first. Here is where the heavy lifting happens:
- Incremental hardening with conservative fallback.
- Automated validation via `typecheck`, `build`, `test`, `doctor`, `lint`. - **`config/runtime-config.ts`**: Handles environment variables and defaults.
- **`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.
+34 -18
View File
@@ -1,27 +1,43 @@
# Troubleshooting # Troubleshooting Guide
## `NodeCG folder does not exist` Here are some common issues you might run into while developing or using the Scoreko desktop app, along with quick fixes.
- Verify `lib/nodecg` exists. ## The app says the NodeCG runtime is incomplete
- Make sure the project contains a full NodeCG installation. This usually means you haven't bundled the runtime yet.
- 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.
## `No read/write permissions on NodeCG` ## NodeCG is present but internal dependencies are missing
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.
- Adjust permissions on `lib/nodecg` for the user running Electron. ## "No read/write permissions on NodeCG"
- On Linux/macOS: `chmod -R u+rw lib/nodecg` (according to your local policy). 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.
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 <PORT> is already in use` ## Port 9090 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.
- Free the port or set `NODECG_PORT` in `.env`. ## Timeout while waiting for NodeCG
- Use `npm run doctor` to validate availability before startup. The app waits for the NodeCG HTTP server to respond. If it times out:
- 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.
## `Timeout while waiting for NodeCG` ## The app crashes immediately on a fresh install
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`.
- Check NodeCG logs in standard output. ## macOS builds are failing complaining about an icon
- Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow. 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.
- Verify NodeCG dependencies (`cd lib/nodecg && npm install`).
## macOS build fails because of icon ## Auto-updates aren't triggering
If you published a new release on Gitea but the app ignores it:
- The configuration expects `static/icons/icon.icns`. - Double check that `static/updates.json` has `"enabled": true` before you build the installer.
- Create that file before running macOS packaging. - Ensure your `apiUrl` points exactly to the Gitea API: `http://gitea.../api/v1/repos/<owner>/<repo>/releases/latest`.
- 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$`).
+11 -1
View File
@@ -3,7 +3,17 @@ import tsParser from "@typescript-eslint/parser";
export default [ export default [
{ {
ignores: ["dist/**", "release/**", "lib/**"], ignores: [
"dist/**",
"release/**",
"lib/**",
"node_modules/**",
".corepack/**",
".electron-cache/**",
".localappdata/**",
".npm-cache/**",
".npm-runtime-cache/**",
],
}, },
{ {
files: ["**/*.ts"], files: ["**/*.ts"],
+12 -65
View File
@@ -8,8 +8,10 @@
"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": {
"@electron/rebuild": "^3.7.1",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@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",
@@ -182,31 +184,6 @@
"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",
@@ -273,35 +250,6 @@
"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",
@@ -2971,6 +2919,15 @@
"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",
@@ -5383,16 +5340,6 @@
"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",
+29 -16
View File
@@ -10,28 +10,31 @@
"private": true, "private": true,
"main": "dist/main/main.js", "main": "dist/main/main.js",
"scripts": { "scripts": {
"clean": "rimraf dist release", "clean": "rimraf dist release lib",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-assets.mjs", "build:bundle": "node scripts/build-scoreko-bundle.mjs",
"build:main": "tsc -p tsconfig.json",
"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",
"rebuild:better-sqlite3": "electron-rebuild --version 39.5.1 --module-dir lib/nodecg/workspaces/database-adapter-sqlite-legacy --only better-sqlite3 -f", "test": "rimraf dist && npm run build:main && node --test dist/tests/**/*.test.js",
"test": "npm run build && node --test dist/tests/**/*.test.js",
"doctor": "node scripts/doctor.mjs", "doctor": "node scripts/doctor.mjs",
"lint": "eslint . --ext .ts,.js,.mjs", "lint": "eslint . --ext .ts,.js,.mjs",
"lint:fix": "npm run lint -- --fix", "lint:fix": "npm run lint -- --fix",
"format": "prettier --check .", "format": "prettier --check .",
"format:write": "prettier --write .", "format:write": "prettier --write .",
"dist:win": "npm run build && electron-builder --win", "dist:win": "npm run build && npm run rebuild:native && electron-builder --win",
"dist:linux": "npm run build && electron-builder --linux AppImage", "dist:linux": "npm run build && npm run rebuild:native && electron-builder --linux AppImage",
"dist:all": "npm run build && electron-builder --win --linux --mac", "dist:all": "npm run build && npm run rebuild:native && electron-builder --win --linux --mac",
"dist:mac": "npm run build && electron-builder --mac" "dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac"
}, },
"build": { "build": {
"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}",
@@ -51,6 +54,10 @@
{ {
"from": "static", "from": "static",
"to": "static" "to": "static"
},
{
"from": ".env",
"to": ".env"
} }
], ],
"mac": { "mac": {
@@ -71,9 +78,11 @@
"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}",
@@ -81,7 +90,9 @@
"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"
}, },
@@ -89,17 +100,19 @@
"node": ">=22" "node": ">=22"
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^3.7.1",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"electron": "39.5.1", "electron": "39.5.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", },
"@typescript-eslint/parser": "^8.22.0", "dependencies": {
"@typescript-eslint/eslint-plugin": "^8.22.0", "electron-log": "^5.4.4"
"prettier": "^3.4.2"
} }
} }
+13
View File
@@ -0,0 +1,13 @@
// 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
@@ -0,0 +1,56 @@
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
@@ -0,0 +1,81 @@
#!/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
@@ -1,15 +0,0 @@
import { cpSync, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const distStatic = path.join(root, "dist", "static");
const sourceStatic = path.join(root, "static");
mkdirSync(distStatic, { recursive: true });
if (existsSync(sourceStatic)) {
cpSync(sourceStatic, distStatic, { recursive: true });
console.log("Copied static assets to dist/static");
} else {
console.warn("No static folder found, skipping copy-assets step");
}
+46 -19
View File
@@ -3,17 +3,35 @@ 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";
const cwd = process.cwd(); import { bundleName, nodecgRuntimeRoot } from "./build-config.mjs";
const nodecgRootPath = path.resolve(cwd, "lib", "nodecg");
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, fallback) { function parsePort(name) {
const raw = process.env[name] ?? fallback; const raw = process.env[name];
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}'.`);
@@ -24,8 +42,12 @@ function parsePort(name, fallback) {
return parsed; return parsed;
} }
function parseIntInRange(name, fallback, min, max) { function parseIntInRange(name, min, max) {
const raw = process.env[name] ?? String(fallback); const raw = process.env[name];
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}'.`);
@@ -36,17 +58,20 @@ function parseIntInRange(name, fallback, min, max) {
} }
function checkNodecgInstall() { function checkNodecgInstall() {
const indexPath = path.join(nodecgRootPath, "index.js"); const indexPath = path.join(nodecgRuntimeRoot, "index.js");
const bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim(); const bootstrapPath = path.join(nodecgRuntimeRoot, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName); const manifestPath = path.join(nodecgRuntimeRoot, ".scoreko-runtime.json");
const bundlePath = path.join(nodecgRuntimeRoot, "bundles", bundleName);
addCheck(fs.existsSync(nodecgRootPath), "NodeCG root", nodecgRootPath); addCheck(fs.existsSync(nodecgRuntimeRoot), "Packaged NodeCG runtime", nodecgRuntimeRoot);
addCheck(fs.existsSync(indexPath), "NodeCG index.js", indexPath); addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath);
addCheck(fs.existsSync(bundlePath), `Bundle '${bundleName}'`, bundlePath); addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath);
addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath);
addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath);
try { try {
fs.accessSync(nodecgRootPath, fs.constants.R_OK | fs.constants.W_OK); fs.accessSync(nodecgRuntimeRoot, fs.constants.R_OK | fs.constants.W_OK);
addCheck(true, "lib/nodecg permissions", "Read/write OK"); addCheck(true, "lib/nodecg permissions", "Read/write OK for local development");
} catch { } catch {
addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg"); addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg");
} }
@@ -71,10 +96,12 @@ function checkPortAvailability(port) {
} }
async function main() { async function main() {
const port = parsePort("NODECG_PORT", "9090"); loadEnv();
parseIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000);
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000); const port = parsePort("NODECG_PORT");
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000); parseIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000);
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000);
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000);
checkNodecgInstall(); checkNodecgInstall();
if (port) { if (port) {
@@ -82,7 +109,7 @@ async function main() {
} }
for (const check of checks) { for (const check of checks) {
const icon = check.ok ? "" : ""; const icon = check.ok ? "OK" : "FAIL";
console.log(`${icon} ${check.title}: ${check.details}`); console.log(`${icon} ${check.title}: ${check.details}`);
} }
+172
View File
@@ -0,0 +1,172 @@
#!/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,15 +1,19 @@
import { existsSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
const root = process.cwd(); import { electronCache, electronRoot, getNpmCommand, nodecgRuntimeRoot, runtimeNpmCache } from "./build-config.mjs";
const nodecgDir = path.join(root, "lib", "nodecg");
const sqliteLegacyDir = path.join(nodecgDir, "workspaces", "database-adapter-sqlite-legacy");
const moduleDirs = [nodecgDir, sqliteLegacyDir].filter((dir) => existsSync(path.join(dir, "package.json"))); const packageJson = JSON.parse(readFileSync(path.join(electronRoot, "package.json"), "utf8"));
const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron;
if (moduleDirs.length === 0) { if (!electronVersion) {
console.error("No NodeCG package folders found. Expected lib/nodecg and/or workspaces."); console.error("Could not determine Electron version from package.json.");
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);
} }
@@ -23,8 +27,10 @@ function run(command, args, cwd) {
env: { env: {
...process.env, ...process.env,
npm_config_runtime: "electron", npm_config_runtime: "electron",
npm_config_target: "39.5.1", npm_config_target: electronVersion,
npm_config_disturl: "https://electronjs.org/headers", npm_config_disturl: "https://electronjs.org/headers",
npm_config_cache: runtimeNpmCache,
ELECTRON_CACHE: electronCache,
}, },
}); });
@@ -38,19 +44,13 @@ function run(command, args, cwd) {
}); });
} }
for (const dir of moduleDirs) { console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgRuntimeRoot}`);
if (dir === sqliteLegacyDir) { await run(getNpmCommand(), [
console.log(`\n[rebuild-native] Ensuring sqlite legacy workspace deps in: ${dir}`); "rebuild",
await run("npm", ["install"], dir); "better-sqlite3",
await run("npm", ["install", "bindings", "--no-save"], dir); "--runtime=electron",
} `--target=${electronVersion}`,
"--dist-url=https://electronjs.org/headers",
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 in: ${dir}`); ], nodecgRuntimeRoot);
await run(
"npm",
["rebuild", "better-sqlite3", "--runtime=electron", "--target=39.5.1", "--dist-url=https://electronjs.org/headers"],
dir,
);
}
console.log("\n[rebuild-native] Done."); console.log("\n[rebuild-native] Done.");
+243
View File
@@ -0,0 +1,243 @@
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
@@ -0,0 +1,152 @@
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
@@ -0,0 +1,84 @@
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
@@ -0,0 +1,32 @@
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;
},
};
}
+129 -21
View File
@@ -1,3 +1,6 @@
import fs from "node:fs";
import path from "node:path";
export type AppRuntimeConfig = { export type AppRuntimeConfig = {
title: string; title: string;
userModelId: string; userModelId: string;
@@ -6,32 +9,74 @@ 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: getEnv("SCOREKO_APP_TITLE", "Scoreko"), title: getRequiredEnv("SCOREKO_APP_TITLE"),
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"), userModelId: getRequiredEnv("SCOREKO_APP_USER_MODEL_ID"),
userDataDirectoryName: getEnv("SCOREKO_APP_USER_DATA_DIRECTORY", "scoreko"), userDataDirectoryName: getRequiredEnv("SCOREKO_APP_USER_DATA_DIRECTORY"),
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"), iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
nodecgPort: parseEnvPort("NODECG_PORT", "9090"), nodecgPort: parseRequiredEnvPort("NODECG_PORT"),
bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"), bundleName: getRequiredEnv("NODECG_BUNDLE_NAME"),
mainDashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"), mainDashboardRoute: getRequiredEnv("SCOREKO_DASHBOARD_ROUTE"),
loadingDashboardRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"), loadDelayMs: parseRequiredEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000),
loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000), startupTimeoutMs: parseRequiredEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000),
startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000), nodecgKillTimeoutMs: parseRequiredEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000),
nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000), updatesEnabled: parseRequiredEnvBool("SCOREKO_UPDATES_ENABLED"),
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;
@@ -41,18 +86,18 @@ export function getEnv(name: string, fallback: string): string {
return getOptionalEnv(name) ?? fallback; return getOptionalEnv(name) ?? fallback;
} }
export function parseEnvInt(name: string, fallback: number): number { export function parseRequiredEnvIntInRange(name: string, min: number, max: number): number {
const rawValue = process.env[name]; const rawValue = getRequiredEnv(name);
if (!rawValue) {
return fallback;
}
const parsedValue = Number.parseInt(rawValue, 10); const parsedValue = Number.parseInt(rawValue, 10);
return Number.isFinite(parsedValue) ? parsedValue : fallback; if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
throw new Error(
`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`,
);
}
return parsedValue;
} }
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;
@@ -60,12 +105,75 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
const parsedValue = Number.parseInt(rawValue, 10); const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) { if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
throw new Error(`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`); throw new Error(
`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`,
);
} }
return parsedValue; return parsedValue;
} }
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 internal node"; export const NODE_RUNTIME_NAME = "Electron embedded Node.js";
export const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f"; export const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f";
export const DEFAULT_WINDOW_SIZE = { export const DEFAULT_WINDOW_SIZE = {
@@ -1,12 +1,12 @@
import { app, dialog } from "electron"; import { app, dialog } from "electron";
import { logger } from "./logger"; import { logger } from "../logging/logger";
export function log(...args: unknown[]): void { export function log(...args: unknown[]): void {
logger.info("runtime", { args }); logger.info("runtime", { args });
} }
export function formatErrorMessage(error: unknown): string { 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,7 +1,14 @@
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(),
@@ -13,17 +20,7 @@ function write(level: LogLevel, message: string, context?: LogContext): void {
const line = JSON.stringify(payload); const line = JSON.stringify(payload);
if (level === "error") { electronLog[level](line);
console.error(line);
return;
}
if (level === "warn") {
console.warn(line);
return;
}
console.log(line);
} }
export const logger = { export const logger = {
+2 -187
View File
@@ -1,188 +1,3 @@
import { app, BrowserWindow } from "electron"; import { bootstrap } from "./app/bootstrap";
import path from "node:path";
import { getRuntimeConfig } from "./config/runtime-config"; bootstrap();
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", "nodecg");
const mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`;
const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`;
const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`;
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> {
// 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
@@ -0,0 +1,58 @@
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;
}
}
}
+136 -92
View File
@@ -1,10 +1,11 @@
import { ChildProcess, spawn, SpawnOptions } from "node:child_process"; import { 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;
@@ -16,7 +17,7 @@ type NodecgProcessManagerConfig = {
}; };
type NodecgProcessManagerDeps = { type NodecgProcessManagerDeps = {
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess; spawnProcess: (command: string, args: string[], options: SpawnOptions) => NodecgChildProcess;
pathExists: (candidatePath: string) => boolean; pathExists: (candidatePath: string) => boolean;
fetchUrl: typeof fetch; fetchUrl: typeof fetch;
platform: NodeJS.Platform; platform: NodeJS.Platform;
@@ -30,13 +31,31 @@ 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<ChildProcess>; startNodecgProcess: () => Promise<void>;
waitForNodecgReady: (startTime: number) => Promise<void>; waitForNodecgReady: (startTime: number) => Promise<void>;
stopNodecgProcessGracefully: () => Promise<void>; stopNodecgProcessGracefully: () => Promise<void>;
getProcess: () => ChildProcess | null; getState: () => NodecgProcessState;
}; };
type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
export function createNodecgProcessManager({ export function createNodecgProcessManager({
isDev, isDev,
nodecgRootPath, nodecgRootPath,
@@ -47,64 +66,98 @@ export function createNodecgProcessManager({
}: NodecgProcessManagerConfig): NodecgProcessManager { }: NodecgProcessManagerConfig): NodecgProcessManager {
const resolvedDeps = resolveDeps(deps); const resolvedDeps = resolveDeps(deps);
let nodecgProcess: ChildProcess | null = null; let nodecgProcess: NodecgChildProcess | 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 = async (): Promise<ChildProcess> => { const startNodecgProcess = (): Promise<void> => {
// Fail fast with actionable errors before spawning child processes. if (nodecgProcess && nodecgState === "running") {
validateNodecgInstall( return Promise.resolve();
nodecgRootPath,
appConfig.bundleName,
resolvedDeps.pathExists,
resolvedDeps.hasReadWriteAccess,
);
const portAsNumber = Number.parseInt(appConfig.nodecgPort, 10);
const isPortAvailable = await resolvedDeps.probePortAvailable(portAsNumber);
if (!isPortAvailable) {
throw new Error(
`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"); if (startNodecgPromise) {
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], { return startNodecgPromise;
cwd: nodecgRootPath, }
env: {
...resolvedDeps.env,
NODE_ENV: isDev ? "development" : "production",
NODECG_PORT: appConfig.nodecgPort,
ELECTRON_RUN_AS_NODE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
detached: resolvedDeps.platform !== "win32",
shell: resolvedDeps.platform === "win32",
});
child.stdout?.on("data", (chunk) => { if (nodecgState === "stopping") {
resolvedDeps.stdoutWrite(String(chunk)); return Promise.reject(new Error("Cannot start NodeCG while shutdown is in progress."));
}); }
child.stderr?.on("data", (chunk) => { nodecgState = "starting";
const line = String(chunk); startNodecgPromise = (async () => {
lastStderrLine = line.trim().length > 0 ? line.trim() : lastStderrLine; // Fail fast with actionable errors before spawning child processes.
resolvedDeps.stderrWrite(line); validateNodecgInstall(
}); nodecgRootPath,
appConfig.bundleName,
resolvedDeps.pathExists,
resolvedDeps.hasReadWriteAccess,
);
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`); const portAsNumber = Number.parseInt(appConfig.nodecgPort, 10);
const isPortAvailable = await resolvedDeps.probePortAvailable(portAsNumber);
if (!isPortAvailable) {
throw new Error(
`Port ${appConfig.nodecgPort} is already in use. Stop the process using it or set NODECG_PORT before starting.`,
);
}
child.on("exit", (code, signal) => { const indexPath = path.join(nodecgRootPath, "index.js");
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`); const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
lastExit = { code, signal }; cwd: nodecgRootPath,
nodecgProcess = null; env: {
}); ...resolvedDeps.env,
NODE_ENV: isDev ? "development" : "production",
NODECG_PORT: appConfig.nodecgPort,
ELECTRON_RUN_AS_NODE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
detached: resolvedDeps.platform !== "win32",
shell: false,
windowsHide: true,
});
lastExit = null; child.stdout?.on("data", (chunk) => {
lastStderrLine = null; resolvedDeps.stdoutWrite(String(chunk));
nodecgProcess = child; });
return child;
child.stderr?.on("data", (chunk) => {
const line = String(chunk);
lastStderrLine = line.trim().length > 0 ? line.trim() : lastStderrLine;
resolvedDeps.stderrWrite(line);
});
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
child.on("exit", (code, signal) => {
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
lastExit = { code, signal };
if (nodecgProcess === child) {
nodecgProcess = null;
}
if (nodecgState !== "stopping") {
nodecgState = code === 0 ? "stopped" : "failed";
}
});
lastExit = null;
lastStderrLine = null;
nodecgProcess = child;
nodecgState = "running";
})()
.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> => {
@@ -121,7 +174,7 @@ export function createNodecgProcessManager({
exitDetails, exitDetails,
stderrDetails, stderrDetails,
`NodeCG path: ${nodecgRootPath}`, `NodeCG path: ${nodecgRootPath}`,
"Check that lib/nodecg dependencies are installed and the bundle exists.", "Check that the packaged runtime was installed correctly and the bundle exists.",
].join("\n"), ].join("\n"),
); );
} }
@@ -148,6 +201,7 @@ export function createNodecgProcessManager({
} }
if (!nodecgProcess || nodecgProcess.killed) { if (!nodecgProcess || nodecgProcess.killed) {
nodecgState = "stopped";
return Promise.resolve(); return Promise.resolve();
} }
@@ -156,18 +210,35 @@ 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}`);
killNodecgProcessTree(pid, "SIGTERM", log, resolvedDeps); killProcessTree(pid, "SIGTERM", {
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();
}; };
@@ -180,7 +251,13 @@ 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}`);
killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps); killProcessTree(pid, "SIGKILL", {
platform: resolvedDeps.platform,
spawnProcess: resolvedDeps.spawnProcess,
killProcess: resolvedDeps.killProcess,
log,
});
complete();
} }
}, },
Math.max(0, appConfig.nodecgKillTimeoutMs), Math.max(0, appConfig.nodecgKillTimeoutMs),
@@ -194,7 +271,7 @@ export function createNodecgProcessManager({
startNodecgProcess, startNodecgProcess,
waitForNodecgReady, waitForNodecgReady,
stopNodecgProcessGracefully, stopNodecgProcessGracefully,
getProcess: () => nodecgProcess, getState: () => nodecgState,
}; };
} }
@@ -234,7 +311,7 @@ function validateNodecgInstall(
} }
if (!pathExists(indexPath)) { if (!pathExists(indexPath)) {
throw new Error(`${indexPath} was not found. Copy a full NodeCG installation into lib/nodecg.`); throw new Error(`${indexPath} was not found. Build the packaged NodeCG runtime before starting Electron.`);
} }
if (!pathExists(nodecgBootstrapPath)) { if (!pathExists(nodecgBootstrapPath)) {
@@ -242,8 +319,8 @@ function validateNodecgInstall(
[ [
"NodeCG is present but internal dependencies are missing.", "NodeCG is present but internal dependencies are missing.",
`Not found: ${nodecgBootstrapPath}`, `Not found: ${nodecgBootstrapPath}`,
"Solution: enter lib/nodecg and install dependencies:", "Solution: rebuild the packaged runtime:",
" npm install", " npm run prepare:runtime",
].join("\n"), ].join("\n"),
); );
} }
@@ -253,7 +330,7 @@ function validateNodecgInstall(
[ [
`Bundle '${bundleName}' was not found.`, `Bundle '${bundleName}' was not found.`,
`Expected path: ${bundlePath}`, `Expected path: ${bundlePath}`,
"Copy/clone your bundle inside lib/nodecg/bundles before running Electron.", "Build and package the Scoreko bundle before running Electron.",
].join("\n"), ].join("\n"),
); );
} }
@@ -305,39 +382,6 @@ function probePortAvailable(port: number): Promise<boolean> {
}); });
} }
function killNodecgProcessTree(
pid: number,
signal: NodeJS.Signals,
log: (...args: unknown[]) => void,
deps: Pick<NodecgProcessManagerDeps, "platform" | "spawnProcess" | "killProcess">,
): boolean {
if (deps.platform === "win32") {
const force = signal === "SIGKILL" ? "/F" : "";
const killer = deps.spawnProcess("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
stdio: "ignore",
shell: true,
});
killer.on("error", (error) => {
log(`taskkill error for pid=${pid}`, error);
});
return true;
}
try {
deps.killProcess(-pid, signal);
return true;
} catch {
try {
deps.killProcess(pid, signal);
return true;
} catch {
return false;
}
}
}
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> { function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimer(resolve, ms); setTimer(resolve, ms);
+201
View File
@@ -0,0 +1,201 @@
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
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,110 @@
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
@@ -0,0 +1,193 @@
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
@@ -0,0 +1,137 @@
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
@@ -0,0 +1,7 @@
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,19 +1,28 @@
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-security"; import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation";
type WindowFactoryDependencies = { type WindowServiceDependencies = {
appConfig: AppRuntimeConfig; appConfig: AppRuntimeConfig;
allowDevTools: boolean;
rootPath: string; rootPath: string;
mainDashboardUrl: string; mainDashboardUrl: string;
}; };
export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: WindowFactoryDependencies): BrowserWindow { export function createMainWindow({
const windowOptions = createWindowOptions({ appConfig, rootPath, isLoadingWindow: false }); allowDevTools,
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 }) => {
@@ -25,6 +34,12 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind
}); });
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;
} }
@@ -44,10 +59,13 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind
} }
export function createLoadingWindow({ export function createLoadingWindow({
allowDevTools,
appConfig, appConfig,
rootPath, rootPath,
}: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow { }: Omit<WindowServiceDependencies, "mainDashboardUrl">): BrowserWindow {
const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true })); const window = new BrowserWindow(createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: true }));
applySecurityPolicies(window, allowDevTools);
window.on("page-title-updated", (event) => { window.on("page-title-updated", (event) => {
event.preventDefault(); event.preventDefault();
@@ -57,10 +75,12 @@ 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;
@@ -74,8 +94,10 @@ function createWindowOptions({
backgroundColor: DEFAULT_WINDOW_BACKGROUND, backgroundColor: DEFAULT_WINDOW_BACKGROUND,
webPreferences: { webPreferences: {
contextIsolation: true, contextIsolation: true,
devTools: allowDevTools,
nodeIntegration: false,
sandbox: true, sandbox: true,
...(isLoadingWindow ? {} : { nodeIntegration: false }), webSecurity: true,
}, },
}; };
@@ -100,3 +122,26 @@ 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
@@ -0,0 +1,56 @@
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
@@ -0,0 +1,289 @@
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);
});
@@ -0,0 +1,48 @@
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;
}
+3 -1
View File
@@ -13,10 +13,12 @@ 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-security"; import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation";
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
@@ -0,0 +1,65 @@
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" },
]);
});
+89 -6
View File
@@ -1,5 +1,6 @@
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";
@@ -25,10 +26,12 @@ 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,
}; };
} }
@@ -81,7 +84,7 @@ test("waitForNodeCGReady resolves when endpoint returns 404", async () => {
deps: { deps: {
platform: "linux", platform: "linux",
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
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();
@@ -114,7 +117,7 @@ test("stopNodeCG sends SIGTERM and then SIGKILL if the process does not exit", a
deps: { deps: {
platform: "linux", platform: "linux",
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
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 });
@@ -159,7 +162,7 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
log: () => undefined, log: () => undefined,
deps: { deps: {
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
killProcess: () => undefined, killProcess: () => undefined,
setTimer: () => 0, setTimer: () => 0,
@@ -180,6 +183,48 @@ 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[] = [];
@@ -195,7 +240,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
log: () => undefined, log: () => undefined,
deps: { deps: {
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
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) => {
@@ -238,6 +283,44 @@ 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({
@@ -249,7 +332,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
deps: { deps: {
pathExists: () => true, pathExists: () => true,
platform: "linux", platform: "linux",
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => { fetchUrl: async () => {
child.emit("exit", 1, null); child.emit("exit", 1, null);
throw new Error("still starting"); throw new Error("still starting");
+187 -13
View File
@@ -1,7 +1,21 @@
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 { getEnv, getOptionalEnv, parseEnvInt, parseEnvIntInRange, parseEnvPort } from "../main/config/runtime-config"; import {
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];
@@ -24,6 +38,30 @@ 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);
@@ -48,18 +86,6 @@ 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/);
@@ -83,3 +109,151 @@ 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
@@ -0,0 +1,209 @@
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
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,69 @@
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
@@ -0,0 +1,93 @@
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
@@ -0,0 +1,89 @@
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
@@ -0,0 +1,174 @@
<!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
@@ -0,0 +1,110 @@
!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
@@ -0,0 +1,4 @@
!macro customHeader
ShowInstDetails hide
ShowUninstDetails hide
!macroend
+101
View File
@@ -0,0 +1,101 @@
<!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>