mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
Compare commits
30 Commits
fbc709463f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 03446a3b4b | |||
| f0a35bf655 | |||
| 934500a1db | |||
| 2496f13055 | |||
| 982c771e82 | |||
| 6952a9954f | |||
| 7102e3dd01 | |||
| 5da609cce4 | |||
| 0ea4c6e01b | |||
| 143ff7e8db | |||
| beb22cb438 | |||
| 88223d744c | |||
| ed5a7d0994 | |||
| d01ae1fa6b | |||
| 3f756feca6 | |||
| ca74a23d19 | |||
| 8e6b79ca68 | |||
| ce59c5db89 | |||
| 92e2da1758 | |||
| 42a298925b | |||
| b0e0fdb9a1 | |||
| 33665ed896 | |||
| 865c3589bd | |||
| c8e2edc0c0 | |||
| 54ab1fcb9f | |||
| 2e1d3a170c | |||
| e3d3936156 | |||
| c168c3b84a | |||
| 67f3e60953 | |||
| d4dd77151c |
+14
-11
@@ -1,24 +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_USER_MODEL_ID=com.scoreko.desktop
|
||||
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_PORT=9090
|
||||
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
|
||||
NODECG_STARTUP_TIMEOUT_MS=30000
|
||||
NODECG_STARTUP_TIMEOUT_MS=120000
|
||||
NODECG_KILL_TIMEOUT_MS=2500
|
||||
|
||||
# Updates
|
||||
# Automated Updates Configuration (Required)
|
||||
SCOREKO_UPDATES_ENABLED=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$
|
||||
SCOREKO_UPDATE_CHECK_DELAY_MS=5000
|
||||
# SCOREKO_UPDATE_CONFIG_PATH=static/updates.json
|
||||
|
||||
# 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$
|
||||
|
||||
@@ -7,3 +7,4 @@ lib
|
||||
.localappdata
|
||||
.npm-cache
|
||||
.npm-runtime-cache
|
||||
.env
|
||||
@@ -1,47 +1,41 @@
|
||||
# scoreko-electron
|
||||
# Scoreko Desktop
|
||||
|
||||
Windows desktop installer for Scoreko. The packaged app includes Electron, NodeCG, the compiled `scoreko-dev` bundle, and the production modules needed to run it, so end users do not need Node.js, pnpm, or a cloned repository.
|
||||
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.
|
||||
|
||||
## Build on a development machine
|
||||
## Local Development
|
||||
|
||||
From the repository root:
|
||||
If you're working on the app locally, start by installing dependencies at the repository root:
|
||||
|
||||
```powershell
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Then from `scoreko-electron-dev`:
|
||||
Then, move into the wrapper folder:
|
||||
|
||||
```powershell
|
||||
cd scoreko-electron-dev
|
||||
npm install
|
||||
npm run dist:win
|
||||
```
|
||||
|
||||
The installer is written to `scoreko-electron-dev/release/Scoreko-setup-0.1.0.exe`.
|
||||
### Useful Commands
|
||||
|
||||
## What the build does
|
||||
- `npm run start`: Builds the bundle and launches Electron locally for testing.
|
||||
- `npm run dist:win`: Packages everything and creates the `.exe` Windows installer in the `release/` folder.
|
||||
- `npm run prepare:runtime`: Extracts a fresh NodeCG runtime from the parent bundle (useful if you changed dependencies).
|
||||
- `npm run rebuild:native`: Rebuilds native Node modules (like SQLite) specifically for Electron's V8 engine.
|
||||
- `npm run doctor`: Runs a quick sanity check to verify your local configuration and port availability.
|
||||
|
||||
- Builds the parent `scoreko-dev` bundle with `pnpm build`.
|
||||
- Creates `scoreko-electron-dev/lib/nodecg` with a small NodeCG runtime.
|
||||
- Installs production runtime modules into that runtime.
|
||||
- Rebuilds `better-sqlite3` for Electron before creating the installer.
|
||||
- Packages the runtime as an Electron extra resource outside the app archive.
|
||||
## How it works under the hood
|
||||
|
||||
## Runtime behavior
|
||||
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.
|
||||
|
||||
On first launch, Scoreko copies the packaged NodeCG runtime to the user's app data folder and then relaunches itself before starting NodeCG. This keeps `cfg`, `db`, and `logs` writable on Windows even when the app is installed under `Program Files`, and avoids transient startup failures caused by freshly copied runtime files.
|
||||
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`.
|
||||
|
||||
## Useful scripts
|
||||
## Auto-Updates via Gitea
|
||||
|
||||
- `npm run start`: build everything and run Electron locally.
|
||||
- `npm run prepare:runtime`: recreate `lib/nodecg` from the parent bundle.
|
||||
- `npm run rebuild:native`: rebuild NodeCG native modules for Electron.
|
||||
- `npm run dist:win`: create the Windows installer.
|
||||
- `npm run doctor`: check the prepared runtime and the configured port.
|
||||
Scoreko supports seamless, opt-in updates through your Gitea instance.
|
||||
|
||||
## Updates from Gitea
|
||||
|
||||
Scoreko can check a Gitea release feed without forcing the user to update. Edit `static/updates.json` before building:
|
||||
Before building your production installer, check `static/updates.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -52,17 +46,20 @@ Scoreko can check a Gitea release feed without forcing the user to update. Edit
|
||||
}
|
||||
```
|
||||
|
||||
For each release, bump `package.json` version, build with `npm run dist:win`, create a Gitea release tagged like `v0.2.0`, and attach `release/Scoreko-setup-0.2.0.exe`. When Scoreko sees a newer tag, it asks whether to download and install it.
|
||||
**To ship an update:**
|
||||
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.
|
||||
|
||||
## Configuration
|
||||
## Environment Configuration
|
||||
|
||||
The defaults match the parent bundle:
|
||||
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_LOADING_ROUTE=dashboard/loading/main.html?standalone=true`
|
||||
- `SCOREKO_UPDATES_ENABLED=true`
|
||||
- `SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$`
|
||||
|
||||
Copy `.env.example` only if you need local overrides while developing.
|
||||
You only need to mess with `.env.example` if you want to override these values locally while testing.
|
||||
|
||||
+21
-24
@@ -1,30 +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`.
|
||||
2. Installs or refreshes the packaged NodeCG runtime in user data when needed (`nodecg/runtime-provisioner.ts`).
|
||||
3. Creates windows (`windows/window-factory.ts`).
|
||||
4. In packaged builds, relaunches once after a fresh runtime install so NodeCG starts from a settled user-data runtime.
|
||||
5. Starts NodeCG with `nodecg/process-manager.ts`.
|
||||
6. Waits for HTTP readiness and shows loading -> main dashboard.
|
||||
7. Checks the configured Gitea latest-release endpoint for optional updates.
|
||||
8. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
|
||||
## Startup Flow
|
||||
|
||||
## 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.
|
||||
- `nodecg/runtime-provisioner.ts`: install/refresh the managed runtime in the writable user data folder and report whether it changed.
|
||||
- `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation.
|
||||
- `updates/update-manager.ts`: optional Gitea release checks, installer download, and user-controlled install.
|
||||
- `updates/update-utils.ts`: release version comparison and installer asset selection.
|
||||
- `windows/window-factory.ts`: window creation and navigation policy.
|
||||
- `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes.
|
||||
- `errors/error-presenter.ts`: fatal error presentation.
|
||||
- `errors/logger.ts`: structured logging (`info/warn/error/debug`).
|
||||
1. **Configuration:** `src/main/main.ts` kicks things off by loading `appConfig` via `config/runtime-config.ts`.
|
||||
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`).
|
||||
3. **Window Creation:** The initial windows (like the loading screen) are instantiated via `windows/window-factory.ts`.
|
||||
4. **NodeCG Boot:** `nodecg/process-manager.ts` spawns the NodeCG process in the background.
|
||||
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.
|
||||
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.
|
||||
- Incremental hardening with conservative fallback.
|
||||
- Automated validation via `typecheck`, `build`, `test`, `doctor`, `lint`.
|
||||
Here is where the heavy lifting happens:
|
||||
|
||||
- **`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
-36
@@ -1,45 +1,43 @@
|
||||
# Troubleshooting
|
||||
# Troubleshooting Guide
|
||||
|
||||
## `The packaged NodeCG runtime is incomplete`
|
||||
Here are some common issues you might run into while developing or using the Scoreko desktop app, along with quick fixes.
|
||||
|
||||
- Run `npm run prepare:runtime` from `scoreko-electron-dev`.
|
||||
- If the parent bundle is not installed yet, run `pnpm install` from the repository root first.
|
||||
## The app says the NodeCG runtime is incomplete
|
||||
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.
|
||||
|
||||
## `NodeCG is present but internal dependencies are missing`
|
||||
## 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.
|
||||
|
||||
- Recreate the runtime with `npm run prepare:runtime`.
|
||||
- If native SQLite errors appear during launch, run `npm run rebuild:native` before packaging.
|
||||
## "No read/write permissions on NodeCG"
|
||||
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.
|
||||
|
||||
## `No read/write permissions on NodeCG`
|
||||
## 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.
|
||||
|
||||
- Installed builds run NodeCG from the user's app data folder, so this usually means the local development copy is locked.
|
||||
- Close any running Scoreko/NodeCG process and run `npm run start` again.
|
||||
## Timeout while waiting for NodeCG
|
||||
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.
|
||||
|
||||
## `Port <PORT> is already in use`
|
||||
## 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`.
|
||||
|
||||
- Free the port or set `NODECG_PORT` in `.env`.
|
||||
- Use `npm run doctor` to validate availability before startup.
|
||||
## macOS builds are failing complaining about an icon
|
||||
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.
|
||||
|
||||
## `Timeout while waiting for NodeCG`
|
||||
|
||||
- Check the Electron/NodeCG output in the terminal.
|
||||
- Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow.
|
||||
- Recreate the runtime with `npm run prepare:runtime` if the bundle changed.
|
||||
|
||||
## First launch after install fails
|
||||
|
||||
- Scoreko relaunches itself automatically after a fresh runtime install.
|
||||
- If it still fails, check whether antivirus or file indexing is locking `%AppData%\scoreko\nodecg`.
|
||||
- Rebuild the installer with `npm run dist:win` after running `npm run rebuild:native`.
|
||||
|
||||
## macOS build fails because of icon
|
||||
|
||||
- The configuration expects `static/icons/icon.icns`.
|
||||
- Create that file before running macOS packaging.
|
||||
|
||||
## Updates do not appear
|
||||
|
||||
- Check that `static/updates.json` has `"enabled": true` before building the installer.
|
||||
- The `apiUrl` must point to Gitea's latest release API: `/api/v1/repos/<owner>/<repo>/releases/latest`.
|
||||
- The release tag must be newer than the installed `package.json` version, for example `v0.2.0`.
|
||||
- The release must include an installer asset matching `assetPattern`, by default `Scoreko-setup-.*\.exe$`.
|
||||
## Auto-updates aren't triggering
|
||||
If you published a new release on Gitea but the app ignores it:
|
||||
- Double check that `static/updates.json` has `"enabled": true` before you build the installer.
|
||||
- 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$`).
|
||||
|
||||
Generated
+12
-65
@@ -8,8 +8,10 @@
|
||||
"name": "scoreko-electron",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"electron-log": "^5.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||
"@typescript-eslint/parser": "^8.22.0",
|
||||
@@ -182,31 +184,6 @@
|
||||
"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": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
|
||||
@@ -273,35 +250,6 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz",
|
||||
@@ -2971,6 +2919,15 @@
|
||||
"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": {
|
||||
"version": "25.1.7",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
|
||||
+18
-8
@@ -34,6 +34,7 @@
|
||||
"dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac"
|
||||
},
|
||||
"build": {
|
||||
"beforePack": "./scripts/before-pack.mjs",
|
||||
"appId": "com.scoreko.desktop",
|
||||
"productName": "Scoreko",
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
@@ -53,6 +54,10 @@
|
||||
{
|
||||
"from": "static",
|
||||
"to": "static"
|
||||
},
|
||||
{
|
||||
"from": ".env",
|
||||
"to": ".env"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -74,9 +79,10 @@
|
||||
],
|
||||
"icon": "static/icons/icon.ico",
|
||||
"executableName": "scoreko",
|
||||
"signAndEditExecutable": false
|
||||
"signAndEditExecutable": true
|
||||
},
|
||||
"nsis": {
|
||||
"include": "static/installer.nsh",
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"artifactName": "${productName}-setup-${version}.${ext}",
|
||||
@@ -84,7 +90,9 @@
|
||||
"uninstallerIcon": "static/icons/icon.ico",
|
||||
"installerHeaderIcon": "static/icons/icon.ico",
|
||||
"shortcutName": "Scoreko",
|
||||
"useZip": false
|
||||
"useZip": false,
|
||||
"deleteAppDataOnUninstall": true,
|
||||
"createDesktopShortcut": "always"
|
||||
},
|
||||
"compression": "normal"
|
||||
},
|
||||
@@ -92,17 +100,19 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^3.7.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||
"@typescript-eslint/parser": "^8.22.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"electron": "39.5.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"eslint": "^9.19.0",
|
||||
"prettier": "^3.4.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.3",
|
||||
"wait-on": "^8.0.1",
|
||||
"eslint": "^9.19.0",
|
||||
"@typescript-eslint/parser": "^8.22.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.22.0",
|
||||
"prettier": "^3.4.2"
|
||||
"wait-on": "^8.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-log": "^5.4.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,14 +3,29 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const electronRoot = process.cwd();
|
||||
const bundleRoot = path.resolve(electronRoot, "..");
|
||||
const packageJsonPath = path.join(bundleRoot, "package.json");
|
||||
const pnpmLockPath = path.join(bundleRoot, "pnpm-lock.yaml");
|
||||
import {
|
||||
bundleRoot,
|
||||
bundleRootMarkers,
|
||||
electronRoot,
|
||||
generatedBundleEntries,
|
||||
getLocalBinPath,
|
||||
getPathInside,
|
||||
} from "./build-config.mjs";
|
||||
|
||||
const nodeModulesPath = path.join(bundleRoot, "node_modules");
|
||||
|
||||
if (!existsSync(packageJsonPath) || !existsSync(pnpmLockPath)) {
|
||||
console.error(`Scoreko bundle root was not found at: ${bundleRoot}`);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -25,7 +40,6 @@ if (!existsSync(nodeModulesPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generatedBundleEntries = ["extension", "node_modules/.vite", "shared/dist", "dashboard", "graphics"];
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
COREPACK_HOME: process.env.COREPACK_HOME ?? path.join(electronRoot, ".corepack"),
|
||||
@@ -33,12 +47,7 @@ const childEnv = {
|
||||
};
|
||||
|
||||
function removeGeneratedOutput(relativePath) {
|
||||
const targetPath = path.resolve(bundleRoot, relativePath);
|
||||
|
||||
if (!targetPath.startsWith(`${bundleRoot}${path.sep}`)) {
|
||||
throw new Error(`Refusing to remove path outside the bundle root: ${targetPath}`);
|
||||
}
|
||||
|
||||
const targetPath = getPathInside(bundleRoot, relativePath);
|
||||
rmSync(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -60,11 +69,6 @@ function runCommand(command, args) {
|
||||
}
|
||||
}
|
||||
|
||||
function runLocalBin(commandName, args) {
|
||||
const extension = process.platform === "win32" ? ".CMD" : "";
|
||||
runCommand(path.join(bundleRoot, "node_modules", ".bin", `${commandName}${extension}`), args);
|
||||
}
|
||||
|
||||
for (const entry of generatedBundleEntries) {
|
||||
removeGeneratedOutput(entry);
|
||||
}
|
||||
@@ -73,5 +77,5 @@ for (const entry of ["shared/dist", "dashboard", "graphics", "extension"]) {
|
||||
mkdirSync(path.join(bundleRoot, entry), { recursive: true });
|
||||
}
|
||||
|
||||
runLocalBin("vite", ["build", "--configLoader", "runner"]);
|
||||
runLocalBin("tsc", ["-b", "tsconfig.extension.json"]);
|
||||
runCommand(getLocalBinPath("vite"), ["build", "--configLoader", "runner"]);
|
||||
runCommand(getLocalBinPath("tsc"), ["-b", "tsconfig.extension.json"]);
|
||||
|
||||
+40
-17
@@ -3,17 +3,35 @@ import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const nodecgRootPath = path.resolve(cwd, "lib", "nodecg");
|
||||
import { bundleName, nodecgRuntimeRoot } from "./build-config.mjs";
|
||||
|
||||
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) {
|
||||
checks.push({ ok, title, details });
|
||||
}
|
||||
|
||||
function parsePort(name, fallback) {
|
||||
const raw = process.env[name] ?? fallback;
|
||||
function parsePort(name) {
|
||||
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);
|
||||
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
||||
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;
|
||||
}
|
||||
|
||||
function parseIntInRange(name, fallback, min, max) {
|
||||
const raw = process.env[name] ?? String(fallback);
|
||||
function parseIntInRange(name, min, max) {
|
||||
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);
|
||||
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
||||
addCheck(false, `${name} invalid`, `It must be an integer between ${min} and ${max}. Received value: '${raw}'.`);
|
||||
@@ -36,20 +58,19 @@ function parseIntInRange(name, fallback, min, max) {
|
||||
}
|
||||
|
||||
function checkNodecgInstall() {
|
||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
||||
const bootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
||||
const manifestPath = path.join(nodecgRootPath, ".scoreko-runtime.json");
|
||||
const bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim();
|
||||
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
|
||||
const indexPath = path.join(nodecgRuntimeRoot, "index.js");
|
||||
const bootstrapPath = path.join(nodecgRuntimeRoot, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
||||
const manifestPath = path.join(nodecgRuntimeRoot, ".scoreko-runtime.json");
|
||||
const bundlePath = path.join(nodecgRuntimeRoot, "bundles", bundleName);
|
||||
|
||||
addCheck(fs.existsSync(nodecgRootPath), "Packaged NodeCG runtime", nodecgRootPath);
|
||||
addCheck(fs.existsSync(nodecgRuntimeRoot), "Packaged NodeCG runtime", nodecgRuntimeRoot);
|
||||
addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath);
|
||||
addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath);
|
||||
addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath);
|
||||
addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath);
|
||||
|
||||
try {
|
||||
fs.accessSync(nodecgRootPath, fs.constants.R_OK | fs.constants.W_OK);
|
||||
fs.accessSync(nodecgRuntimeRoot, fs.constants.R_OK | fs.constants.W_OK);
|
||||
addCheck(true, "lib/nodecg permissions", "Read/write OK for local development");
|
||||
} catch {
|
||||
addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg");
|
||||
@@ -75,10 +96,12 @@ function checkPortAvailability(port) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = parsePort("NODECG_PORT", "9090");
|
||||
parseIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000);
|
||||
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000);
|
||||
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000);
|
||||
loadEnv();
|
||||
|
||||
const port = parsePort("NODECG_PORT");
|
||||
parseIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000);
|
||||
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000);
|
||||
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000);
|
||||
checkNodecgInstall();
|
||||
|
||||
if (port) {
|
||||
|
||||
@@ -3,27 +3,17 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } fr
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const electronRoot = process.cwd();
|
||||
const bundleRoot = path.resolve(electronRoot, "..");
|
||||
const runtimeRoot = path.join(electronRoot, "lib", "nodecg");
|
||||
const runtimeNodeModules = path.join(runtimeRoot, "node_modules");
|
||||
const bundleName = process.env.NODECG_BUNDLE_NAME?.trim() || "scoreko-dev";
|
||||
const runtimeBundleRoot = path.join(runtimeRoot, "bundles", bundleName);
|
||||
|
||||
const bundleEntries = [
|
||||
"assets",
|
||||
"dashboard",
|
||||
"extension",
|
||||
"graphics",
|
||||
"schemas",
|
||||
"shared",
|
||||
"configschema.json",
|
||||
"LICENSE",
|
||||
"package.json",
|
||||
"README.md",
|
||||
];
|
||||
|
||||
const requiredBundleEntries = ["dashboard", "extension", "graphics", "schemas", "shared", "package.json"];
|
||||
import {
|
||||
bundleName,
|
||||
bundleRoot,
|
||||
getNpmCommand,
|
||||
nodecgRuntimeNodeModules,
|
||||
nodecgRuntimeRoot,
|
||||
preparedBundleEntries,
|
||||
requiredPreparedBundleEntries,
|
||||
runtimeBundleRoot,
|
||||
runtimeNpmCache,
|
||||
} from "./build-config.mjs";
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||
@@ -50,7 +40,7 @@ function run(command, args, cwd) {
|
||||
shell: process.platform === "win32",
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_cache: process.env.npm_config_cache ?? path.join(electronRoot, ".npm-runtime-cache"),
|
||||
npm_config_cache: runtimeNpmCache,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,7 +70,7 @@ function getInstalledNodecgVersion() {
|
||||
}
|
||||
|
||||
function assertBundleBuildExists() {
|
||||
for (const entry of requiredBundleEntries) {
|
||||
for (const entry of requiredPreparedBundleEntries) {
|
||||
const source = path.join(bundleRoot, entry);
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(
|
||||
@@ -102,7 +92,7 @@ function createRuntimePackageJson() {
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
path.join(runtimeRoot, "package.json"),
|
||||
path.join(nodecgRuntimeRoot, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
@@ -120,23 +110,23 @@ function createRuntimePackageJson() {
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
writeFileSync(path.join(runtimeRoot, "index.js"), 'require("nodecg");\n');
|
||||
writeFileSync(path.join(nodecgRuntimeRoot, "index.js"), 'require("nodecg");\n');
|
||||
}
|
||||
|
||||
function copyBundle() {
|
||||
mkdirSync(runtimeBundleRoot, { recursive: true });
|
||||
|
||||
for (const entry of bundleEntries) {
|
||||
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(runtimeRoot, "package.json"));
|
||||
const runtimePackageJson = readJson(path.join(nodecgRuntimeRoot, "package.json"));
|
||||
|
||||
writeFileSync(
|
||||
path.join(runtimeRoot, ".scoreko-runtime.json"),
|
||||
path.join(nodecgRuntimeRoot, ".scoreko-runtime.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
bundleName,
|
||||
@@ -156,23 +146,22 @@ function installRuntimeDependencies() {
|
||||
return;
|
||||
}
|
||||
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
run(npmCommand, ["install", "--omit=dev", "--no-audit", "--no-fund"], runtimeRoot);
|
||||
run(getNpmCommand(), ["install", "--omit=dev", "--no-audit", "--no-fund"], nodecgRuntimeRoot);
|
||||
}
|
||||
|
||||
function main() {
|
||||
assertBundleBuildExists();
|
||||
|
||||
rmSync(runtimeRoot, { recursive: true, force: true });
|
||||
mkdirSync(runtimeNodeModules, { recursive: true });
|
||||
mkdirSync(path.join(runtimeRoot, "bundles"), { recursive: true });
|
||||
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 ${runtimeRoot}`);
|
||||
console.log(`[prepare-runtime] NodeCG runtime ready at ${nodecgRuntimeRoot}`);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,18 +2,17 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const root = process.cwd();
|
||||
const nodecgDir = path.join(root, "lib", "nodecg");
|
||||
const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
||||
import { electronCache, electronRoot, getNpmCommand, nodecgRuntimeRoot, runtimeNpmCache } from "./build-config.mjs";
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(path.join(electronRoot, "package.json"), "utf8"));
|
||||
const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron;
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
|
||||
if (!electronVersion) {
|
||||
console.error("Could not determine Electron version from package.json.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(path.join(nodecgDir, "package.json"))) {
|
||||
if (!existsSync(path.join(nodecgRuntimeRoot, "package.json"))) {
|
||||
console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first.");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -30,8 +29,8 @@ function run(command, args, cwd) {
|
||||
npm_config_runtime: "electron",
|
||||
npm_config_target: electronVersion,
|
||||
npm_config_disturl: "https://electronjs.org/headers",
|
||||
npm_config_cache: process.env.npm_config_cache ?? path.join(root, ".npm-runtime-cache"),
|
||||
ELECTRON_CACHE: process.env.ELECTRON_CACHE ?? path.join(root, ".electron-cache"),
|
||||
npm_config_cache: runtimeNpmCache,
|
||||
ELECTRON_CACHE: electronCache,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,13 +44,13 @@ function run(command, args, cwd) {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgDir}`);
|
||||
await run(npmCommand, [
|
||||
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgRuntimeRoot}`);
|
||||
await run(getNpmCommand(), [
|
||||
"rebuild",
|
||||
"better-sqlite3",
|
||||
"--runtime=electron",
|
||||
`--target=${electronVersion}`,
|
||||
"--dist-url=https://electronjs.org/headers",
|
||||
], nodecgDir);
|
||||
], nodecgRuntimeRoot);
|
||||
|
||||
console.log("\n[rebuild-native] Done.");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type AppRuntimeConfig = {
|
||||
title: string;
|
||||
userModelId: string;
|
||||
@@ -6,7 +9,6 @@ export type AppRuntimeConfig = {
|
||||
nodecgPort: string;
|
||||
bundleName: string;
|
||||
mainDashboardRoute: string;
|
||||
loadingDashboardRoute: string;
|
||||
loadDelayMs: number;
|
||||
startupTimeoutMs: number;
|
||||
nodecgKillTimeoutMs: number;
|
||||
@@ -14,36 +16,67 @@ export type AppRuntimeConfig = {
|
||||
updateApiUrl?: string;
|
||||
updateReleasePageUrl?: string;
|
||||
updateAssetPattern?: string;
|
||||
updateConfigPathOverride?: string;
|
||||
updateCheckDelayMs: number;
|
||||
};
|
||||
|
||||
const MIN_TCP_PORT = 1;
|
||||
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 {
|
||||
// Centralized defaults keep local development and packaged builds consistent.
|
||||
return {
|
||||
title: getEnv("SCOREKO_APP_TITLE", "Scoreko"),
|
||||
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"),
|
||||
userDataDirectoryName: getEnv("SCOREKO_APP_USER_DATA_DIRECTORY", "scoreko"),
|
||||
title: getRequiredEnv("SCOREKO_APP_TITLE"),
|
||||
userModelId: getRequiredEnv("SCOREKO_APP_USER_MODEL_ID"),
|
||||
userDataDirectoryName: getRequiredEnv("SCOREKO_APP_USER_DATA_DIRECTORY"),
|
||||
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
|
||||
nodecgPort: parseEnvPort("NODECG_PORT", "9090"),
|
||||
bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"),
|
||||
mainDashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"),
|
||||
loadingDashboardRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"),
|
||||
loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000),
|
||||
startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000),
|
||||
nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000),
|
||||
updatesEnabled: parseEnvBool("SCOREKO_UPDATES_ENABLED", true),
|
||||
nodecgPort: parseRequiredEnvPort("NODECG_PORT"),
|
||||
bundleName: getRequiredEnv("NODECG_BUNDLE_NAME"),
|
||||
mainDashboardRoute: getRequiredEnv("SCOREKO_DASHBOARD_ROUTE"),
|
||||
loadDelayMs: parseRequiredEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000),
|
||||
startupTimeoutMs: parseRequiredEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000),
|
||||
nodecgKillTimeoutMs: parseRequiredEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 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"),
|
||||
updateConfigPathOverride: getOptionalEnv("SCOREKO_UPDATE_CONFIG_PATH"),
|
||||
updateCheckDelayMs: parseEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 5000, 0, 600000),
|
||||
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 {
|
||||
const value = process.env[name]?.trim();
|
||||
return value && value.length > 0 ? value : undefined;
|
||||
@@ -53,18 +86,18 @@ export function getEnv(name: string, fallback: string): string {
|
||||
return getOptionalEnv(name) ?? fallback;
|
||||
}
|
||||
|
||||
export function parseEnvInt(name: string, fallback: number): number {
|
||||
const rawValue = process.env[name];
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function parseRequiredEnvIntInRange(name: string, min: number, max: number): number {
|
||||
const rawValue = getRequiredEnv(name);
|
||||
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 {
|
||||
// We throw here instead of silently coercing to avoid hidden misconfiguration in production.
|
||||
const rawValue = process.env[name];
|
||||
if (!rawValue) {
|
||||
return fallback;
|
||||
@@ -80,6 +113,19 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
|
||||
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) {
|
||||
@@ -115,6 +161,19 @@ export function parseOptionalHttpUrl(name: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const rawValue = getEnv(name, fallback);
|
||||
const parsedValue = Number.parseInt(rawValue, 10);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { app, dialog } from "electron";
|
||||
|
||||
import { logger } from "./logger";
|
||||
import { logger } from "../logging/logger";
|
||||
|
||||
export function log(...args: unknown[]): void {
|
||||
logger.info("runtime", { args });
|
||||
}
|
||||
|
||||
export function formatErrorMessage(error: unknown): string {
|
||||
function formatErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const stack = error.stack?.trim();
|
||||
return stack && stack.length > 0 ? stack : error.message;
|
||||
@@ -1,7 +1,14 @@
|
||||
import electronLog from "electron-log";
|
||||
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
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 {
|
||||
const payload = {
|
||||
ts: new Date().toISOString(),
|
||||
@@ -13,17 +20,7 @@ function write(level: LogLevel, message: string, context?: LogContext): void {
|
||||
|
||||
const line = JSON.stringify(payload);
|
||||
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (level === "warn") {
|
||||
console.warn(line);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(line);
|
||||
electronLog[level](line);
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
+2
-219
@@ -1,220 +1,3 @@
|
||||
import { app, BrowserWindow } from "electron";
|
||||
import path from "node:path";
|
||||
import { bootstrap } from "./app/bootstrap";
|
||||
|
||||
import { getRuntimeConfig } from "./config/runtime-config";
|
||||
import { showFatalError, log } from "./errors/error-presenter";
|
||||
import { createNodecgProcessManager, NodecgProcessManager } from "./nodecg/process-manager";
|
||||
import { prepareUserNodecgRuntime } from "./nodecg/runtime-provisioner";
|
||||
import { scheduleUpdateCheck } from "./updates/update-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 sourceNodecgRuntimePath = path.resolve(rootPath, "lib", "nodecg");
|
||||
const mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`;
|
||||
const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`;
|
||||
const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`;
|
||||
|
||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!hasSingleInstanceLock) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
type AppShutdownState = "running" | "stopping" | "stopped";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let loadingWindow: BrowserWindow | null = null;
|
||||
let nodecgManager: NodecgProcessManager | null = null;
|
||||
let shutdownState: AppShutdownState = "running";
|
||||
|
||||
function focusExistingWindow(): void {
|
||||
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> {
|
||||
const preparedRuntime = prepareUserNodecgRuntime({
|
||||
sourceRuntimePath: sourceNodecgRuntimePath,
|
||||
userDataPath: app.getPath("userData"),
|
||||
appVersion: app.getVersion(),
|
||||
bundleName: appConfig.bundleName,
|
||||
log,
|
||||
});
|
||||
|
||||
if (preparedRuntime.installed && app.isPackaged) {
|
||||
log("Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.");
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
nodecgManager = createNodecgProcessManager({
|
||||
isDev,
|
||||
nodecgRootPath: preparedRuntime.runtimePath,
|
||||
nodecgBaseUrl,
|
||||
appConfig,
|
||||
log,
|
||||
});
|
||||
|
||||
// We create both windows early so startup feels instant while NodeCG is booting in the background.
|
||||
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
|
||||
loadingWindow = createLoadingWindow({ appConfig, rootPath });
|
||||
|
||||
await startNodecg();
|
||||
|
||||
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();
|
||||
scheduleUpdateCheck({
|
||||
appConfig,
|
||||
rootPath,
|
||||
getParentWindow: () => mainWindow,
|
||||
beforeInstall: stopNodecgGracefully,
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
async function startNodecg(): Promise<void> {
|
||||
if (!nodecgManager) {
|
||||
throw new Error("NodeCG process manager is not initialized.");
|
||||
}
|
||||
|
||||
await nodecgManager.startNodecgProcess();
|
||||
await nodecgManager.waitForNodecgReady(Date.now());
|
||||
}
|
||||
|
||||
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() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
shutdownState = "stopping";
|
||||
|
||||
return (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve()).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);
|
||||
});
|
||||
bootstrap();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { NODE_RUNTIME_NAME } from "../constants";
|
||||
import { killProcessTree } from "./process-killer";
|
||||
|
||||
type NodecgProcessManagerConfig = {
|
||||
isDev: boolean;
|
||||
@@ -16,7 +17,7 @@ type NodecgProcessManagerConfig = {
|
||||
};
|
||||
|
||||
type NodecgProcessManagerDeps = {
|
||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
||||
spawnProcess: (command: string, args: string[], options: SpawnOptions) => NodecgChildProcess;
|
||||
pathExists: (candidatePath: string) => boolean;
|
||||
fetchUrl: typeof fetch;
|
||||
platform: NodeJS.Platform;
|
||||
@@ -30,13 +31,31 @@ type NodecgProcessManagerDeps = {
|
||||
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 = {
|
||||
startNodecgProcess: () => Promise<ChildProcess>;
|
||||
startNodecgProcess: () => Promise<void>;
|
||||
waitForNodecgReady: (startTime: number) => Promise<void>;
|
||||
stopNodecgProcessGracefully: () => Promise<void>;
|
||||
getProcess: () => ChildProcess | null;
|
||||
getState: () => NodecgProcessState;
|
||||
};
|
||||
|
||||
type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
|
||||
|
||||
export function createNodecgProcessManager({
|
||||
isDev,
|
||||
nodecgRootPath,
|
||||
@@ -47,65 +66,98 @@ export function createNodecgProcessManager({
|
||||
}: NodecgProcessManagerConfig): NodecgProcessManager {
|
||||
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 lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||
let lastStderrLine: string | null = null;
|
||||
|
||||
const startNodecgProcess = async (): Promise<ChildProcess> => {
|
||||
// Fail fast with actionable errors before spawning child processes.
|
||||
validateNodecgInstall(
|
||||
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 startNodecgProcess = (): Promise<void> => {
|
||||
if (nodecgProcess && nodecgState === "running") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
||||
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
|
||||
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: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
if (startNodecgPromise) {
|
||||
return startNodecgPromise;
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
resolvedDeps.stdoutWrite(String(chunk));
|
||||
});
|
||||
if (nodecgState === "stopping") {
|
||||
return Promise.reject(new Error("Cannot start NodeCG while shutdown is in progress."));
|
||||
}
|
||||
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const line = String(chunk);
|
||||
lastStderrLine = line.trim().length > 0 ? line.trim() : lastStderrLine;
|
||||
resolvedDeps.stderrWrite(line);
|
||||
});
|
||||
nodecgState = "starting";
|
||||
startNodecgPromise = (async () => {
|
||||
// Fail fast with actionable errors before spawning child processes.
|
||||
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) => {
|
||||
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
|
||||
lastExit = { code, signal };
|
||||
nodecgProcess = null;
|
||||
});
|
||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
||||
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
|
||||
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: false,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
lastExit = null;
|
||||
lastStderrLine = null;
|
||||
nodecgProcess = child;
|
||||
return child;
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
resolvedDeps.stdoutWrite(String(chunk));
|
||||
});
|
||||
|
||||
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> => {
|
||||
@@ -149,6 +201,7 @@ export function createNodecgProcessManager({
|
||||
}
|
||||
|
||||
if (!nodecgProcess || nodecgProcess.killed) {
|
||||
nodecgState = "stopped";
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -157,11 +210,19 @@ export function createNodecgProcessManager({
|
||||
|
||||
if (typeof pid !== "number") {
|
||||
log("NodeCG pid unavailable, skipping graceful stop");
|
||||
nodecgProcess = null;
|
||||
nodecgState = "stopped";
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
nodecgState = "stopping";
|
||||
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) => {
|
||||
let completed = false;
|
||||
@@ -177,6 +238,7 @@ export function createNodecgProcessManager({
|
||||
nodecgProcess = null;
|
||||
}
|
||||
|
||||
nodecgState = "stopped";
|
||||
stopNodecgPromise = null;
|
||||
resolve();
|
||||
};
|
||||
@@ -189,7 +251,12 @@ export function createNodecgProcessManager({
|
||||
() => {
|
||||
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
||||
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();
|
||||
}
|
||||
},
|
||||
@@ -204,7 +271,7 @@ export function createNodecgProcessManager({
|
||||
startNodecgProcess,
|
||||
waitForNodecgReady,
|
||||
stopNodecgProcessGracefully,
|
||||
getProcess: () => nodecgProcess,
|
||||
getState: () => nodecgState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -315,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> {
|
||||
return new Promise((resolve) => {
|
||||
setTimer(resolve, ms);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { getManagedNodecgRuntimePath } from "../app/paths";
|
||||
|
||||
type RuntimeProvisionerConfig = {
|
||||
sourceRuntimePath: string;
|
||||
userDataPath: string;
|
||||
@@ -21,11 +23,13 @@ type RuntimeProvisionerDeps = {
|
||||
recursive: true;
|
||||
force: true;
|
||||
dereference: true;
|
||||
filter: (sourcePath: string) => boolean;
|
||||
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 = {
|
||||
@@ -38,6 +42,7 @@ type RuntimeManifest = {
|
||||
bundleName?: unknown;
|
||||
sourceRuntime?: RuntimeManifest | null;
|
||||
bundleVersion?: unknown;
|
||||
generatedAt?: unknown;
|
||||
nodecgVersion?: unknown;
|
||||
};
|
||||
|
||||
@@ -54,7 +59,7 @@ export function prepareUserNodecgRuntime({
|
||||
deps,
|
||||
}: RuntimeProvisionerConfig): PreparedNodecgRuntime {
|
||||
const resolvedDeps = resolveDeps(deps);
|
||||
const targetRuntimePath = path.join(userDataPath, "nodecg");
|
||||
const targetRuntimePath = getManagedNodecgRuntimePath(userDataPath);
|
||||
|
||||
validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync);
|
||||
resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true });
|
||||
@@ -81,6 +86,8 @@ function resolveDeps(deps?: Partial<RuntimeProvisionerDeps>): RuntimeProvisioner
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +138,7 @@ function shouldInstallRuntime(
|
||||
targetMarker?.appVersion !== appVersion ||
|
||||
targetMarker?.bundleName !== bundleName ||
|
||||
targetMarker?.sourceRuntime?.bundleVersion !== sourceMarker?.bundleVersion ||
|
||||
targetMarker?.sourceRuntime?.generatedAt !== sourceMarker?.generatedAt ||
|
||||
targetMarker?.sourceRuntime?.nodecgVersion !== sourceMarker?.nodecgVersion
|
||||
);
|
||||
}
|
||||
@@ -146,16 +154,20 @@ function installManagedRuntime(
|
||||
deps.rmSync(path.join(targetRuntimePath, entry), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
deps.cpSync(sourceRuntimePath, targetRuntimePath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
dereference: true,
|
||||
filter: (sourcePath) => {
|
||||
const relativePath = path.relative(sourceRuntimePath, sourcePath);
|
||||
const firstSegment = relativePath.split(path.sep)[0];
|
||||
return !WRITABLE_NODECG_DIRS.includes(firstSegment as (typeof WRITABLE_NODECG_DIRS)[number]);
|
||||
},
|
||||
});
|
||||
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(
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { app, BrowserWindow, dialog, shell } from "electron";
|
||||
import type { MessageBoxOptions } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { buildReleaseUpdate, GiteaRelease, ReleaseUpdate, sanitizeFileName, UpdateFileConfig } from "./update-utils";
|
||||
|
||||
type UpdateManagerConfig = {
|
||||
appConfig: AppRuntimeConfig;
|
||||
rootPath: string;
|
||||
getParentWindow: () => BrowserWindow | null;
|
||||
beforeInstall: () => Promise<void>;
|
||||
log: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
type UpdateSettings = {
|
||||
enabled: boolean;
|
||||
apiUrl?: string;
|
||||
releasePageUrl?: string;
|
||||
assetPattern: string;
|
||||
};
|
||||
|
||||
export function scheduleUpdateCheck({
|
||||
appConfig,
|
||||
rootPath,
|
||||
getParentWindow,
|
||||
beforeInstall,
|
||||
log,
|
||||
}: UpdateManagerConfig): void {
|
||||
const settings = loadUpdateSettings(appConfig, rootPath, log);
|
||||
|
||||
if (!settings.enabled || !settings.apiUrl) {
|
||||
log("Update checks disabled or not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
void checkForUpdates({ settings, getParentWindow, beforeInstall, log });
|
||||
}, appConfig.updateCheckDelayMs);
|
||||
}
|
||||
|
||||
async function checkForUpdates({
|
||||
settings,
|
||||
getParentWindow,
|
||||
beforeInstall,
|
||||
log,
|
||||
}: {
|
||||
settings: UpdateSettings;
|
||||
getParentWindow: () => BrowserWindow | null;
|
||||
beforeInstall: () => Promise<void>;
|
||||
log: (...args: unknown[]) => void;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (!settings.apiUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const release = await fetchLatestRelease(settings.apiUrl);
|
||||
const update = buildReleaseUpdate(release, app.getVersion(), settings.assetPattern);
|
||||
|
||||
if (!update) {
|
||||
log("No Scoreko update available.");
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldDownload = await askToDownloadUpdate(
|
||||
update,
|
||||
settings.releasePageUrl ?? update.pageUrl,
|
||||
getParentWindow(),
|
||||
);
|
||||
if (!shouldDownload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const installerPath = await downloadInstaller(update);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function loadUpdateSettings(
|
||||
appConfig: AppRuntimeConfig,
|
||||
rootPath: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
): UpdateSettings {
|
||||
const fileConfig = readUpdateFileConfig(appConfig, rootPath, log);
|
||||
|
||||
return {
|
||||
enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)),
|
||||
apiUrl: appConfig.updateApiUrl ?? readOptionalString(fileConfig.apiUrl),
|
||||
releasePageUrl: appConfig.updateReleasePageUrl ?? readOptionalString(fileConfig.releasePageUrl),
|
||||
assetPattern:
|
||||
appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || "Scoreko-setup-.*\\.exe$",
|
||||
};
|
||||
}
|
||||
|
||||
function readUpdateFileConfig(
|
||||
appConfig: AppRuntimeConfig,
|
||||
rootPath: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
): UpdateFileConfig {
|
||||
const configPath = appConfig.updateConfigPathOverride ?? path.join(rootPath, "static", "updates.json");
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(configPath, "utf8")) as UpdateFileConfig;
|
||||
} catch (error) {
|
||||
log(`Could not read update config at ${configPath}.`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
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}.`);
|
||||
}
|
||||
|
||||
return (await response.json()) as GiteaRelease;
|
||||
}
|
||||
|
||||
async function askToDownloadUpdate(
|
||||
update: ReleaseUpdate,
|
||||
releasePageUrl: string | undefined,
|
||||
parentWindow: BrowserWindow | null,
|
||||
): Promise<boolean> {
|
||||
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) {
|
||||
await shell.openExternal(releasePageUrl);
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.response === 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function downloadInstaller(update: ReleaseUpdate): Promise<string> {
|
||||
const safeFileName = sanitizeFileName(update.installer.name);
|
||||
const downloadDirectory = path.join(app.getPath("temp"), "scoreko-updates");
|
||||
const targetPath = path.join(downloadDirectory, safeFileName);
|
||||
|
||||
fs.mkdirSync(downloadDirectory, { recursive: true });
|
||||
|
||||
const response = await fetch(update.installer.downloadUrl);
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Could not download update installer. HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const fileStream = fs.createWriteStream(targetPath);
|
||||
const responseStream = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
|
||||
responseStream.on("error", reject);
|
||||
fileStream.on("error", reject);
|
||||
fileStream.on("finish", resolve);
|
||||
responseStream.pipe(fileStream);
|
||||
});
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function showMessageBox(parentWindow: BrowserWindow | null, options: MessageBoxOptions) {
|
||||
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
export type GiteaReleaseAsset = {
|
||||
name?: unknown;
|
||||
browser_download_url?: unknown;
|
||||
size?: unknown;
|
||||
};
|
||||
|
||||
export type GiteaRelease = {
|
||||
tag_name?: unknown;
|
||||
name?: unknown;
|
||||
html_url?: unknown;
|
||||
assets?: unknown;
|
||||
};
|
||||
|
||||
export type InstallerAsset = {
|
||||
name: string;
|
||||
downloadUrl: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type ReleaseUpdate = {
|
||||
version: string;
|
||||
title: string;
|
||||
pageUrl?: string;
|
||||
installer: InstallerAsset;
|
||||
};
|
||||
|
||||
export type UpdateFileConfig = {
|
||||
enabled?: unknown;
|
||||
apiUrl?: unknown;
|
||||
releasePageUrl?: unknown;
|
||||
assetPattern?: unknown;
|
||||
};
|
||||
|
||||
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 getReleaseVersion(release: GiteaRelease): string | null {
|
||||
const tagName = typeof release.tag_name === "string" ? release.tag_name.trim() : "";
|
||||
return tagName.length > 0 ? tagName.replace(/^v/i, "") : null;
|
||||
}
|
||||
|
||||
export function getReleaseTitle(release: GiteaRelease, version: string): string {
|
||||
const releaseName = typeof release.name === "string" ? release.name.trim() : "";
|
||||
return releaseName.length > 0 ? releaseName : `Scoreko ${version}`;
|
||||
}
|
||||
|
||||
export function selectInstallerAsset(release: GiteaRelease, assetPattern: string): InstallerAsset | null {
|
||||
const assets = Array.isArray(release.assets) ? release.assets : [];
|
||||
const matcher = new RegExp(assetPattern, "i");
|
||||
|
||||
for (const asset of assets as GiteaReleaseAsset[]) {
|
||||
const name = typeof asset.name === "string" ? asset.name : "";
|
||||
const downloadUrl = typeof asset.browser_download_url === "string" ? asset.browser_download_url : "";
|
||||
|
||||
if (!name || !downloadUrl || !matcher.test(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
downloadUrl,
|
||||
...(typeof asset.size === "number" ? { size: asset.size } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildReleaseUpdate(
|
||||
release: GiteaRelease,
|
||||
currentVersion: string,
|
||||
assetPattern: string,
|
||||
): ReleaseUpdate | null {
|
||||
const version = getReleaseVersion(release);
|
||||
if (!version || !isVersionNewer(version, currentVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const installer = selectInstallerAsset(release, assetPattern);
|
||||
if (!installer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageUrl = typeof release.html_url === "string" && release.html_url.length > 0 ? release.html_url : undefined;
|
||||
|
||||
return {
|
||||
version,
|
||||
title: getReleaseTitle(release, version),
|
||||
pageUrl,
|
||||
installer,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeFileName(fileName: string): string {
|
||||
return fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_");
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -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 electronLog from "electron-log";
|
||||
|
||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
|
||||
import { resolveAppIconPath } from "./icon-path";
|
||||
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-security";
|
||||
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation";
|
||||
|
||||
type WindowFactoryDependencies = {
|
||||
type WindowServiceDependencies = {
|
||||
appConfig: AppRuntimeConfig;
|
||||
allowDevTools: boolean;
|
||||
rootPath: string;
|
||||
mainDashboardUrl: string;
|
||||
};
|
||||
|
||||
export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: WindowFactoryDependencies): BrowserWindow {
|
||||
const windowOptions = createWindowOptions({ appConfig, rootPath, isLoadingWindow: false });
|
||||
export function createMainWindow({
|
||||
allowDevTools,
|
||||
appConfig,
|
||||
rootPath,
|
||||
mainDashboardUrl,
|
||||
}: WindowServiceDependencies): BrowserWindow {
|
||||
const windowOptions = createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: false });
|
||||
const window = new BrowserWindow(windowOptions);
|
||||
|
||||
applySecurityPolicies(window, allowDevTools);
|
||||
window.setMenuBarVisibility(false);
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
@@ -25,6 +34,12 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind
|
||||
});
|
||||
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
@@ -44,10 +59,13 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind
|
||||
}
|
||||
|
||||
export function createLoadingWindow({
|
||||
allowDevTools,
|
||||
appConfig,
|
||||
rootPath,
|
||||
}: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
|
||||
const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true }));
|
||||
}: Omit<WindowServiceDependencies, "mainDashboardUrl">): BrowserWindow {
|
||||
const window = new BrowserWindow(createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: true }));
|
||||
|
||||
applySecurityPolicies(window, allowDevTools);
|
||||
|
||||
window.on("page-title-updated", (event) => {
|
||||
event.preventDefault();
|
||||
@@ -57,10 +75,12 @@ export function createLoadingWindow({
|
||||
}
|
||||
|
||||
function createWindowOptions({
|
||||
allowDevTools,
|
||||
appConfig,
|
||||
rootPath,
|
||||
isLoadingWindow,
|
||||
}: {
|
||||
allowDevTools: boolean;
|
||||
appConfig: AppRuntimeConfig;
|
||||
rootPath: string;
|
||||
isLoadingWindow: boolean;
|
||||
@@ -74,8 +94,10 @@ function createWindowOptions({
|
||||
backgroundColor: DEFAULT_WINDOW_BACKGROUND,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
devTools: allowDevTools,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
...(isLoadingWindow ? {} : { nodeIntegration: false }),
|
||||
webSecurity: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -100,3 +122,26 @@ function createWindowOptions({
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,7 +13,6 @@ function getBaseConfig(): AppRuntimeConfig {
|
||||
nodecgPort: "9090",
|
||||
bundleName: "scoreko-dev",
|
||||
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
|
||||
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
|
||||
loadDelayMs: 10000,
|
||||
startupTimeoutMs: 30000,
|
||||
nodecgKillTimeoutMs: 2500,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
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";
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
@@ -26,7 +26,6 @@ function getBaseConfig(): AppRuntimeConfig {
|
||||
nodecgPort: "9090",
|
||||
bundleName: "scoreko-dev",
|
||||
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
|
||||
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
|
||||
loadDelayMs: 10000,
|
||||
startupTimeoutMs: 100,
|
||||
nodecgKillTimeoutMs: 10,
|
||||
@@ -85,7 +84,7 @@ test("waitForNodeCGReady resolves when endpoint returns 404", async () => {
|
||||
deps: {
|
||||
platform: "linux",
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
|
||||
handler();
|
||||
@@ -118,7 +117,7 @@ test("stopNodeCG sends SIGTERM and then SIGKILL if the process does not exit", a
|
||||
deps: {
|
||||
platform: "linux",
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: (pid, signal) => {
|
||||
killSignals.push({ pid, signal });
|
||||
@@ -163,7 +162,7 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
|
||||
log: () => undefined,
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: () => undefined,
|
||||
setTimer: () => 0,
|
||||
@@ -184,6 +183,48 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
|
||||
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 () => {
|
||||
const child = new MockChildProcess(7777);
|
||||
const timeouts: number[] = [];
|
||||
@@ -199,7 +240,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
|
||||
log: () => undefined,
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
|
||||
killProcess: () => undefined,
|
||||
setTimer: (handler, timeoutMs) => {
|
||||
@@ -264,7 +305,7 @@ test("startNodeCG spawns Electron directly on Windows", async () => {
|
||||
capturedCommand = command;
|
||||
capturedArgs = args;
|
||||
capturedOptions.push(options);
|
||||
return child as unknown as import("node:child_process").ChildProcess;
|
||||
return child;
|
||||
},
|
||||
stdoutWrite: () => undefined,
|
||||
stderrWrite: () => undefined,
|
||||
@@ -291,7 +332,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
|
||||
deps: {
|
||||
pathExists: () => true,
|
||||
platform: "linux",
|
||||
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess,
|
||||
spawnProcess: () => child,
|
||||
fetchUrl: async () => {
|
||||
child.emit("exit", 1, null);
|
||||
throw new Error("still starting");
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
getEnv,
|
||||
getOptionalEnv,
|
||||
parseEnvBool,
|
||||
parseEnvInt,
|
||||
parseEnvIntInRange,
|
||||
parseEnvPort,
|
||||
parseOptionalHttpUrl,
|
||||
loadEnvFile,
|
||||
getRuntimeConfig,
|
||||
getRequiredEnv,
|
||||
parseRequiredEnvIntInRange,
|
||||
parseRequiredEnvBool,
|
||||
parseRequiredEnvPort,
|
||||
} from "../main/config/runtime-config";
|
||||
|
||||
function withEnv(name: string, value: string | undefined, run: () => void): void {
|
||||
@@ -32,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", () => {
|
||||
withEnv("TEST_OPTIONAL_ENV", undefined, () => {
|
||||
assert.equal(getOptionalEnv("TEST_OPTIONAL_ENV"), undefined);
|
||||
@@ -56,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", () => {
|
||||
withEnv("TEST_ENV_INT_RANGE", "999", () => {
|
||||
assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /must be an integer/);
|
||||
@@ -119,3 +137,123 @@ test("parseOptionalHttpUrl rejects unsupported protocols", () => {
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { prepareUserNodecgRuntime } from "../main/nodecg/runtime-provisioner";
|
||||
import { prepareUserNodecgRuntime } from "../main/nodecg/runtime-setup";
|
||||
|
||||
type FakeFsState = {
|
||||
paths: Set<string>;
|
||||
@@ -38,10 +38,21 @@ function createFakeFs(initialPaths: string[] = [], initialFiles: Record<string,
|
||||
cpSync: (from: string, to: string) => {
|
||||
state.copied.push({ from: path.normalize(from), to: path.normalize(to) });
|
||||
state.paths.add(path.normalize(to));
|
||||
state.paths.add(path.join(path.normalize(to), "index.js"));
|
||||
state.paths.add(path.join(path.normalize(to), "package.json"));
|
||||
state.paths.add(path.join(path.normalize(to), "node_modules", "nodecg", "dist", "server", "bootstrap.js"));
|
||||
state.paths.add(path.join(path.normalize(to), "bundles", "scoreko-dev", "package.json"));
|
||||
},
|
||||
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) => {
|
||||
@@ -57,7 +68,9 @@ function getSourcePaths(source: string) {
|
||||
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"),
|
||||
];
|
||||
@@ -81,7 +94,7 @@ test("prepareUserNodecgRuntime copies the packaged runtime into userData", () =>
|
||||
|
||||
assert.equal(preparedRuntime.runtimePath, path.join(userData, "nodecg"));
|
||||
assert.equal(preparedRuntime.installed, true);
|
||||
assert.equal(state.copied.length, 1);
|
||||
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")));
|
||||
@@ -92,7 +105,7 @@ test("prepareUserNodecgRuntime keeps an up-to-date runtime in place", () => {
|
||||
const source = path.normalize("/app/lib/nodecg");
|
||||
const userData = path.normalize("/user/scoreko");
|
||||
const target = path.join(userData, "nodecg");
|
||||
const sourceManifest = { bundleVersion: "0.1.0", nodecgVersion: "2.6.4" };
|
||||
const 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(
|
||||
[
|
||||
@@ -125,7 +138,7 @@ test("prepareUserNodecgRuntime refreshes managed files when the app version chan
|
||||
const source = path.normalize("/app/lib/nodecg");
|
||||
const userData = path.normalize("/user/scoreko");
|
||||
const target = path.join(userData, "nodecg");
|
||||
const sourceManifest = { bundleVersion: "0.1.0", nodecgVersion: "2.6.4" };
|
||||
const 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(
|
||||
[
|
||||
@@ -150,8 +163,47 @@ test("prepareUserNodecgRuntime refreshes managed files when the app version chan
|
||||
});
|
||||
|
||||
assert.equal(preparedRuntime.installed, true);
|
||||
assert.equal(state.copied.length, 1);
|
||||
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")));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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$",
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -4,9 +4,10 @@ import test from "node:test";
|
||||
import {
|
||||
buildReleaseUpdate,
|
||||
isVersionNewer,
|
||||
parseGiteaRelease,
|
||||
sanitizeFileName,
|
||||
selectInstallerAsset,
|
||||
} from "../main/updates/update-utils";
|
||||
} from "../main/updates/update-schema";
|
||||
|
||||
test("isVersionNewer compares semantic versions with optional v prefix", () => {
|
||||
assert.equal(isVersionNewer("v0.2.0", "0.1.9"), true);
|
||||
@@ -17,9 +18,10 @@ test("isVersionNewer compares semantic versions with optional v prefix", () => {
|
||||
test("selectInstallerAsset picks the first matching exe asset", () => {
|
||||
const asset = selectInstallerAsset(
|
||||
{
|
||||
tagName: "v0.2.0",
|
||||
assets: [
|
||||
{ name: "latest.yml", browser_download_url: "http://gitea/latest.yml" },
|
||||
{ name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 },
|
||||
{ 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$",
|
||||
@@ -35,8 +37,8 @@ test("selectInstallerAsset picks the first matching exe asset", () => {
|
||||
test("buildReleaseUpdate returns null when the release is not newer", () => {
|
||||
const update = buildReleaseUpdate(
|
||||
{
|
||||
tag_name: "v0.1.0",
|
||||
assets: [{ name: "Scoreko-setup-0.1.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.1.0.exe" }],
|
||||
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$",
|
||||
@@ -48,10 +50,10 @@ test("buildReleaseUpdate returns null when the release is not newer", () => {
|
||||
test("buildReleaseUpdate builds update info for newer releases", () => {
|
||||
const update = buildReleaseUpdate(
|
||||
{
|
||||
tag_name: "v0.2.0",
|
||||
name: "Scoreko 0.2.0",
|
||||
html_url: "http://gitea/releases/v0.2.0",
|
||||
assets: [{ name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe" }],
|
||||
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$",
|
||||
@@ -66,3 +68,22 @@ test("buildReleaseUpdate builds update info for newer releases", () => {
|
||||
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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
!macro customHeader
|
||||
ShowInstDetails hide
|
||||
ShowUninstDetails hide
|
||||
!macroend
|
||||
@@ -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>
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"apiUrl": "http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest",
|
||||
"releasePageUrl": "http://gitea.local/OWNER/REPO/releases",
|
||||
"assetPattern": "Scoreko-setup-.*\\.exe$"
|
||||
}
|
||||
Reference in New Issue
Block a user