mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-06 05:32:06 +00:00
Compare commits
11 Commits
707038a22c
...
865c3589bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 865c3589bd | |||
| c8e2edc0c0 | |||
| 54ab1fcb9f | |||
| 2e1d3a170c | |||
| e3d3936156 | |||
| c168c3b84a | |||
| 67f3e60953 | |||
| d4dd77151c | |||
| fbc709463f | |||
| 955a1f7116 | |||
| 41e4e91c4b |
@@ -14,3 +14,11 @@ SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true
|
|||||||
ELECTRON_LOAD_DELAY_MS=10000
|
ELECTRON_LOAD_DELAY_MS=10000
|
||||||
NODECG_STARTUP_TIMEOUT_MS=30000
|
NODECG_STARTUP_TIMEOUT_MS=30000
|
||||||
NODECG_KILL_TIMEOUT_MS=2500
|
NODECG_KILL_TIMEOUT_MS=2500
|
||||||
|
|
||||||
|
# Updates
|
||||||
|
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
|
||||||
|
|||||||
@@ -2,3 +2,8 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
release
|
release
|
||||||
lib
|
lib
|
||||||
|
.corepack
|
||||||
|
.electron-cache
|
||||||
|
.localappdata
|
||||||
|
.npm-cache
|
||||||
|
.npm-runtime-cache
|
||||||
|
|||||||
@@ -3,3 +3,8 @@ release
|
|||||||
lib/nodecg
|
lib/nodecg
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
.corepack
|
||||||
|
.electron-cache
|
||||||
|
.localappdata
|
||||||
|
.npm-cache
|
||||||
|
.npm-runtime-cache
|
||||||
|
|||||||
@@ -1,53 +1,68 @@
|
|||||||
# scoreko-electron
|
# scoreko-electron
|
||||||
|
|
||||||
Desktop app (Electron + TypeScript) to run and package a NodeCG installation with the `scoreko-dev` bundle.
|
Windows desktop installer for Scoreko. The packaged app includes Electron, NodeCG, the compiled `scoreko-dev` bundle, and the production modules needed to run it, so end users do not need Node.js, pnpm, or a cloned repository.
|
||||||
|
|
||||||
## Requirements
|
## Build on a development machine
|
||||||
|
|
||||||
- Node.js `>=22`
|
From the repository root:
|
||||||
- Dependencies installed with `npm install`
|
|
||||||
|
|
||||||
## Available scripts
|
```powershell
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
### Development
|
Then from `scoreko-electron-dev`:
|
||||||
|
|
||||||
- `npm run dev`: compiles in watch mode and opens Electron.
|
```powershell
|
||||||
- `npm run watch`: TypeScript watch mode.
|
npm install
|
||||||
- `npm run dev:electron`: opens Electron when `dist/main/main.js` is ready.
|
npm run dist:win
|
||||||
- `npm run start`: full build and local run.
|
```
|
||||||
|
|
||||||
### Build and distribution
|
The installer is written to `scoreko-electron-dev/release/Scoreko-setup-0.1.0.exe`.
|
||||||
|
|
||||||
- `npm run clean`: removes `dist` and `release`.
|
## What the build does
|
||||||
- `npm run typecheck`: validates types without emitting files.
|
|
||||||
- `npm run build`: compiles TypeScript and copies assets.
|
|
||||||
- `npm run pack`: generates the app without an installer (`electron-builder --dir`).
|
|
||||||
- `npm run dist:win`: builds a Windows installer.
|
|
||||||
- `npm run dist:linux`: builds a Linux AppImage.
|
|
||||||
- `npm run dist:mac`: builds a macOS package.
|
|
||||||
- `npm run dist:all`: builds artifacts for Windows, Linux, and macOS.
|
|
||||||
|
|
||||||
### Quality and diagnostics
|
- Builds the parent `scoreko-dev` bundle with `pnpm build`.
|
||||||
|
- Creates `scoreko-electron-dev/lib/nodecg` with a small NodeCG runtime.
|
||||||
|
- Installs production runtime modules into that runtime.
|
||||||
|
- Rebuilds `better-sqlite3` for Electron before creating the installer.
|
||||||
|
- Packages the runtime as an Electron extra resource outside the app archive.
|
||||||
|
|
||||||
- `npm run test`: build and tests (`node:test`).
|
## Runtime behavior
|
||||||
- `npm run doctor`: environment/configuration diagnostics.
|
|
||||||
- `npm run lint`: lint with ESLint.
|
|
||||||
- `npm run lint:fix`: lint with auto-fix.
|
|
||||||
- `npm run format`: checks formatting with Prettier.
|
|
||||||
- `npm run format:write`: applies formatting with Prettier.
|
|
||||||
|
|
||||||
### Native modules
|
On first launch, Scoreko copies the packaged NodeCG runtime to the user's app data folder and 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.
|
||||||
|
|
||||||
- `npm run rebuild:native`: rebuilds NodeCG native modules.
|
## Useful scripts
|
||||||
- `npm run rebuild:better-sqlite3`: rebuilds only `better-sqlite3` for Electron.
|
|
||||||
|
|
||||||
## Quick setup
|
- `npm run start`: build everything and run Electron locally.
|
||||||
|
- `npm run prepare:runtime`: recreate `lib/nodecg` from the parent bundle.
|
||||||
|
- `npm run rebuild:native`: rebuild NodeCG native modules for Electron.
|
||||||
|
- `npm run dist:win`: create the Windows installer.
|
||||||
|
- `npm run doctor`: check the prepared runtime and the configured port.
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env`.
|
## Updates from Gitea
|
||||||
2. Adjust variables for your environment.
|
|
||||||
3. Run `npm run doctor` before developing or packaging.
|
|
||||||
|
|
||||||
## References
|
Scoreko can check a Gitea release feed without forcing the user to update. Edit `static/updates.json` before building:
|
||||||
|
|
||||||
- Troubleshooting: `docs/troubleshooting.md`
|
```json
|
||||||
- Architecture: `docs/architecture.md`
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"apiUrl": "http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest",
|
||||||
|
"releasePageUrl": "http://gitea.local/OWNER/REPO/releases",
|
||||||
|
"assetPattern": "Scoreko-setup-.*\\.exe$"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The defaults match the parent 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.
|
||||||
|
|||||||
+10
-4
@@ -3,15 +3,21 @@
|
|||||||
## Startup flow
|
## Startup flow
|
||||||
|
|
||||||
1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`.
|
1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`.
|
||||||
2. Creates windows (`windows/window-factory.ts`).
|
2. Installs or refreshes the packaged NodeCG runtime in user data when needed (`nodecg/runtime-provisioner.ts`).
|
||||||
3. Starts NodeCG with `nodecg/process-manager.ts`.
|
3. Creates windows (`windows/window-factory.ts`).
|
||||||
4. Waits for HTTP readiness and shows loading -> main dashboard.
|
4. In packaged builds, relaunches once after a fresh runtime install so NodeCG starts from a settled user-data runtime.
|
||||||
5. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
|
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.
|
||||||
|
|
||||||
## Main modules
|
## Main modules
|
||||||
|
|
||||||
- `config/runtime-config.ts`: read/validate env vars.
|
- `config/runtime-config.ts`: read/validate env vars.
|
||||||
|
- `nodecg/runtime-provisioner.ts`: install/refresh the managed runtime in the writable user data folder and report whether it changed.
|
||||||
- `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation.
|
- `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation.
|
||||||
|
- `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/window-factory.ts`: window creation and navigation policy.
|
||||||
- `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes.
|
- `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes.
|
||||||
- `errors/error-presenter.ts`: fatal error presentation.
|
- `errors/error-presenter.ts`: fatal error presentation.
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Architecture Audit
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This audit documents the current architecture of the Electron wrapper and the refactor boundaries agreed for future work. It is based on the previous architectural analysis and should be treated as the source of truth for refactor sessions.
|
||||||
|
|
||||||
|
The project is not being redesigned from scratch. The goal is to preserve the current working model, reduce lifecycle risk, and make Electron startup, NodeCG process management, updates, and security easier to test and maintain.
|
||||||
|
|
||||||
|
## Current Mental Model
|
||||||
|
|
||||||
|
The application is an Electron wrapper that lives almost entirely in the main process. There is no custom renderer, no preload script, and no meaningful IPC layer.
|
||||||
|
|
||||||
|
Electron starts a local NodeCG runtime and loads HTTP dashboards from `localhost` or `127.0.0.1`. The main process owns startup, provisioning, window creation, navigation policy, update checks, and shutdown.
|
||||||
|
|
||||||
|
## Existing Strengths
|
||||||
|
|
||||||
|
- The project already uses TypeScript strictness and has passing tests.
|
||||||
|
- NodeCG process handling is encapsulated in a dedicated module.
|
||||||
|
- Runtime provisioning preserves user-owned directories: `cfg`, `db`, and `logs`.
|
||||||
|
- Navigation security is separated from window creation.
|
||||||
|
- Packaging has a clear dependency on `lib/nodecg` as an Electron resource.
|
||||||
|
- There is no accidental IPC surface.
|
||||||
|
- Browser security defaults are mostly strong: `nodeIntegration: false`, `contextIsolation: true`, and `sandbox: true`.
|
||||||
|
|
||||||
|
## Current Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/
|
||||||
|
main.ts
|
||||||
|
nodecg/
|
||||||
|
process-manager.ts
|
||||||
|
runtime-provisioner.ts
|
||||||
|
windows/
|
||||||
|
window-factory.ts
|
||||||
|
navigation-security.ts
|
||||||
|
updates/
|
||||||
|
update-manager.ts
|
||||||
|
scripts/
|
||||||
|
build-scoreko-bundle.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Main Process Responsibilities
|
||||||
|
|
||||||
|
`src/main/main.ts` currently performs several roles:
|
||||||
|
|
||||||
|
- Configures Electron app metadata and paths.
|
||||||
|
- Sets `userData`.
|
||||||
|
- Acquires the single-instance lock.
|
||||||
|
- Prepares the managed NodeCG runtime.
|
||||||
|
- Relaunches after first runtime installation.
|
||||||
|
- Creates loading and main windows.
|
||||||
|
- Starts NodeCG.
|
||||||
|
- Waits for HTTP readiness.
|
||||||
|
- Loads dashboards.
|
||||||
|
- Schedules updates.
|
||||||
|
- Handles activation and shutdown.
|
||||||
|
|
||||||
|
This is not unmanageable, but it is the main coupling point and should be split carefully.
|
||||||
|
|
||||||
|
## NodeCG Runtime
|
||||||
|
|
||||||
|
The current runtime strategy should be preserved:
|
||||||
|
|
||||||
|
- Install the managed NodeCG runtime under Electron `userData`.
|
||||||
|
- Preserve user data directories across managed runtime replacement.
|
||||||
|
- Run NodeCG through the Electron binary using `ELECTRON_RUN_AS_NODE`.
|
||||||
|
- Wait for the local HTTP endpoint before loading dashboards.
|
||||||
|
- Stop the process tree on shutdown.
|
||||||
|
|
||||||
|
This model is central to the product and should not be replaced during the refactor.
|
||||||
|
|
||||||
|
## Lifecycle Risks
|
||||||
|
|
||||||
|
### Import-Time Side Effects
|
||||||
|
|
||||||
|
`main.ts` performs work at import time, including app configuration, path setup, lock acquisition, URL calculation, and global state initialization.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- Startup behavior is harder to test without real Electron.
|
||||||
|
- Unit tests must work around global mutable state.
|
||||||
|
- Future lifecycle changes are more likely to produce hidden side effects.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- Move side-effect-free calculation into pure functions.
|
||||||
|
- Keep Electron side effects inside the entrypoint or an explicit bootstrap layer.
|
||||||
|
|
||||||
|
### Mixed Orchestration
|
||||||
|
|
||||||
|
`main.ts` combines orchestration with concrete runtime, window, updater, delay, and shutdown behavior.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- Adding lifecycle features increases coupling.
|
||||||
|
- Startup and shutdown behavior is hard to cover end to end.
|
||||||
|
- Error paths become difficult to reason about.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- Extract a small `ApplicationController`.
|
||||||
|
- Give it explicit lifecycle states.
|
||||||
|
- Keep behavior equivalent before moving folders.
|
||||||
|
|
||||||
|
### macOS Activation
|
||||||
|
|
||||||
|
The `activate` flow can recreate the main window and load the dashboard without guaranteeing NodeCG is alive and ready.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- On macOS, restoring the app after all windows are closed can race with NodeCG readiness.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- Route activation through the same readiness-aware controller used by initial startup.
|
||||||
|
|
||||||
|
## Process Management Risks
|
||||||
|
|
||||||
|
NodeCG process management is generally well isolated, but process tree shutdown is a sensitive boundary.
|
||||||
|
|
||||||
|
Current concern:
|
||||||
|
|
||||||
|
- Windows shutdown uses `taskkill` with `shell: true`.
|
||||||
|
- The PID is numeric, so command injection risk is low, but platform process control should be isolated and tested.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- Move Windows and POSIX process termination into a platform adapter.
|
||||||
|
- Keep process manager focused on NodeCG lifecycle.
|
||||||
|
- Test process-kill command construction separately.
|
||||||
|
|
||||||
|
## Updater Risks
|
||||||
|
|
||||||
|
The updater is the clearest controlled rewrite candidate.
|
||||||
|
|
||||||
|
Current concerns:
|
||||||
|
|
||||||
|
- Remote JSON is trusted without strong schema validation.
|
||||||
|
- Asset URLs need stricter protocol and host validation.
|
||||||
|
- Installer integrity is not verified.
|
||||||
|
- Download behavior should use safe temporary paths and atomic finalization.
|
||||||
|
- User-facing Spanish text contains visible encoding corruption.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- Rewrite the updater in small modules.
|
||||||
|
- Validate remote update metadata before use.
|
||||||
|
- Validate download URLs.
|
||||||
|
- Keep dialogs and installation side effects separate from fetch and download logic.
|
||||||
|
- Fix encoding as part of the updater cleanup.
|
||||||
|
|
||||||
|
## Electron Security Audit
|
||||||
|
|
||||||
|
Current good defaults:
|
||||||
|
|
||||||
|
- `nodeIntegration: false`
|
||||||
|
- `contextIsolation: true`
|
||||||
|
- `sandbox: true`
|
||||||
|
- No preload script
|
||||||
|
- No exposed IPC bridge
|
||||||
|
|
||||||
|
Security gaps to address:
|
||||||
|
|
||||||
|
- No explicit permission handler.
|
||||||
|
- No explicit devtools policy by environment.
|
||||||
|
- `webSecurity` should be explicitly set.
|
||||||
|
- Navigation should remain restricted to allowed local origins.
|
||||||
|
- CSP is constrained by NodeCG, but should be reviewed where feasible.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- Keep the browser surface minimal.
|
||||||
|
- Do not add preload or IPC unless a clear product need appears.
|
||||||
|
- Harden Electron defaults explicitly even when current defaults already behave safely.
|
||||||
|
|
||||||
|
## Script And Packaging Risks
|
||||||
|
|
||||||
|
The build scripts depend on the parent repository layout.
|
||||||
|
|
||||||
|
Risk:
|
||||||
|
|
||||||
|
- CI or external workspaces may fail if the expected sibling project is missing.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- Do not introduce a larger build framework.
|
||||||
|
- Normalize path validation and shared constants.
|
||||||
|
- Make script assumptions explicit and fail with clear errors.
|
||||||
|
|
||||||
|
## Refactor Boundary
|
||||||
|
|
||||||
|
Allowed controlled rewrites:
|
||||||
|
|
||||||
|
- Updater internals.
|
||||||
|
- Application lifecycle controller.
|
||||||
|
- Platform process termination adapter.
|
||||||
|
- Path and URL calculation helpers.
|
||||||
|
|
||||||
|
Not allowed:
|
||||||
|
|
||||||
|
- Replacing the Electron plus NodeCG runtime model.
|
||||||
|
- Adding a renderer architecture without a product requirement.
|
||||||
|
- Adding IPC only for architectural symmetry.
|
||||||
|
- Moving folders before behavior is protected by tests.
|
||||||
|
- Introducing broad framework abstractions.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The project has a healthier base than a typical Electron wrapper at this stage. The refactor should make the main process boring, explicit, and testable while preserving the current NodeCG runtime model.
|
||||||
|
|
||||||
|
The target is not an enterprise architecture. The target is a small Electron shell with clear lifecycle ownership, hardened update and process boundaries, and minimal browser privileges.
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Architecture Rules
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
These rules define the constraints for future refactor sessions. They are intentionally practical: every rule should either protect behavior, reduce Electron risk, or keep the codebase easier to test.
|
||||||
|
|
||||||
|
## Core Rules
|
||||||
|
|
||||||
|
### Preserve The Product Model
|
||||||
|
|
||||||
|
- The app remains an Electron main-process wrapper around a local NodeCG runtime.
|
||||||
|
- NodeCG continues to be launched locally.
|
||||||
|
- Dashboards continue to load from approved local HTTP origins.
|
||||||
|
- The managed runtime continues to live under Electron `userData`.
|
||||||
|
- Runtime provisioning must preserve `cfg`, `db`, and `logs`.
|
||||||
|
|
||||||
|
### Avoid Unnecessary Architecture
|
||||||
|
|
||||||
|
- Do not add a renderer architecture unless a user-facing feature requires it.
|
||||||
|
- Do not add a preload script unless desktop APIs must be exposed to web content.
|
||||||
|
- Do not add IPC for organizational neatness.
|
||||||
|
- Do not introduce broad frameworks for lifecycle, dependency injection, logging, or configuration.
|
||||||
|
- Add an abstraction only when it removes real complexity or isolates a risky boundary.
|
||||||
|
|
||||||
|
### Keep Behavior Stable
|
||||||
|
|
||||||
|
- Preserve current behavior before reorganizing files.
|
||||||
|
- Write tests around existing behavior before extracting lifecycle code.
|
||||||
|
- Move files separately from behavior changes.
|
||||||
|
- Run typecheck, tests, and lint after meaningful refactor steps.
|
||||||
|
|
||||||
|
## TypeScript Rules
|
||||||
|
|
||||||
|
- Do not use `any`.
|
||||||
|
- Prefer explicit domain types at module boundaries.
|
||||||
|
- Validate unknown external input before narrowing.
|
||||||
|
- Keep pure functions pure.
|
||||||
|
- Prefer narrow interfaces over large service objects.
|
||||||
|
- Avoid global mutable state outside bootstrap or explicit controllers.
|
||||||
|
|
||||||
|
## Electron Rules
|
||||||
|
|
||||||
|
### BrowserWindow Defaults
|
||||||
|
|
||||||
|
Every application window must use secure defaults:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nodeIntegration: false
|
||||||
|
contextIsolation: true
|
||||||
|
sandbox: true
|
||||||
|
webSecurity: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional rules:
|
||||||
|
|
||||||
|
- Devtools availability must be controlled by environment or explicit config.
|
||||||
|
- Permission requests must be denied by default.
|
||||||
|
- New-window behavior must be blocked unless explicitly allowed.
|
||||||
|
- Navigation must be allowlisted.
|
||||||
|
- Remote content must not gain Node.js access.
|
||||||
|
|
||||||
|
### Preload Rules
|
||||||
|
|
||||||
|
Current decision:
|
||||||
|
|
||||||
|
- No preload script is required.
|
||||||
|
|
||||||
|
If a preload becomes necessary:
|
||||||
|
|
||||||
|
- Keep it minimal.
|
||||||
|
- Expose APIs only through `contextBridge`.
|
||||||
|
- Do not expose raw `ipcRenderer`.
|
||||||
|
- Do not include business logic in preload.
|
||||||
|
- Validate all payloads crossing the boundary.
|
||||||
|
- Treat preload as part of the security boundary, not as a convenience layer.
|
||||||
|
|
||||||
|
### Renderer Rules
|
||||||
|
|
||||||
|
Current decision:
|
||||||
|
|
||||||
|
- There is no custom renderer.
|
||||||
|
|
||||||
|
If a renderer is added later:
|
||||||
|
|
||||||
|
- It must not assume Node.js access.
|
||||||
|
- It must communicate through typed, validated IPC only.
|
||||||
|
- It must not own NodeCG process lifecycle.
|
||||||
|
- It must not bypass navigation or permission policies.
|
||||||
|
|
||||||
|
## IPC Rules
|
||||||
|
|
||||||
|
Current decision:
|
||||||
|
|
||||||
|
- No IPC layer is needed.
|
||||||
|
|
||||||
|
If IPC becomes necessary:
|
||||||
|
|
||||||
|
- Define all channel names in one module.
|
||||||
|
- Use explicit request and response types.
|
||||||
|
- Validate every payload at runtime.
|
||||||
|
- Use allowlisted handlers only.
|
||||||
|
- Never expose filesystem, process, shell, or update primitives directly.
|
||||||
|
- Never expose raw Electron APIs to web content.
|
||||||
|
- Keep handlers small and delegate to tested services.
|
||||||
|
- Return structured errors instead of throwing raw implementation details across IPC.
|
||||||
|
|
||||||
|
Example target shape:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/ipc/
|
||||||
|
channels.ts
|
||||||
|
register-handlers.ts
|
||||||
|
validators.ts
|
||||||
|
src/shared/ipc/
|
||||||
|
types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not create this structure until IPC is genuinely needed.
|
||||||
|
|
||||||
|
## NodeCG Runtime Rules
|
||||||
|
|
||||||
|
- Keep NodeCG process ownership in the main process.
|
||||||
|
- Launch NodeCG with `ELECTRON_RUN_AS_NODE`.
|
||||||
|
- Validate the runtime installation before launching it.
|
||||||
|
- Wait for HTTP readiness before loading dashboards.
|
||||||
|
- Treat process stdout and stderr as diagnostic information only.
|
||||||
|
- Stop the full process tree on app shutdown.
|
||||||
|
- Keep platform process termination behind an adapter.
|
||||||
|
|
||||||
|
## Filesystem Rules
|
||||||
|
|
||||||
|
- Filesystem behavior must live behind domain modules.
|
||||||
|
- Runtime provisioning must never delete user-owned `cfg`, `db`, or `logs`.
|
||||||
|
- Downloads must stay inside safe temporary directories.
|
||||||
|
- Paths from config, remote metadata, or user-controlled sources must be validated before use.
|
||||||
|
- Avoid scattering path construction across unrelated modules.
|
||||||
|
|
||||||
|
## Update Rules
|
||||||
|
|
||||||
|
- Treat remote update metadata as untrusted.
|
||||||
|
- Validate update JSON with a runtime schema.
|
||||||
|
- Validate asset URLs before download.
|
||||||
|
- Prefer `https:` URLs for production updates.
|
||||||
|
- Fail closed when metadata is malformed.
|
||||||
|
- Download to a safe temporary file.
|
||||||
|
- Finalize downloads atomically.
|
||||||
|
- Keep fetch, validation, download, dialog, and install steps separate.
|
||||||
|
- Fix user-facing encoding issues when touching updater text.
|
||||||
|
- Do not execute downloaded installers unless validation has succeeded.
|
||||||
|
|
||||||
|
## Navigation Rules
|
||||||
|
|
||||||
|
- Allow only expected NodeCG dashboard origins.
|
||||||
|
- Prefer explicit URL parsing over string prefix checks.
|
||||||
|
- Block external navigation by default.
|
||||||
|
- Block unexpected new-window attempts.
|
||||||
|
- Keep navigation policy testable as pure logic where possible.
|
||||||
|
|
||||||
|
## Configuration Rules
|
||||||
|
|
||||||
|
- Parse configuration once.
|
||||||
|
- Keep configuration access centralized.
|
||||||
|
- Avoid reading environment variables throughout the codebase.
|
||||||
|
- Keep runtime defaults explicit.
|
||||||
|
- Ensure build scripts and app runtime agree on shared constants where appropriate.
|
||||||
|
|
||||||
|
## Process Rules
|
||||||
|
|
||||||
|
- Child process management must sit behind a small interface.
|
||||||
|
- Platform-specific kill behavior must be isolated.
|
||||||
|
- Windows process termination must validate numeric PIDs before command construction.
|
||||||
|
- Shutdown must be idempotent.
|
||||||
|
- Repeated quit events must not trigger duplicate process cleanup.
|
||||||
|
|
||||||
|
## Testing Rules
|
||||||
|
|
||||||
|
- Test pure path and URL functions directly.
|
||||||
|
- Test lifecycle states without launching real Electron where possible.
|
||||||
|
- Test updater validation with malformed metadata.
|
||||||
|
- Test navigation allow and block cases.
|
||||||
|
- Test process shutdown edge cases.
|
||||||
|
- Add integration-style coverage only where unit tests cannot represent the Electron behavior.
|
||||||
|
|
||||||
|
## Refactor Rules
|
||||||
|
|
||||||
|
- Do not refactor unrelated modules in the same change.
|
||||||
|
- Do not change formatting across the repository unless requested.
|
||||||
|
- Do not move folders and change behavior in the same step.
|
||||||
|
- Prefer small commits or small reviewable patches.
|
||||||
|
- Leave existing passing tests intact unless the product behavior intentionally changes.
|
||||||
|
|
||||||
|
## Security Baseline
|
||||||
|
|
||||||
|
The secure baseline is:
|
||||||
|
|
||||||
|
- No Node.js in web content.
|
||||||
|
- No preload unless needed.
|
||||||
|
- No IPC unless needed.
|
||||||
|
- Local navigation only.
|
||||||
|
- Deny permissions by default.
|
||||||
|
- Validate remote update data.
|
||||||
|
- Validate downloaded update assets.
|
||||||
|
- Keep process and filesystem access in main-process services only.
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
# Migration Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Refactor the Electron main-process architecture without changing product behavior. The plan is sequential and should be followed in order so future sessions can make progress without reinterpreting the architecture.
|
||||||
|
|
||||||
|
## Migration Principles
|
||||||
|
|
||||||
|
- Preserve behavior before moving structure.
|
||||||
|
- Add tests around risky lifecycle behavior before refactoring it.
|
||||||
|
- Keep the NodeCG runtime model intact.
|
||||||
|
- Keep Electron minimal.
|
||||||
|
- Avoid introducing IPC, preload, or renderer code unless required by a concrete feature.
|
||||||
|
- Prefer small pure functions for paths, URLs, update metadata, asset selection, and navigation policy.
|
||||||
|
- Rewrite only the degraded areas: updater, lifecycle orchestration, and platform process shutdown.
|
||||||
|
|
||||||
|
## Phase 1: Freeze Existing Behavior
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Protect current startup, shutdown, provisioning, navigation, and update behavior before extracting modules.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- Add tests for first-run runtime provisioning and relaunch behavior.
|
||||||
|
- Add tests for loading window and main window ordering.
|
||||||
|
- Add tests for update scheduling behavior.
|
||||||
|
- Add tests for shutdown when NodeCG is running.
|
||||||
|
- Add tests for shutdown when NodeCG is already stopped.
|
||||||
|
- Add tests for double quit or repeated shutdown calls.
|
||||||
|
- Add tests for macOS-style activation after windows are closed.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Existing tests continue to pass.
|
||||||
|
- New tests describe current behavior, not the desired future behavior.
|
||||||
|
- No folder reorganization occurs in this phase.
|
||||||
|
|
||||||
|
## Phase 2: Extract Pure Path And URL Logic
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Remove deterministic calculations from `main.ts` while keeping Electron side effects in the entrypoint.
|
||||||
|
|
||||||
|
Target files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/app/paths.ts
|
||||||
|
src/main/config/runtime-config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- Extract `userData` path calculation.
|
||||||
|
- Extract managed runtime path calculation.
|
||||||
|
- Extract NodeCG dashboard URL construction.
|
||||||
|
- Extract runtime configuration parsing.
|
||||||
|
- Keep Electron `app.setPath`, `app.setName`, and lock handling in bootstrap code.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Extracted functions are pure.
|
||||||
|
- Tests cover path and URL edge cases.
|
||||||
|
- No Electron app object is required to test the extracted logic.
|
||||||
|
|
||||||
|
## Phase 3: Introduce ApplicationController
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Make lifecycle explicit without redesigning the app.
|
||||||
|
|
||||||
|
Target file:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/app/application-controller.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Required states:
|
||||||
|
|
||||||
|
```text
|
||||||
|
idle
|
||||||
|
preparing
|
||||||
|
starting
|
||||||
|
ready
|
||||||
|
stopping
|
||||||
|
stopped
|
||||||
|
failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Prepare runtime.
|
||||||
|
- Start NodeCG.
|
||||||
|
- Wait for readiness.
|
||||||
|
- Create or reuse windows through a window service.
|
||||||
|
- Load dashboards only after NodeCG readiness.
|
||||||
|
- Schedule update checks.
|
||||||
|
- Handle activation safely.
|
||||||
|
- Handle shutdown idempotently.
|
||||||
|
|
||||||
|
Non-responsibilities:
|
||||||
|
|
||||||
|
- Direct process-kill command construction.
|
||||||
|
- Direct update metadata parsing.
|
||||||
|
- Direct Electron `BrowserWindow` option construction.
|
||||||
|
- Business logic inside preload or renderer code.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `main.ts` becomes a thin bootstrap.
|
||||||
|
- Activation uses readiness-aware startup behavior.
|
||||||
|
- Shutdown is idempotent.
|
||||||
|
- Tests cover state transitions and failure paths.
|
||||||
|
|
||||||
|
## Phase 4: Extract Shutdown Service
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Make shutdown behavior predictable and easy to test.
|
||||||
|
|
||||||
|
Target files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/app/shutdown-service.ts
|
||||||
|
src/main/nodecg/nodecg-process-service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- Centralize app shutdown sequencing.
|
||||||
|
- Ensure NodeCG is stopped before app exit completes.
|
||||||
|
- Handle repeated shutdown requests.
|
||||||
|
- Handle process already exited.
|
||||||
|
- Preserve current quit behavior.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Repeated quit calls do not duplicate process termination.
|
||||||
|
- Tests cover `before-quit`, process exit before shutdown, and shutdown failure logging.
|
||||||
|
|
||||||
|
## Phase 5: Rewrite Updater In Small Modules
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Replace the fragile updater internals while preserving user-visible behavior.
|
||||||
|
|
||||||
|
Target files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/updates/update-service.ts
|
||||||
|
src/main/updates/update-config.ts
|
||||||
|
src/main/updates/update-download.ts
|
||||||
|
src/main/updates/update-schema.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- Define a runtime schema for update metadata.
|
||||||
|
- Validate remote JSON before using it.
|
||||||
|
- Validate update asset URLs.
|
||||||
|
- Restrict protocols to `https:` unless an explicit local development mode exists.
|
||||||
|
- Select platform assets through pure functions.
|
||||||
|
- Download to a safe temporary path.
|
||||||
|
- Finalize downloads atomically.
|
||||||
|
- Keep dialog behavior outside fetch and download helpers.
|
||||||
|
- Correct Spanish encoding issues.
|
||||||
|
- Add tests for malformed JSON, missing assets, invalid URLs, failed downloads, and cancelled dialogs.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Invalid update metadata fails closed.
|
||||||
|
- Downloaded files cannot escape the intended temporary directory.
|
||||||
|
- User-facing strings render correctly.
|
||||||
|
- Existing update behavior is preserved where valid metadata is provided.
|
||||||
|
|
||||||
|
## Phase 6: Isolate Platform Process Termination
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Keep OS-specific process-kill details outside NodeCG lifecycle logic.
|
||||||
|
|
||||||
|
Target file:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/nodecg/platform-process-killer.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- Implement a small interface for terminating process trees.
|
||||||
|
- Provide Windows and POSIX implementations.
|
||||||
|
- Validate that Windows PIDs are numeric before command construction.
|
||||||
|
- Avoid spreading platform conditionals through the process manager.
|
||||||
|
- Test command selection and error handling.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `taskkill` construction is isolated.
|
||||||
|
- POSIX process termination is isolated.
|
||||||
|
- Process manager tests no longer need to know platform command details.
|
||||||
|
|
||||||
|
## Phase 7: Harden Electron Window Policy
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Make Electron browser security explicit and testable.
|
||||||
|
|
||||||
|
Target files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/windows/window-service.ts
|
||||||
|
src/main/windows/navigation-policy.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- Explicitly set `webSecurity: true`.
|
||||||
|
- Keep `nodeIntegration: false`.
|
||||||
|
- Keep `contextIsolation: true`.
|
||||||
|
- Keep `sandbox: true`.
|
||||||
|
- Add a permission request handler that denies by default.
|
||||||
|
- Define devtools policy by environment.
|
||||||
|
- Keep navigation allowlist limited to approved local NodeCG origins.
|
||||||
|
- Prevent unexpected new-window behavior.
|
||||||
|
- Review CSP options for NodeCG-hosted content where feasible.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- BrowserWindow options are covered by tests.
|
||||||
|
- Permission requests are denied unless explicitly allowed.
|
||||||
|
- Navigation policy has tests for allowed and blocked origins.
|
||||||
|
|
||||||
|
## Phase 8: Normalize Scripts And Shared Constants
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Reduce packaging fragility without changing the build system.
|
||||||
|
|
||||||
|
Targets:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scripts/
|
||||||
|
src/main/config/
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- Make repository layout assumptions explicit.
|
||||||
|
- Validate required paths before build work starts.
|
||||||
|
- Share package/runtime constants where reasonable.
|
||||||
|
- Improve error messages for missing parent project or missing NodeCG runtime.
|
||||||
|
- Keep scripts simple `.mjs` utilities.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- CI failures explain missing external dependencies clearly.
|
||||||
|
- Local build behavior remains unchanged.
|
||||||
|
- No new framework is introduced.
|
||||||
|
|
||||||
|
## Phase 9: Reorganize Folders Last
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Move files only after behavior is protected and new ownership is clear.
|
||||||
|
|
||||||
|
Target structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/
|
||||||
|
app/
|
||||||
|
config/
|
||||||
|
windows/
|
||||||
|
nodecg/
|
||||||
|
updates/
|
||||||
|
logging/
|
||||||
|
src/shared/
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
|
||||||
|
- Move files into target folders after tests pass.
|
||||||
|
- Update imports mechanically.
|
||||||
|
- Avoid changing logic during moves.
|
||||||
|
- Run typecheck, tests, and lint after each group of moves.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- File moves are behavior-neutral.
|
||||||
|
- Test output remains unchanged.
|
||||||
|
- Imports reflect the target architecture.
|
||||||
|
|
||||||
|
## Required Verification Per Phase
|
||||||
|
|
||||||
|
Run the relevant subset while iterating, then run all checks before closing a phase:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stop Conditions
|
||||||
|
|
||||||
|
Stop and reassess if:
|
||||||
|
|
||||||
|
- A change requires adding IPC without a product need.
|
||||||
|
- A refactor changes the NodeCG runtime model.
|
||||||
|
- Update validation requires a product or release-server decision.
|
||||||
|
- CSP changes break NodeCG dashboards.
|
||||||
|
- CI requires external repository layout decisions outside this package.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Phase 1 Fix Summary
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This fix restores the Electron renderer after the Phase 1 architecture extraction. It keeps the new main-process structure, does not add a custom renderer, preload, or IPC layer, and does not revert the Phase 1 refactor.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
Phase 1 moved the real bootstrap module from `src/main/main.ts` to `src/main/app/bootstrap.ts`. After compilation, that changed the runtime `__dirname` from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/main
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/main/app
|
||||||
|
```
|
||||||
|
|
||||||
|
The development root calculation still treated `__dirname` as if it were `dist/main`, so `rootPath` resolved to `dist` instead of the repository root. Electron then looked for the packaged NodeCG runtime at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/lib/nodecg
|
||||||
|
```
|
||||||
|
|
||||||
|
instead of:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lib/nodecg
|
||||||
|
```
|
||||||
|
|
||||||
|
That prevented the main process from preparing and launching the correct NodeCG runtime, leaving the BrowserWindow without a valid dashboard to render.
|
||||||
|
|
||||||
|
During verification, a second compatibility issue appeared in the packaged runtime: the Vite/NodeCG bundle imports generated files from the bundle-level `nodecg` directory, but `prepare-nodecg-runtime.mjs` did not copy that directory into `lib/nodecg/bundles/scoreko-dev`. Without it, NodeCG could start but failed to mount the `scoreko-dev` extension, and dashboard URLs returned 404.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
- `src/main/app/bootstrap.ts`
|
||||||
|
- Passes `dist/main` into the path helper by resolving one level above the compiled bootstrap directory.
|
||||||
|
- Keeps path ownership in `src/main/app/paths.ts` and preserves the extracted bootstrap architecture.
|
||||||
|
|
||||||
|
- `scripts/prepare-nodecg-runtime.mjs`
|
||||||
|
- Copies the generated bundle `nodecg` directory into the managed NodeCG runtime.
|
||||||
|
- Treats that directory as required runtime output so an incomplete Vite/NodeCG build fails early.
|
||||||
|
|
||||||
|
- `src/main/nodecg/runtime-provisioner.ts`
|
||||||
|
- Refreshes the managed runtime when the source runtime manifest was regenerated, even if the app and bundle versions are unchanged.
|
||||||
|
- Still preserves user-owned `cfg`, `db`, and `logs`.
|
||||||
|
|
||||||
|
- `src/tests/runtime-provisioner.test.ts`
|
||||||
|
- Adds coverage for refreshing managed runtime files when the source runtime manifest changes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Commands run successfully:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Current test result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
53 tests passing
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime verification:
|
||||||
|
|
||||||
|
- Rebuilt the NodeCG runtime.
|
||||||
|
- Rebuilt `better-sqlite3` for Electron 39.5.1.
|
||||||
|
- Started Electron with updates disabled and load delay set to zero.
|
||||||
|
- Confirmed NodeCG served:
|
||||||
|
- `http://127.0.0.1:9090` with `200 OK`
|
||||||
|
- `http://localhost:9090/bundles/scoreko-dev/dashboard/scoreko-dev/main.html?standalone=true` with `200 OK`
|
||||||
|
- `http://localhost:9090/bundles/scoreko-dev/dashboard/loading/main.html?standalone=true` with `200 OK`
|
||||||
|
- Confirmed the Electron renderer target loaded:
|
||||||
|
- title: `Dashboard`
|
||||||
|
- URL: `http://localhost:9090/bundles/scoreko-dev/dashboard/scoreko-dev/main.html?standalone=true#/`
|
||||||
|
- DOM element count: `311`
|
||||||
|
- visible body text included the Scoreko dashboard navigation and controls.
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- No preload was added.
|
||||||
|
- No IPC was added.
|
||||||
|
- No custom renderer architecture was added.
|
||||||
|
- BrowserWindow security settings remain explicit.
|
||||||
|
- NodeCG remains owned by the main process.
|
||||||
|
- Dashboard loading remains gated behind NodeCG readiness.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Phase 1 Summary
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Executed the architecture base refactor only. The change keeps the Electron plus local NodeCG product model intact and does not add a renderer, preload, or IPC layer.
|
||||||
|
|
||||||
|
Documentation used as source of truth:
|
||||||
|
|
||||||
|
- `docs/refactor/ARCHITECTURE_AUDIT.md`
|
||||||
|
- `docs/refactor/ARCHITECTURE_RULES.md`
|
||||||
|
- `docs/refactor/TARGET_ARCHITECTURE.md`
|
||||||
|
- `docs/refactor/MIGRATION_PLAN.md`
|
||||||
|
- `docs/refactor/SESSION_HANDOFF.md`
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- Split the Electron entrypoint into a thin `src/main/main.ts` and explicit bootstrap logic in `src/main/app/bootstrap.ts`.
|
||||||
|
- Added `src/main/app/application-controller.ts` to own startup, activation, update scheduling, and shutdown coordination.
|
||||||
|
- Added `src/main/app/paths.ts` for pure root path, userData path, NodeCG runtime path, and dashboard URL construction.
|
||||||
|
- Added `src/main/app/shutdown-service.ts` so repeated shutdown requests reuse one stop operation.
|
||||||
|
- Renamed window ownership toward the target architecture:
|
||||||
|
- `src/main/windows/window-service.ts`
|
||||||
|
- `src/main/windows/navigation-policy.ts`
|
||||||
|
- Moved logging to `src/main/logging/logger.ts`.
|
||||||
|
- Extracted process-tree termination to `src/main/nodecg/platform-process-killer.ts`.
|
||||||
|
- Normalized imports away from old `window-factory`, `navigation-security`, and `errors/logger` paths.
|
||||||
|
- Made BrowserWindow security settings explicit:
|
||||||
|
- `nodeIntegration: false`
|
||||||
|
- `contextIsolation: true`
|
||||||
|
- `sandbox: true`
|
||||||
|
- `webSecurity: true`
|
||||||
|
- devtools controlled by development mode
|
||||||
|
- permissions denied by default
|
||||||
|
- Added architecture-base tests for:
|
||||||
|
- path and URL helpers
|
||||||
|
- application startup ordering
|
||||||
|
- packaged relaunch after runtime installation
|
||||||
|
- activation before readiness
|
||||||
|
- shutdown idempotency
|
||||||
|
- platform process killing
|
||||||
|
|
||||||
|
## Intentionally Not Changed
|
||||||
|
|
||||||
|
- No UX changes.
|
||||||
|
- No custom renderer.
|
||||||
|
- No preload script.
|
||||||
|
- No IPC layer.
|
||||||
|
- No packaging changes.
|
||||||
|
- No update-manager rewrite.
|
||||||
|
- No complex NodeCG lifecycle rewrite.
|
||||||
|
- No change to the managed runtime location under Electron `userData`.
|
||||||
|
- No change to preservation of `cfg`, `db`, and `logs`.
|
||||||
|
- No change to launching NodeCG with `ELECTRON_RUN_AS_NODE`.
|
||||||
|
|
||||||
|
## Compatibility Notes
|
||||||
|
|
||||||
|
- Startup still prepares the managed NodeCG runtime before launching NodeCG.
|
||||||
|
- Packaged first-run runtime installation still relaunches before NodeCG starts.
|
||||||
|
- Loading and main windows are still created before NodeCG readiness so the startup experience remains equivalent.
|
||||||
|
- Dashboard loading remains gated behind NodeCG readiness.
|
||||||
|
- Update checks are still scheduled only after the main window is shown.
|
||||||
|
- Shutdown remains idempotent and still stops the NodeCG process tree.
|
||||||
|
- macOS-style activation now routes through the controller so dashboard loading cannot bypass readiness.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Commands run successfully:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Current test result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
52 tests passing
|
||||||
|
```
|
||||||
|
|
||||||
|
Import/security sanity search:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rg -n "navigation-security|window-factory|errors/logger|preload|ipcRenderer|ipcMain|nodeIntegration:\s*true|webSecurity:\s*false|any\b" src docs/refactor
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- No legacy imports or unsafe Electron settings remain in `src`.
|
||||||
|
- Remaining matches are source-of-truth documentation references only.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Phase 2 Summary
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Executed the IPC and process-management phase only.
|
||||||
|
|
||||||
|
Documentation used as source of truth:
|
||||||
|
|
||||||
|
- `docs/refactor/ARCHITECTURE_AUDIT.md`
|
||||||
|
- `docs/refactor/ARCHITECTURE_RULES.md`
|
||||||
|
- `docs/refactor/TARGET_ARCHITECTURE.md`
|
||||||
|
- `docs/refactor/MIGRATION_PLAN.md`
|
||||||
|
- `docs/refactor/SESSION_HANDOFF.md`
|
||||||
|
|
||||||
|
## IPC And Preload Decision
|
||||||
|
|
||||||
|
No IPC or preload layer was added.
|
||||||
|
|
||||||
|
This is intentional. The current architecture defines a zero-surface IPC model as the secure target because there is no custom renderer and no product requirement for desktop APIs to cross into web content.
|
||||||
|
|
||||||
|
To make that decision enforceable, a regression test now scans `src/main` and fails if main-process source introduces:
|
||||||
|
|
||||||
|
- `ipcMain`
|
||||||
|
- `ipcRenderer`
|
||||||
|
- `contextBridge`
|
||||||
|
- `preload`
|
||||||
|
|
||||||
|
## Process Management Changes
|
||||||
|
|
||||||
|
- Narrowed `NodecgProcessManager` so `startNodecgProcess` no longer returns the raw `ChildProcess`.
|
||||||
|
- Removed the public internal `getProcess` escape hatch from `NodecgProcessManager`.
|
||||||
|
- Added explicit NodeCG process states:
|
||||||
|
- `idle`
|
||||||
|
- `starting`
|
||||||
|
- `running`
|
||||||
|
- `stopping`
|
||||||
|
- `stopped`
|
||||||
|
- `failed`
|
||||||
|
- Added `getState` as the narrow observable process-management API.
|
||||||
|
- Made NodeCG startup idempotent while an async startup is already in progress.
|
||||||
|
- Prevented new startup while process shutdown is in progress.
|
||||||
|
- Preserved process-tree termination through `platform-process-killer.ts`.
|
||||||
|
- Preserved `ELECTRON_RUN_AS_NODE`, `shell: false`, `windowsHide: true`, and detached POSIX process-group behavior.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- No raw Electron IPC APIs are imported in production source.
|
||||||
|
- No preload script is configured or exposed.
|
||||||
|
- No renderer/main business logic boundary was added.
|
||||||
|
- No filesystem, process, shell, or update primitives were exposed to web content.
|
||||||
|
- BrowserWindow security settings from Phase 1 remain unchanged.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Commands run successfully:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Current test result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
55 tests passing
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional sanity search:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rg -n "ipcMain|ipcRenderer|contextBridge|preload|nodeIntegration:\s*true|webSecurity:\s*false|\bany\b" src/main src/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- No production IPC or preload surface exists.
|
||||||
|
- No unsafe Electron settings were introduced.
|
||||||
|
- Remaining IPC/preload string matches are limited to the regression test that guards the zero-surface policy.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Phase 3 Summary
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Executed the UI and settings cleanup phase only for the Electron package.
|
||||||
|
|
||||||
|
Documentation used as source of truth:
|
||||||
|
|
||||||
|
- `docs/refactor/ARCHITECTURE_AUDIT.md`
|
||||||
|
- `docs/refactor/ARCHITECTURE_RULES.md`
|
||||||
|
- `docs/refactor/TARGET_ARCHITECTURE.md`
|
||||||
|
- `docs/refactor/MIGRATION_PLAN.md`
|
||||||
|
- `docs/refactor/SESSION_HANDOFF.md`
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
- Split update dialog UI out of `src/main/updates/update-manager.ts` into `src/main/updates/update-dialogs.ts`.
|
||||||
|
- Split update settings loading and file-config normalization into `src/main/updates/update-settings.ts`.
|
||||||
|
- Split installer download behavior into `src/main/updates/update-download.ts`.
|
||||||
|
- Kept `src/main/updates/update-manager.ts` focused on orchestration:
|
||||||
|
- load settings
|
||||||
|
- fetch latest release
|
||||||
|
- ask the user what to do
|
||||||
|
- download installer
|
||||||
|
- run install handoff
|
||||||
|
- Added defensive update config parsing from `unknown` JSON without introducing `any`.
|
||||||
|
- Added settings tests covering:
|
||||||
|
- runtime config disabling updates
|
||||||
|
- runtime config overriding file settings
|
||||||
|
- malformed update config normalization
|
||||||
|
- invalid JSON fallback and logging
|
||||||
|
- Fixed the existing Spanish mojibake in update dialogs touched by this phase.
|
||||||
|
|
||||||
|
## Intentionally Not Changed
|
||||||
|
|
||||||
|
- No UX flow changes.
|
||||||
|
- No new features.
|
||||||
|
- No custom renderer was added.
|
||||||
|
- No preload was added.
|
||||||
|
- No IPC was added.
|
||||||
|
- No parent bundle source was modified.
|
||||||
|
- No generated `dist` or `lib` source was edited manually.
|
||||||
|
- No forms or controls were changed in the NodeCG dashboard.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Commands run successfully:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Current test result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
59 tests passing
|
||||||
|
```
|
||||||
|
|
||||||
|
Sanity searches:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rg -n "\bany\b" src/main src/tests
|
||||||
|
rg -n "ActualizaciÃ|estÃ|versiÃ|cerrarÃ" src/main src/tests
|
||||||
|
rg -n "ipcMain|ipcRenderer|contextBridge|preload|nodeIntegration:\s*true|webSecurity:\s*false" src/main src/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- No `any` was introduced.
|
||||||
|
- No touched Spanish update-dialog text remains mojibaked.
|
||||||
|
- No production IPC or preload surface exists.
|
||||||
|
- No unsafe Electron window settings were introduced.
|
||||||
|
- Remaining IPC/preload matches are limited to the regression test that guards the zero-surface policy.
|
||||||
|
|
||||||
|
## UI Verification
|
||||||
|
|
||||||
|
The Electron launch path prepared a temporary managed runtime, but the NodeCG child did not expose port `9090` within the verification window. To verify the served UI without touching the user's real runtime data, NodeCG was launched from a temporary Electron `userData` directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SCOREKO_APP_USER_DATA_DIRECTORY=scoreko-codex-ui-check
|
||||||
|
SCOREKO_UPDATES_ENABLED=false
|
||||||
|
ELECTRON_LOAD_DELAY_MS=0
|
||||||
|
```
|
||||||
|
|
||||||
|
The temporary NodeCG runtime served:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:9090 -> 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Browser verification loaded:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:9090/bundles/scoreko-dev/dashboard/scoreko-dev/main.html?standalone=true#/
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed UI signals:
|
||||||
|
|
||||||
|
- Page title: `Dashboard`
|
||||||
|
- Scoreko sidebar rendered.
|
||||||
|
- Main navigation rendered.
|
||||||
|
- `Settings` navigation entry rendered.
|
||||||
|
- Dashboard form controls rendered.
|
||||||
|
|
||||||
|
The temporary NodeCG process was stopped after verification.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Phase 4 Summary
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Executed only the filesystem, updater, and packaging/build-config cleanup requested for this phase.
|
||||||
|
|
||||||
|
Documentation used as source of truth:
|
||||||
|
|
||||||
|
- `docs/refactor/ARCHITECTURE_AUDIT.md`
|
||||||
|
- `docs/refactor/ARCHITECTURE_RULES.md`
|
||||||
|
- `docs/refactor/TARGET_ARCHITECTURE.md`
|
||||||
|
- `docs/refactor/MIGRATION_PLAN.md`
|
||||||
|
- `docs/refactor/SESSION_HANDOFF.md`
|
||||||
|
|
||||||
|
## Filesystem And Paths
|
||||||
|
|
||||||
|
- Added pure path helpers in `src/main/app/paths.ts` for:
|
||||||
|
- managed NodeCG runtime storage under Electron `userData`
|
||||||
|
- default update config location
|
||||||
|
- update download temp directory
|
||||||
|
- safe child-path resolution that rejects traversal and absolute-path escape
|
||||||
|
- Updated runtime provisioning to use the managed-runtime path helper instead of rebuilding that storage path locally.
|
||||||
|
- Added tests for update storage paths and path traversal rejection.
|
||||||
|
|
||||||
|
## Updater
|
||||||
|
|
||||||
|
- Reorganized updater modules toward the target architecture:
|
||||||
|
- `src/main/updates/update-service.ts`
|
||||||
|
- `src/main/updates/update-config.ts`
|
||||||
|
- `src/main/updates/update-schema.ts`
|
||||||
|
- `src/main/updates/update-download.ts`
|
||||||
|
- Removed the older updater module names:
|
||||||
|
- `update-manager.ts`
|
||||||
|
- `update-settings.ts`
|
||||||
|
- `update-utils.ts`
|
||||||
|
- Added runtime validation for remote Gitea release metadata before building update state.
|
||||||
|
- Added URL policy handling so packaged builds reject insecure HTTP update URLs and installer downloads.
|
||||||
|
- Kept local development able to use HTTP update endpoints explicitly through the dev policy.
|
||||||
|
- Changed installer download behavior to:
|
||||||
|
- validate URL protocol before fetch
|
||||||
|
- sanitize installer file names
|
||||||
|
- constrain output to the safe temp download directory
|
||||||
|
- write to a staging file first
|
||||||
|
- finalize with atomic rename
|
||||||
|
- clean staging files on failure
|
||||||
|
- Kept dialogs and install handoff separate from schema parsing and download streaming.
|
||||||
|
|
||||||
|
## Packaging And Build Config
|
||||||
|
|
||||||
|
- Added `scripts/build-config.mjs` as the shared build-layout source for scripts.
|
||||||
|
- Consolidated repeated script constants for:
|
||||||
|
- Electron package root
|
||||||
|
- parent Scoreko bundle root
|
||||||
|
- packaged NodeCG runtime root
|
||||||
|
- bundle name
|
||||||
|
- generated bundle entries
|
||||||
|
- prepared runtime entries
|
||||||
|
- npm/electron cache locations
|
||||||
|
- local binary path resolution
|
||||||
|
- Updated packaging-related scripts to use the shared config:
|
||||||
|
- `scripts/build-scoreko-bundle.mjs`
|
||||||
|
- `scripts/prepare-nodecg-runtime.mjs`
|
||||||
|
- `scripts/rebuild-nodecg-native.mjs`
|
||||||
|
- `scripts/doctor.mjs`
|
||||||
|
- Improved the missing parent-project error in `build-scoreko-bundle.mjs` so CI/local failures report the expected layout and missing markers.
|
||||||
|
|
||||||
|
## Intentionally Not Changed
|
||||||
|
|
||||||
|
- No UX changes.
|
||||||
|
- No custom renderer.
|
||||||
|
- No preload.
|
||||||
|
- No IPC.
|
||||||
|
- No Electron window behavior changes.
|
||||||
|
- No NodeCG runtime model changes.
|
||||||
|
- No user-owned runtime directory deletion changes.
|
||||||
|
- No broad build framework introduced.
|
||||||
|
- No `any` added.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Commands run successfully:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run typecheck
|
||||||
|
npm.cmd test
|
||||||
|
npm.cmd run lint
|
||||||
|
npm.cmd run doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
Current test result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
65 tests passing
|
||||||
|
```
|
||||||
|
|
||||||
|
Packaging verification:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm.cmd run pack
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- Passed with escalated filesystem permission, generating `release/win-unpacked`.
|
||||||
|
- A later non-escalated rerun was blocked by the sandbox while writing generated bundle output in the parent Scoreko project (`shared/dist`). That rerun failed before packaging because of sandbox filesystem permissions, not because of a build error.
|
||||||
|
- A final escalated rerun could not be started because the approval system rejected the escalation. Typecheck, tests, lint, and doctor were run successfully around the packaging verification.
|
||||||
|
|
||||||
|
Sanity searches:
|
||||||
|
|
||||||
|
```text
|
||||||
|
rg -n "\bany\b|update-manager|update-settings|update-utils|ActualizaciÃ|estÃ|versiÃ|nodeIntegration:\s*true|webSecurity:\s*false|ipcMain|ipcRenderer|contextBridge|preload" src scripts docs/refactor
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
- No `any` was introduced in production or test source.
|
||||||
|
- No legacy updater module references remain in `src`.
|
||||||
|
- No touched Spanish update text is mojibaked.
|
||||||
|
- No production IPC or preload surface exists.
|
||||||
|
- No unsafe Electron window settings were introduced.
|
||||||
|
- Remaining IPC/preload matches are documentation and the regression test that guards the zero-surface policy.
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# Session Handoff
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This handoff captures the agreed architecture direction for future refactor sessions. Do not restart architectural discovery from zero unless the codebase has changed substantially.
|
||||||
|
|
||||||
|
The previous analysis concluded that the codebase is fundamentally healthy. The refactor should be controlled and incremental, focused on lifecycle, updater safety, process shutdown, and explicit Electron security.
|
||||||
|
|
||||||
|
## Current Technical State
|
||||||
|
|
||||||
|
Known validation from the prior analysis:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
All passed at that time, with 42 tests passing.
|
||||||
|
|
||||||
|
## Source Of Truth Documents
|
||||||
|
|
||||||
|
Use these documents in order:
|
||||||
|
|
||||||
|
1. `docs/refactor/ARCHITECTURE_AUDIT.md`
|
||||||
|
2. `docs/refactor/ARCHITECTURE_RULES.md`
|
||||||
|
3. `docs/refactor/TARGET_ARCHITECTURE.md`
|
||||||
|
4. `docs/refactor/MIGRATION_PLAN.md`
|
||||||
|
5. `docs/refactor/SESSION_HANDOFF.md`
|
||||||
|
|
||||||
|
## High-Level Project Model
|
||||||
|
|
||||||
|
This is an Electron wrapper around a local NodeCG runtime.
|
||||||
|
|
||||||
|
Important facts:
|
||||||
|
|
||||||
|
- The app lives mostly in Electron main.
|
||||||
|
- There is no custom renderer.
|
||||||
|
- There is no preload.
|
||||||
|
- There is no meaningful IPC.
|
||||||
|
- Electron starts NodeCG locally.
|
||||||
|
- Electron loads dashboards from local HTTP origins.
|
||||||
|
- Runtime provisioning happens under Electron `userData`.
|
||||||
|
- NodeCG is launched through the Electron binary with `ELECTRON_RUN_AS_NODE`.
|
||||||
|
|
||||||
|
Do not treat the absence of IPC or preload as a missing feature. It is currently a desirable security property.
|
||||||
|
|
||||||
|
## Preserve These Behaviors
|
||||||
|
|
||||||
|
- Runtime stored under `userData`.
|
||||||
|
- Relaunch after first runtime installation when required.
|
||||||
|
- Preservation of `cfg`, `db`, and `logs`.
|
||||||
|
- Use of `ELECTRON_RUN_AS_NODE`.
|
||||||
|
- Waiting for NodeCG HTTP readiness before dashboard load.
|
||||||
|
- Existing tests for config, provisioning, navigation, process management, and updates.
|
||||||
|
- Minimal Electron surface.
|
||||||
|
- No IPC unless required.
|
||||||
|
|
||||||
|
## Main Risks To Address
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
`main.ts` currently mixes:
|
||||||
|
|
||||||
|
- App configuration.
|
||||||
|
- Runtime preparation.
|
||||||
|
- Window handling.
|
||||||
|
- NodeCG startup.
|
||||||
|
- Readiness waiting.
|
||||||
|
- Update scheduling.
|
||||||
|
- Shutdown.
|
||||||
|
|
||||||
|
Refactor target:
|
||||||
|
|
||||||
|
- Introduce `ApplicationController`.
|
||||||
|
- Add explicit lifecycle states.
|
||||||
|
- Keep `main.ts` or `bootstrap.ts` thin.
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
|
||||||
|
Current risk:
|
||||||
|
|
||||||
|
- macOS activation can recreate a window and load a dashboard without proving NodeCG readiness.
|
||||||
|
|
||||||
|
Refactor target:
|
||||||
|
|
||||||
|
- Route activation through the same readiness-aware controller as startup.
|
||||||
|
|
||||||
|
### Updater
|
||||||
|
|
||||||
|
Current risk:
|
||||||
|
|
||||||
|
- Remote JSON and asset URLs need stronger validation.
|
||||||
|
- Download and install behavior should be separated.
|
||||||
|
- Spanish text contains visible encoding corruption.
|
||||||
|
|
||||||
|
Refactor target:
|
||||||
|
|
||||||
|
- Rewrite updater internals in small modules.
|
||||||
|
- Validate metadata.
|
||||||
|
- Validate URLs.
|
||||||
|
- Use safe temporary paths.
|
||||||
|
- Fix encoding.
|
||||||
|
|
||||||
|
### Process Shutdown
|
||||||
|
|
||||||
|
Current risk:
|
||||||
|
|
||||||
|
- Windows process-tree termination uses `taskkill`.
|
||||||
|
- It is low risk because the PID is numeric, but platform behavior should be isolated.
|
||||||
|
|
||||||
|
Refactor target:
|
||||||
|
|
||||||
|
- Add `platform-process-killer.ts`.
|
||||||
|
- Keep platform-specific commands out of NodeCG lifecycle orchestration.
|
||||||
|
|
||||||
|
### Electron Security
|
||||||
|
|
||||||
|
Current strengths:
|
||||||
|
|
||||||
|
- `nodeIntegration: false`
|
||||||
|
- `contextIsolation: true`
|
||||||
|
- `sandbox: true`
|
||||||
|
- No preload.
|
||||||
|
- No IPC.
|
||||||
|
|
||||||
|
Refactor target:
|
||||||
|
|
||||||
|
- Add explicit `webSecurity: true`.
|
||||||
|
- Add permission denial by default.
|
||||||
|
- Add devtools policy by environment.
|
||||||
|
- Keep navigation allowlisted.
|
||||||
|
|
||||||
|
## Recommended Next Session
|
||||||
|
|
||||||
|
Start with Phase 1 from `MIGRATION_PLAN.md`.
|
||||||
|
|
||||||
|
Immediate next work:
|
||||||
|
|
||||||
|
1. Add lifecycle tests around current behavior.
|
||||||
|
2. Cover first-run relaunch behavior.
|
||||||
|
3. Cover loading window and main window order.
|
||||||
|
4. Cover update scheduling.
|
||||||
|
5. Cover shutdown idempotency.
|
||||||
|
6. Cover activation readiness behavior.
|
||||||
|
|
||||||
|
Do not move files before these tests exist.
|
||||||
|
|
||||||
|
## Important Constraints
|
||||||
|
|
||||||
|
- Do not re-architect around a renderer.
|
||||||
|
- Do not introduce IPC proactively.
|
||||||
|
- Do not rewrite the whole project.
|
||||||
|
- Do not move folders before preserving behavior with tests.
|
||||||
|
- Do not remove the managed NodeCG runtime strategy.
|
||||||
|
- Do not delete user-owned runtime directories.
|
||||||
|
- Do not broaden Electron permissions.
|
||||||
|
|
||||||
|
## Preferred Refactor Order
|
||||||
|
|
||||||
|
1. Tests around current lifecycle behavior.
|
||||||
|
2. Pure path and URL extraction.
|
||||||
|
3. `ApplicationController`.
|
||||||
|
4. Shutdown service.
|
||||||
|
5. Updater rewrite.
|
||||||
|
6. Platform process-killer adapter.
|
||||||
|
7. Electron security hardening.
|
||||||
|
8. Script normalization.
|
||||||
|
9. Folder reorganization.
|
||||||
|
|
||||||
|
## Completion Criteria For The Refactor
|
||||||
|
|
||||||
|
The refactor is successful when:
|
||||||
|
|
||||||
|
- Startup is controlled by a tested application controller.
|
||||||
|
- Shutdown is idempotent.
|
||||||
|
- Activation cannot load dashboards before NodeCG readiness.
|
||||||
|
- Updater metadata and URLs are validated.
|
||||||
|
- Downloads use safe paths and atomic finalization.
|
||||||
|
- Process-tree termination is isolated by platform.
|
||||||
|
- Browser windows declare secure settings explicitly.
|
||||||
|
- Permission requests are denied by default.
|
||||||
|
- No unnecessary IPC, preload, or renderer has been introduced.
|
||||||
|
- Typecheck, tests, and lint pass.
|
||||||
|
|
||||||
|
## Reminder For Future Agents
|
||||||
|
|
||||||
|
This project does not need to become bigger to become safer.
|
||||||
|
|
||||||
|
Keep Electron small. Keep NodeCG ownership explicit. Keep remote update data untrusted. Keep the browser surface boring.
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# Target Architecture
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
The target architecture keeps the application small and explicit. Electron should remain a thin shell that owns desktop lifecycle, launches NodeCG, loads local dashboards, manages updates, and shuts down cleanly.
|
||||||
|
|
||||||
|
The target is not a full rewrite. It is a gradual extraction of responsibilities from `main.ts` into testable modules.
|
||||||
|
|
||||||
|
## Target Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/
|
||||||
|
app/
|
||||||
|
bootstrap.ts
|
||||||
|
application-controller.ts
|
||||||
|
paths.ts
|
||||||
|
shutdown-service.ts
|
||||||
|
config/
|
||||||
|
runtime-config.ts
|
||||||
|
windows/
|
||||||
|
window-service.ts
|
||||||
|
navigation-policy.ts
|
||||||
|
nodecg/
|
||||||
|
runtime-provisioner.ts
|
||||||
|
nodecg-process-service.ts
|
||||||
|
platform-process-killer.ts
|
||||||
|
updates/
|
||||||
|
update-service.ts
|
||||||
|
update-config.ts
|
||||||
|
update-download.ts
|
||||||
|
update-schema.ts
|
||||||
|
logging/
|
||||||
|
logger.ts
|
||||||
|
shared/
|
||||||
|
result.ts
|
||||||
|
src/shared/
|
||||||
|
types/
|
||||||
|
config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
`shared/result.ts` is optional and should be added only if it removes repeated error-handling noise.
|
||||||
|
|
||||||
|
`src/shared/types/config.ts` should contain only types that are genuinely shared across process boundaries or packages. It should not become a dumping ground.
|
||||||
|
|
||||||
|
## Runtime Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
Electron entrypoint
|
||||||
|
-> bootstrap app identity, paths, lock, config
|
||||||
|
-> create ApplicationController
|
||||||
|
-> prepare managed NodeCG runtime
|
||||||
|
-> relaunch if first install requires it
|
||||||
|
-> create loading window
|
||||||
|
-> start NodeCG process
|
||||||
|
-> wait for HTTP readiness
|
||||||
|
-> create or show main window
|
||||||
|
-> load NodeCG dashboard URL
|
||||||
|
-> close loading window
|
||||||
|
-> schedule update checks
|
||||||
|
```
|
||||||
|
|
||||||
|
Shutdown flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
quit requested
|
||||||
|
-> mark controller stopping
|
||||||
|
-> stop update work if needed
|
||||||
|
-> stop NodeCG process tree
|
||||||
|
-> close windows
|
||||||
|
-> allow app exit
|
||||||
|
```
|
||||||
|
|
||||||
|
Activation flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
activate requested
|
||||||
|
-> if ready, create or show main window
|
||||||
|
-> if not ready, route through readiness-aware startup
|
||||||
|
-> never load dashboard before NodeCG readiness
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Responsibilities
|
||||||
|
|
||||||
|
### `app/bootstrap.ts`
|
||||||
|
|
||||||
|
Owns Electron entrypoint side effects:
|
||||||
|
|
||||||
|
- Set app name.
|
||||||
|
- Set app paths.
|
||||||
|
- Acquire single-instance lock.
|
||||||
|
- Register Electron app event handlers.
|
||||||
|
- Instantiate services.
|
||||||
|
- Delegate startup and shutdown to `ApplicationController`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Keep this file thin.
|
||||||
|
- Do not put business logic here.
|
||||||
|
- Do not make it responsible for update parsing, process killing, or window option construction.
|
||||||
|
|
||||||
|
### `app/application-controller.ts`
|
||||||
|
|
||||||
|
Owns high-level lifecycle state.
|
||||||
|
|
||||||
|
States:
|
||||||
|
|
||||||
|
```text
|
||||||
|
idle
|
||||||
|
preparing
|
||||||
|
starting
|
||||||
|
ready
|
||||||
|
stopping
|
||||||
|
stopped
|
||||||
|
failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Coordinate runtime preparation.
|
||||||
|
- Coordinate NodeCG startup and readiness.
|
||||||
|
- Coordinate loading and main windows.
|
||||||
|
- Coordinate update scheduling.
|
||||||
|
- Coordinate activation.
|
||||||
|
- Coordinate shutdown.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- State transitions must be explicit.
|
||||||
|
- Shutdown must be idempotent.
|
||||||
|
- Activation must not bypass readiness.
|
||||||
|
- Controller tests should not require real Electron where avoidable.
|
||||||
|
|
||||||
|
### `app/paths.ts`
|
||||||
|
|
||||||
|
Owns pure path construction:
|
||||||
|
|
||||||
|
- Electron `userData` derived paths.
|
||||||
|
- Managed runtime path.
|
||||||
|
- Safe temp locations.
|
||||||
|
- Any path constants shared by startup and provisioning.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- No Electron side effects.
|
||||||
|
- No filesystem writes.
|
||||||
|
- Pure functions only.
|
||||||
|
|
||||||
|
### `config/runtime-config.ts`
|
||||||
|
|
||||||
|
Owns runtime configuration:
|
||||||
|
|
||||||
|
- Parse environment variables.
|
||||||
|
- Parse static config.
|
||||||
|
- Define defaults.
|
||||||
|
- Return typed runtime config.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Parse once.
|
||||||
|
- Validate early.
|
||||||
|
- Do not read environment variables throughout the application.
|
||||||
|
|
||||||
|
### `windows/window-service.ts`
|
||||||
|
|
||||||
|
Owns Electron window creation and window lifecycle.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Create loading window.
|
||||||
|
- Create main window.
|
||||||
|
- Apply secure `webPreferences`.
|
||||||
|
- Apply devtools policy.
|
||||||
|
- Register permission handlers.
|
||||||
|
- Delegate navigation decisions to `navigation-policy`.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- No NodeCG process logic.
|
||||||
|
- No updater logic.
|
||||||
|
- No runtime provisioning logic.
|
||||||
|
|
||||||
|
### `windows/navigation-policy.ts`
|
||||||
|
|
||||||
|
Owns pure navigation decisions.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Allow approved local NodeCG origins.
|
||||||
|
- Block external navigation.
|
||||||
|
- Block unexpected new-window attempts.
|
||||||
|
- Normalize URL parsing.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Prefer `URL` parsing.
|
||||||
|
- Keep policy testable without Electron.
|
||||||
|
- Do not use broad string-prefix allow checks as the primary control.
|
||||||
|
|
||||||
|
### `nodecg/runtime-provisioner.ts`
|
||||||
|
|
||||||
|
Owns managed runtime installation and replacement.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Validate bundled runtime source.
|
||||||
|
- Install runtime into `userData`.
|
||||||
|
- Replace managed runtime safely.
|
||||||
|
- Preserve `cfg`, `db`, and `logs`.
|
||||||
|
- Report whether relaunch is needed.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- User-owned data must not be deleted.
|
||||||
|
- Runtime replacement must be predictable and test-covered.
|
||||||
|
|
||||||
|
### `nodecg/nodecg-process-service.ts`
|
||||||
|
|
||||||
|
Owns NodeCG process lifecycle.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Validate runtime before launch.
|
||||||
|
- Start NodeCG with `ELECTRON_RUN_AS_NODE`.
|
||||||
|
- Capture process output.
|
||||||
|
- Wait for HTTP readiness.
|
||||||
|
- Stop the process.
|
||||||
|
- Delegate platform-specific process-tree termination.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Do not build platform kill commands here.
|
||||||
|
- Do not create windows here.
|
||||||
|
- Do not schedule updates here.
|
||||||
|
|
||||||
|
### `nodecg/platform-process-killer.ts`
|
||||||
|
|
||||||
|
Owns OS-specific process-tree termination.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Terminate a process tree on Windows.
|
||||||
|
- Terminate a process tree on POSIX systems.
|
||||||
|
- Validate process IDs before command construction.
|
||||||
|
- Normalize process-kill errors.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Keep platform branches here.
|
||||||
|
- Test command construction.
|
||||||
|
- Keep inputs narrow and typed.
|
||||||
|
|
||||||
|
### `updates/update-service.ts`
|
||||||
|
|
||||||
|
Owns update orchestration.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Schedule update checks.
|
||||||
|
- Fetch update metadata through a helper.
|
||||||
|
- Validate metadata through schema helpers.
|
||||||
|
- Select the correct platform asset.
|
||||||
|
- Ask the user before installing.
|
||||||
|
- Delegate download work.
|
||||||
|
- Start installer only after validation and download success.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Do not trust remote metadata.
|
||||||
|
- Do not mix dialogs with JSON parsing.
|
||||||
|
- Do not mix installer execution with download streaming.
|
||||||
|
|
||||||
|
### `updates/update-config.ts`
|
||||||
|
|
||||||
|
Owns update settings:
|
||||||
|
|
||||||
|
- Feed URL.
|
||||||
|
- Current app version.
|
||||||
|
- Platform selection.
|
||||||
|
- Development-mode behavior.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Keep production and development behavior explicit.
|
||||||
|
- Do not silently downgrade security in production.
|
||||||
|
|
||||||
|
### `updates/update-download.ts`
|
||||||
|
|
||||||
|
Owns download behavior:
|
||||||
|
|
||||||
|
- Validate URL protocol.
|
||||||
|
- Download to a safe temp path.
|
||||||
|
- Write atomically.
|
||||||
|
- Return a typed result.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- No dialogs.
|
||||||
|
- No installer execution.
|
||||||
|
- No remote metadata interpretation.
|
||||||
|
|
||||||
|
### `updates/update-schema.ts`
|
||||||
|
|
||||||
|
Owns runtime validation:
|
||||||
|
|
||||||
|
- Update metadata shape.
|
||||||
|
- Asset shape.
|
||||||
|
- Version field presence.
|
||||||
|
- URL field validity before download selection.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Unknown remote JSON must be validated before use.
|
||||||
|
- Invalid metadata must fail closed.
|
||||||
|
|
||||||
|
### `logging/logger.ts`
|
||||||
|
|
||||||
|
Optional thin logging boundary.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Add only if it improves consistency.
|
||||||
|
- Do not introduce a large logging framework.
|
||||||
|
- Keep logs useful for startup, process, update, and shutdown diagnostics.
|
||||||
|
|
||||||
|
## Electron Decisions
|
||||||
|
|
||||||
|
- Keep Electron main as the only privileged process.
|
||||||
|
- Keep Node.js unavailable to web content.
|
||||||
|
- Keep custom renderer absent unless a concrete feature requires one.
|
||||||
|
- Keep preload absent unless a desktop API must cross into web content.
|
||||||
|
- Treat windows as untrusted web surfaces even when loading local NodeCG dashboards.
|
||||||
|
|
||||||
|
Required `BrowserWindow` security posture:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nodeIntegration: false
|
||||||
|
contextIsolation: true
|
||||||
|
sandbox: true
|
||||||
|
webSecurity: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional decisions:
|
||||||
|
|
||||||
|
- Deny permissions by default.
|
||||||
|
- Control devtools by environment.
|
||||||
|
- Block external navigation by default.
|
||||||
|
- Block unexpected new windows.
|
||||||
|
- Review CSP options for NodeCG-hosted content, but do not break dashboards to satisfy theoretical policy.
|
||||||
|
|
||||||
|
## IPC Decisions
|
||||||
|
|
||||||
|
Current target:
|
||||||
|
|
||||||
|
- No IPC.
|
||||||
|
- No preload.
|
||||||
|
- No exposed desktop API.
|
||||||
|
|
||||||
|
Future IPC, if needed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/main/ipc/
|
||||||
|
channels.ts
|
||||||
|
register-handlers.ts
|
||||||
|
validators.ts
|
||||||
|
src/shared/ipc/
|
||||||
|
types.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
IPC must be:
|
||||||
|
|
||||||
|
- Explicitly justified by a product requirement.
|
||||||
|
- Channel allowlisted.
|
||||||
|
- Payload validated at runtime.
|
||||||
|
- Typed at compile time.
|
||||||
|
- Narrow in capability.
|
||||||
|
|
||||||
|
IPC must not expose:
|
||||||
|
|
||||||
|
- Raw `ipcRenderer`.
|
||||||
|
- Filesystem primitives.
|
||||||
|
- Process primitives.
|
||||||
|
- Shell execution.
|
||||||
|
- Update installation primitives.
|
||||||
|
- Arbitrary NodeCG process controls.
|
||||||
|
|
||||||
|
## Security Decisions
|
||||||
|
|
||||||
|
Security controls to preserve:
|
||||||
|
|
||||||
|
- No Node.js in web content.
|
||||||
|
- Context isolation enabled.
|
||||||
|
- Sandbox enabled.
|
||||||
|
- No IPC surface by default.
|
||||||
|
- Local navigation only.
|
||||||
|
|
||||||
|
Security controls to add:
|
||||||
|
|
||||||
|
- Explicit `webSecurity: true`.
|
||||||
|
- Permission handler that denies by default.
|
||||||
|
- Explicit devtools policy.
|
||||||
|
- Strong update metadata validation.
|
||||||
|
- Strong update asset URL validation.
|
||||||
|
- Safe temporary download paths.
|
||||||
|
- Atomic download finalization.
|
||||||
|
- Platform process-kill isolation.
|
||||||
|
|
||||||
|
## What This Architecture Should Feel Like
|
||||||
|
|
||||||
|
Future maintainers should be able to answer these questions quickly:
|
||||||
|
|
||||||
|
- Where does startup happen?
|
||||||
|
- Where is NodeCG launched?
|
||||||
|
- Where is readiness checked?
|
||||||
|
- Where are windows created?
|
||||||
|
- Where is navigation allowed or denied?
|
||||||
|
- Where are updates validated?
|
||||||
|
- Where is shutdown coordinated?
|
||||||
|
- Where does platform-specific process killing live?
|
||||||
|
|
||||||
|
If a future change makes those answers harder, it is moving against the target architecture.
|
||||||
+25
-7
@@ -1,14 +1,19 @@
|
|||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
## `NodeCG folder does not exist`
|
## `The packaged NodeCG runtime is incomplete`
|
||||||
|
|
||||||
- Verify `lib/nodecg` exists.
|
- Run `npm run prepare:runtime` from `scoreko-electron-dev`.
|
||||||
- Make sure the project contains a full NodeCG installation.
|
- If the parent bundle is not installed yet, run `pnpm install` from the repository root first.
|
||||||
|
|
||||||
|
## `NodeCG is present but internal dependencies are missing`
|
||||||
|
|
||||||
|
- Recreate the runtime with `npm run prepare:runtime`.
|
||||||
|
- If native SQLite errors appear during launch, run `npm run rebuild:native` before packaging.
|
||||||
|
|
||||||
## `No read/write permissions on NodeCG`
|
## `No read/write permissions on NodeCG`
|
||||||
|
|
||||||
- Adjust permissions on `lib/nodecg` for the user running Electron.
|
- Installed builds run NodeCG from the user's app data folder, so this usually means the local development copy is locked.
|
||||||
- On Linux/macOS: `chmod -R u+rw lib/nodecg` (according to your local policy).
|
- Close any running Scoreko/NodeCG process and run `npm run start` again.
|
||||||
|
|
||||||
## `Port <PORT> is already in use`
|
## `Port <PORT> is already in use`
|
||||||
|
|
||||||
@@ -17,11 +22,24 @@
|
|||||||
|
|
||||||
## `Timeout while waiting for NodeCG`
|
## `Timeout while waiting for NodeCG`
|
||||||
|
|
||||||
- Check NodeCG logs in standard output.
|
- Check the Electron/NodeCG output in the terminal.
|
||||||
- Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow.
|
- Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow.
|
||||||
- Verify NodeCG dependencies (`cd lib/nodecg && npm install`).
|
- Recreate the runtime with `npm run prepare:runtime` if the bundle changed.
|
||||||
|
|
||||||
|
## 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
|
## macOS build fails because of icon
|
||||||
|
|
||||||
- The configuration expects `static/icons/icon.icns`.
|
- The configuration expects `static/icons/icon.icns`.
|
||||||
- Create that file before running macOS packaging.
|
- 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$`.
|
||||||
|
|||||||
+11
-1
@@ -3,7 +3,17 @@ import tsParser from "@typescript-eslint/parser";
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: ["dist/**", "release/**", "lib/**"],
|
ignores: [
|
||||||
|
"dist/**",
|
||||||
|
"release/**",
|
||||||
|
"lib/**",
|
||||||
|
"node_modules/**",
|
||||||
|
".corepack/**",
|
||||||
|
".electron-cache/**",
|
||||||
|
".localappdata/**",
|
||||||
|
".npm-cache/**",
|
||||||
|
".npm-runtime-cache/**",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ["**/*.ts"],
|
files: ["**/*.ts"],
|
||||||
|
|||||||
+12
-9
@@ -10,26 +10,28 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist release",
|
"clean": "rimraf dist release lib",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-assets.mjs",
|
"build:bundle": "node scripts/build-scoreko-bundle.mjs",
|
||||||
|
"build:main": "tsc -p tsconfig.json",
|
||||||
|
"prepare:runtime": "node scripts/prepare-nodecg-runtime.mjs",
|
||||||
|
"build": "npm run clean && npm run build:bundle && npm run build:main && npm run prepare:runtime",
|
||||||
"start": "npm run build && electron .",
|
"start": "npm run build && electron .",
|
||||||
"dev": "concurrently -k \"npm:watch\" \"npm:dev:electron\"",
|
"dev": "concurrently -k \"npm:watch\" \"npm:dev:electron\"",
|
||||||
"watch": "tsc -p tsconfig.json --watch",
|
"watch": "tsc -p tsconfig.json --watch",
|
||||||
"dev:electron": "wait-on dist/main/main.js && electron .",
|
"dev:electron": "wait-on dist/main/main.js && electron .",
|
||||||
"pack": "npm run build && electron-builder --dir",
|
"pack": "npm run build && electron-builder --dir",
|
||||||
"rebuild:native": "node scripts/rebuild-nodecg-native.mjs",
|
"rebuild:native": "node scripts/rebuild-nodecg-native.mjs",
|
||||||
"rebuild:better-sqlite3": "electron-rebuild --version 39.5.1 --module-dir lib/nodecg/workspaces/database-adapter-sqlite-legacy --only better-sqlite3 -f",
|
"test": "rimraf dist && npm run build:main && node --test dist/tests/**/*.test.js",
|
||||||
"test": "npm run build && node --test dist/tests/**/*.test.js",
|
|
||||||
"doctor": "node scripts/doctor.mjs",
|
"doctor": "node scripts/doctor.mjs",
|
||||||
"lint": "eslint . --ext .ts,.js,.mjs",
|
"lint": "eslint . --ext .ts,.js,.mjs",
|
||||||
"lint:fix": "npm run lint -- --fix",
|
"lint:fix": "npm run lint -- --fix",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"format:write": "prettier --write .",
|
"format:write": "prettier --write .",
|
||||||
"dist:win": "npm run build && electron-builder --win",
|
"dist:win": "npm run build && npm run rebuild:native && electron-builder --win",
|
||||||
"dist:linux": "npm run build && electron-builder --linux AppImage",
|
"dist:linux": "npm run build && npm run rebuild:native && electron-builder --linux AppImage",
|
||||||
"dist:all": "npm run build && electron-builder --win --linux --mac",
|
"dist:all": "npm run build && npm run rebuild:native && electron-builder --win --linux --mac",
|
||||||
"dist:mac": "npm run build && electron-builder --mac"
|
"dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.scoreko.desktop",
|
"appId": "com.scoreko.desktop",
|
||||||
@@ -71,7 +73,8 @@
|
|||||||
"nsis"
|
"nsis"
|
||||||
],
|
],
|
||||||
"icon": "static/icons/icon.ico",
|
"icon": "static/icons/icon.ico",
|
||||||
"executableName": "scoreko"
|
"executableName": "scoreko",
|
||||||
|
"signAndEditExecutable": false
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
import {
|
||||||
|
bundleRoot,
|
||||||
|
bundleRootMarkers,
|
||||||
|
electronRoot,
|
||||||
|
generatedBundleEntries,
|
||||||
|
getLocalBinPath,
|
||||||
|
getPathInside,
|
||||||
|
} from "./build-config.mjs";
|
||||||
|
|
||||||
|
const nodeModulesPath = path.join(bundleRoot, "node_modules");
|
||||||
|
|
||||||
|
const missingMarkers = bundleRootMarkers
|
||||||
|
.map((entry) => path.join(bundleRoot, entry))
|
||||||
|
.filter((candidatePath) => !existsSync(candidatePath));
|
||||||
|
|
||||||
|
if (missingMarkers.length > 0) {
|
||||||
|
console.error(
|
||||||
|
[
|
||||||
|
`Scoreko bundle root was not found at: ${bundleRoot}`,
|
||||||
|
"This Electron package expects to live inside the Scoreko repository with the bundle project as its parent.",
|
||||||
|
...missingMarkers.map((candidatePath) => `Missing: ${candidatePath}`),
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(nodeModulesPath)) {
|
||||||
|
console.error(
|
||||||
|
[
|
||||||
|
"The Scoreko bundle dependencies are not installed.",
|
||||||
|
`Run this once from ${bundleRoot}:`,
|
||||||
|
" pnpm install",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const childEnv = {
|
||||||
|
...process.env,
|
||||||
|
COREPACK_HOME: process.env.COREPACK_HOME ?? path.join(electronRoot, ".corepack"),
|
||||||
|
PATH: `${path.join(bundleRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
function removeGeneratedOutput(relativePath) {
|
||||||
|
const targetPath = getPathInside(bundleRoot, relativePath);
|
||||||
|
rmSync(targetPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, args) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd: bundleRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
env: childEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error(`Could not run '${command} ${args.join(" ")}': ${result.error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of generatedBundleEntries) {
|
||||||
|
removeGeneratedOutput(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of ["shared/dist", "dashboard", "graphics", "extension"]) {
|
||||||
|
mkdirSync(path.join(bundleRoot, entry), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
runCommand(getLocalBinPath("vite"), ["build", "--configLoader", "runner"]);
|
||||||
|
runCommand(getLocalBinPath("tsc"), ["-b", "tsconfig.extension.json"]);
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
const distStatic = path.join(root, "dist", "static");
|
|
||||||
const sourceStatic = path.join(root, "static");
|
|
||||||
|
|
||||||
mkdirSync(distStatic, { recursive: true });
|
|
||||||
|
|
||||||
if (existsSync(sourceStatic)) {
|
|
||||||
cpSync(sourceStatic, distStatic, { recursive: true });
|
|
||||||
console.log("Copied static assets to dist/static");
|
|
||||||
} else {
|
|
||||||
console.warn("No static folder found, skipping copy-assets step");
|
|
||||||
}
|
|
||||||
+13
-11
@@ -3,8 +3,7 @@ import fs from "node:fs";
|
|||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const cwd = process.cwd();
|
import { bundleName, nodecgRuntimeRoot } from "./build-config.mjs";
|
||||||
const nodecgRootPath = path.resolve(cwd, "lib", "nodecg");
|
|
||||||
|
|
||||||
const checks = [];
|
const checks = [];
|
||||||
|
|
||||||
@@ -36,17 +35,20 @@ function parseIntInRange(name, fallback, min, max) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function checkNodecgInstall() {
|
function checkNodecgInstall() {
|
||||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
const indexPath = path.join(nodecgRuntimeRoot, "index.js");
|
||||||
const bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim();
|
const bootstrapPath = path.join(nodecgRuntimeRoot, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
||||||
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
|
const manifestPath = path.join(nodecgRuntimeRoot, ".scoreko-runtime.json");
|
||||||
|
const bundlePath = path.join(nodecgRuntimeRoot, "bundles", bundleName);
|
||||||
|
|
||||||
addCheck(fs.existsSync(nodecgRootPath), "NodeCG root", nodecgRootPath);
|
addCheck(fs.existsSync(nodecgRuntimeRoot), "Packaged NodeCG runtime", nodecgRuntimeRoot);
|
||||||
addCheck(fs.existsSync(indexPath), "NodeCG index.js", indexPath);
|
addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath);
|
||||||
addCheck(fs.existsSync(bundlePath), `Bundle '${bundleName}'`, bundlePath);
|
addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath);
|
||||||
|
addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath);
|
||||||
|
addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.accessSync(nodecgRootPath, fs.constants.R_OK | fs.constants.W_OK);
|
fs.accessSync(nodecgRuntimeRoot, fs.constants.R_OK | fs.constants.W_OK);
|
||||||
addCheck(true, "lib/nodecg permissions", "Read/write OK");
|
addCheck(true, "lib/nodecg permissions", "Read/write OK for local development");
|
||||||
} catch {
|
} catch {
|
||||||
addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg");
|
addCheck(false, "lib/nodecg permissions", "No read/write permissions in lib/nodecg");
|
||||||
}
|
}
|
||||||
@@ -82,7 +84,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const check of checks) {
|
for (const check of checks) {
|
||||||
const icon = check.ok ? "✅" : "❌";
|
const icon = check.ok ? "OK" : "FAIL";
|
||||||
console.log(`${icon} ${check.title}: ${check.details}`);
|
console.log(`${icon} ${check.title}: ${check.details}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
|
import {
|
||||||
|
bundleName,
|
||||||
|
bundleRoot,
|
||||||
|
getNpmCommand,
|
||||||
|
nodecgRuntimeNodeModules,
|
||||||
|
nodecgRuntimeRoot,
|
||||||
|
preparedBundleEntries,
|
||||||
|
requiredPreparedBundleEntries,
|
||||||
|
runtimeBundleRoot,
|
||||||
|
runtimeNpmCache,
|
||||||
|
} from "./build-config.mjs";
|
||||||
|
|
||||||
|
function readJson(filePath) {
|
||||||
|
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyIfExists(source, destination) {
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cpSync(source, destination, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
dereference: true,
|
||||||
|
filter: (sourcePath) => !sourcePath.split(path.sep).includes("node_modules"),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, cwd) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd,
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
npm_config_cache: runtimeNpmCache,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(`${command} ${args.join(" ")} failed: ${result.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command} ${args.join(" ")} failed with code ${result.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstalledNodecgVersion() {
|
||||||
|
const nodecgPackagePath = path.join(bundleRoot, "node_modules", "nodecg", "package.json");
|
||||||
|
|
||||||
|
if (!existsSync(nodecgPackagePath)) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"NodeCG is not installed in the parent project.",
|
||||||
|
`Expected: ${nodecgPackagePath}`,
|
||||||
|
`Run 'pnpm install' from ${bundleRoot} before packaging.`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return readJson(nodecgPackagePath).version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBundleBuildExists() {
|
||||||
|
for (const entry of requiredPreparedBundleEntries) {
|
||||||
|
const source = path.join(bundleRoot, entry);
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`The built Scoreko bundle is missing '${entry}'.`,
|
||||||
|
`Expected: ${source}`,
|
||||||
|
`Run 'pnpm build' from ${bundleRoot} before packaging.`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRuntimePackageJson() {
|
||||||
|
const bundlePackageJson = readJson(path.join(bundleRoot, "package.json"));
|
||||||
|
const dependencies = {
|
||||||
|
nodecg: getInstalledNodecgVersion(),
|
||||||
|
...(bundlePackageJson.dependencies ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
path.join(nodecgRuntimeRoot, "package.json"),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
private: true,
|
||||||
|
name: "scoreko-nodecg-runtime",
|
||||||
|
version: bundlePackageJson.version ?? "0.0.0",
|
||||||
|
description: "Packaged NodeCG runtime for Scoreko Desktop.",
|
||||||
|
type: "commonjs",
|
||||||
|
scripts: {
|
||||||
|
start: "node index.js",
|
||||||
|
},
|
||||||
|
dependencies,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
writeFileSync(path.join(nodecgRuntimeRoot, "index.js"), 'require("nodecg");\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyBundle() {
|
||||||
|
mkdirSync(runtimeBundleRoot, { recursive: true });
|
||||||
|
|
||||||
|
for (const entry of preparedBundleEntries) {
|
||||||
|
copyIfExists(path.join(bundleRoot, entry), path.join(runtimeBundleRoot, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeManifest() {
|
||||||
|
const bundlePackageJson = readJson(path.join(bundleRoot, "package.json"));
|
||||||
|
const runtimePackageJson = readJson(path.join(nodecgRuntimeRoot, "package.json"));
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
path.join(nodecgRuntimeRoot, ".scoreko-runtime.json"),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
bundleName,
|
||||||
|
bundleVersion: bundlePackageJson.version ?? "0.0.0",
|
||||||
|
nodecgVersion: runtimePackageJson.dependencies.nodecg,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installRuntimeDependencies() {
|
||||||
|
if (process.env.SCOREKO_SKIP_RUNTIME_NPM_INSTALL === "1") {
|
||||||
|
console.log("[prepare-runtime] Skipping runtime npm install by environment request.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
run(getNpmCommand(), ["install", "--omit=dev", "--no-audit", "--no-fund"], nodecgRuntimeRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
assertBundleBuildExists();
|
||||||
|
|
||||||
|
rmSync(nodecgRuntimeRoot, { recursive: true, force: true });
|
||||||
|
mkdirSync(nodecgRuntimeNodeModules, { recursive: true });
|
||||||
|
mkdirSync(path.join(nodecgRuntimeRoot, "bundles"), { recursive: true });
|
||||||
|
|
||||||
|
createRuntimePackageJson();
|
||||||
|
copyBundle();
|
||||||
|
installRuntimeDependencies();
|
||||||
|
writeManifest();
|
||||||
|
|
||||||
|
console.log(`[prepare-runtime] NodeCG runtime ready at ${nodecgRuntimeRoot}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
const root = process.cwd();
|
import { electronCache, electronRoot, getNpmCommand, nodecgRuntimeRoot, runtimeNpmCache } from "./build-config.mjs";
|
||||||
const nodecgDir = path.join(root, "lib", "nodecg");
|
|
||||||
const sqliteLegacyDir = path.join(nodecgDir, "workspaces", "database-adapter-sqlite-legacy");
|
|
||||||
|
|
||||||
const moduleDirs = [nodecgDir, sqliteLegacyDir].filter((dir) => existsSync(path.join(dir, "package.json")));
|
const packageJson = JSON.parse(readFileSync(path.join(electronRoot, "package.json"), "utf8"));
|
||||||
|
const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron;
|
||||||
|
|
||||||
if (moduleDirs.length === 0) {
|
if (!electronVersion) {
|
||||||
console.error("No NodeCG package folders found. Expected lib/nodecg and/or workspaces.");
|
console.error("Could not determine Electron version from package.json.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(path.join(nodecgRuntimeRoot, "package.json"))) {
|
||||||
|
console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,8 +27,10 @@ function run(command, args, cwd) {
|
|||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
npm_config_runtime: "electron",
|
npm_config_runtime: "electron",
|
||||||
npm_config_target: "39.5.1",
|
npm_config_target: electronVersion,
|
||||||
npm_config_disturl: "https://electronjs.org/headers",
|
npm_config_disturl: "https://electronjs.org/headers",
|
||||||
|
npm_config_cache: runtimeNpmCache,
|
||||||
|
ELECTRON_CACHE: electronCache,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,19 +44,13 @@ function run(command, args, cwd) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const dir of moduleDirs) {
|
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgRuntimeRoot}`);
|
||||||
if (dir === sqliteLegacyDir) {
|
await run(getNpmCommand(), [
|
||||||
console.log(`\n[rebuild-native] Ensuring sqlite legacy workspace deps in: ${dir}`);
|
"rebuild",
|
||||||
await run("npm", ["install"], dir);
|
"better-sqlite3",
|
||||||
await run("npm", ["install", "bindings", "--no-save"], dir);
|
"--runtime=electron",
|
||||||
}
|
`--target=${electronVersion}`,
|
||||||
|
"--dist-url=https://electronjs.org/headers",
|
||||||
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 in: ${dir}`);
|
], nodecgRuntimeRoot);
|
||||||
await run(
|
|
||||||
"npm",
|
|
||||||
["rebuild", "better-sqlite3", "--runtime=electron", "--target=39.5.1", "--dist-url=https://electronjs.org/headers"],
|
|
||||||
dir,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n[rebuild-native] Done.");
|
console.log("\n[rebuild-native] Done.");
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
|
import { NodecgProcessManager } from "../nodecg/process-manager";
|
||||||
|
import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner";
|
||||||
|
import { getRemainingDelayMs } from "../utils/timing";
|
||||||
|
import { createShutdownService, ShutdownService } from "./shutdown-service";
|
||||||
|
|
||||||
|
export 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>;
|
||||||
|
restore: () => void;
|
||||||
|
show: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApplicationControllerConfig = {
|
||||||
|
appConfig: AppRuntimeConfig;
|
||||||
|
appVersion: string;
|
||||||
|
isPackaged: boolean;
|
||||||
|
isWindows: boolean;
|
||||||
|
paths: {
|
||||||
|
rootPath: string;
|
||||||
|
sourceNodecgRuntimePath: string;
|
||||||
|
userDataPath: string;
|
||||||
|
nodecgBaseUrl: string;
|
||||||
|
mainDashboardUrl: string;
|
||||||
|
loadingDashboardUrl: string;
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
relaunch: () => void;
|
||||||
|
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>;
|
||||||
|
stopNodecgGracefully: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createApplicationController({
|
||||||
|
appConfig,
|
||||||
|
appVersion,
|
||||||
|
deps,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = "preparing";
|
||||||
|
const preparedRuntime = deps.prepareRuntime({
|
||||||
|
sourceRuntimePath: paths.sourceNodecgRuntimePath,
|
||||||
|
userDataPath: paths.userDataPath,
|
||||||
|
appVersion,
|
||||||
|
bundleName: appConfig.bundleName,
|
||||||
|
log: deps.log,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (preparedRuntime.installed && isPackaged) {
|
||||||
|
deps.log("Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.");
|
||||||
|
deps.relaunch();
|
||||||
|
deps.exit(0);
|
||||||
|
state = "stopped";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodecgManager = deps.createNodecgProcessManager(preparedRuntime.runtimePath);
|
||||||
|
|
||||||
|
mainWindow = deps.createMainWindow();
|
||||||
|
loadingWindow = deps.createLoadingWindow();
|
||||||
|
|
||||||
|
state = "starting";
|
||||||
|
await startNodecg();
|
||||||
|
|
||||||
|
if (!loadingWindow || loadingWindow.isDestroyed()) {
|
||||||
|
state = "ready";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadingWindow.loadURL(paths.loadingDashboardUrl);
|
||||||
|
loadingWindow.show();
|
||||||
|
|
||||||
|
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;
|
||||||
|
closeLoadingWindow();
|
||||||
|
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";
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
activate,
|
||||||
|
focusExistingWindow,
|
||||||
|
getState: () => state,
|
||||||
|
launch,
|
||||||
|
stopNodecgGracefully,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { app, BrowserWindow } from "electron";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { getRuntimeConfig } from "../config/runtime-config";
|
||||||
|
import { showFatalError, log } from "../errors/error-presenter";
|
||||||
|
import { createNodecgProcessManager } from "../nodecg/process-manager";
|
||||||
|
import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner";
|
||||||
|
import { scheduleUpdateCheck } from "../updates/update-service";
|
||||||
|
import { createLoadingWindow, createMainWindow } from "../windows/window-service";
|
||||||
|
import { createApplicationController } from "./application-controller";
|
||||||
|
import { getApplicationPaths } from "./paths";
|
||||||
|
|
||||||
|
export function bootstrap(): void {
|
||||||
|
const appConfig = getRuntimeConfig();
|
||||||
|
const isDev = !app.isPackaged;
|
||||||
|
const paths = getApplicationPaths({
|
||||||
|
appConfig,
|
||||||
|
appDataPath: app.getPath("appData"),
|
||||||
|
compiledMainDir: path.resolve(__dirname, ".."),
|
||||||
|
isDev,
|
||||||
|
resourcesPath: process.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,
|
||||||
|
relaunch: () => app.relaunch(),
|
||||||
|
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) => {
|
||||||
|
showFatalError("No se pudo iniciar Scoreko.", error);
|
||||||
|
app.exit(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,86 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
|
|
||||||
|
export type ApplicationPaths = {
|
||||||
|
rootPath: string;
|
||||||
|
sourceNodecgRuntimePath: string;
|
||||||
|
userDataPath: string;
|
||||||
|
nodecgBaseUrl: string;
|
||||||
|
mainDashboardUrl: string;
|
||||||
|
loadingDashboardUrl: 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 getDefaultUpdateConfigPath(rootPath: string): string {
|
||||||
|
return path.join(rootPath, "static", "updates.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
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" | "loadingDashboardRoute" | "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),
|
||||||
|
loadingDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.loadingDashboardRoute),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ export type AppRuntimeConfig = {
|
|||||||
loadDelayMs: number;
|
loadDelayMs: number;
|
||||||
startupTimeoutMs: number;
|
startupTimeoutMs: number;
|
||||||
nodecgKillTimeoutMs: number;
|
nodecgKillTimeoutMs: number;
|
||||||
|
updatesEnabled: boolean;
|
||||||
|
updateApiUrl?: string;
|
||||||
|
updateReleasePageUrl?: string;
|
||||||
|
updateAssetPattern?: string;
|
||||||
|
updateConfigPathOverride?: string;
|
||||||
|
updateCheckDelayMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIN_TCP_PORT = 1;
|
const MIN_TCP_PORT = 1;
|
||||||
@@ -29,6 +35,12 @@ export function getRuntimeConfig(): AppRuntimeConfig {
|
|||||||
loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000),
|
loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000),
|
||||||
startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000),
|
startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000),
|
||||||
nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000),
|
nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000),
|
||||||
|
updatesEnabled: parseEnvBool("SCOREKO_UPDATES_ENABLED", true),
|
||||||
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +72,49 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
|
|||||||
|
|
||||||
const parsedValue = Number.parseInt(rawValue, 10);
|
const parsedValue = Number.parseInt(rawValue, 10);
|
||||||
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
|
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
|
||||||
throw new Error(`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`);
|
throw new Error(
|
||||||
|
`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsedValue;
|
return parsedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseEnvBool(name: string, fallback: boolean): boolean {
|
||||||
|
const rawValue = process.env[name]?.trim().toLowerCase();
|
||||||
|
if (!rawValue) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["1", "true", "yes", "on"].includes(rawValue)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["0", "false", "no", "off"].includes(rawValue)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`The ${name} variable must be a boolean. Received value: '${process.env[name]}'.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOptionalHttpUrl(name: string): string | undefined {
|
||||||
|
const rawValue = getOptionalEnv(name);
|
||||||
|
if (!rawValue) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(rawValue);
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
throw new Error("unsupported protocol");
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
throw new Error(`The ${name} variable must be a valid HTTP(S) URL. Received value: '${rawValue}'.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseEnvPort(name: string, fallback: string): string {
|
export function parseEnvPort(name: string, fallback: string): string {
|
||||||
const rawValue = getEnv(name, fallback);
|
const rawValue = getEnv(name, fallback);
|
||||||
const parsedValue = Number.parseInt(rawValue, 10);
|
const parsedValue = Number.parseInt(rawValue, 10);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const NODE_RUNTIME_NAME = "electron internal node";
|
export const NODE_RUNTIME_NAME = "Electron embedded Node.js";
|
||||||
export const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f";
|
export const DEFAULT_WINDOW_BACKGROUND = "#0f0f0f";
|
||||||
|
|
||||||
export const DEFAULT_WINDOW_SIZE = {
|
export const DEFAULT_WINDOW_SIZE = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { app, dialog } from "electron";
|
import { app, dialog } from "electron";
|
||||||
|
|
||||||
import { logger } from "./logger";
|
import { logger } from "../logging/logger";
|
||||||
|
|
||||||
export function log(...args: unknown[]): void {
|
export function log(...args: unknown[]): void {
|
||||||
logger.info("runtime", { args });
|
logger.info("runtime", { args });
|
||||||
|
|||||||
+2
-187
@@ -1,188 +1,3 @@
|
|||||||
import { app, BrowserWindow } from "electron";
|
import { bootstrap } from "./app/bootstrap";
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { getRuntimeConfig } from "./config/runtime-config";
|
bootstrap();
|
||||||
import { showFatalError, log } from "./errors/error-presenter";
|
|
||||||
import { createNodecgProcessManager } from "./nodecg/process-manager";
|
|
||||||
import { getRemainingDelayMs } from "./utils/timing";
|
|
||||||
import { createLoadingWindow, createMainWindow } from "./windows/window-factory";
|
|
||||||
|
|
||||||
const appConfig = getRuntimeConfig();
|
|
||||||
|
|
||||||
// Force a stable userData folder name; overridable via SCOREKO_APP_USER_DATA_DIRECTORY.
|
|
||||||
app.setName(appConfig.title);
|
|
||||||
app.setPath("userData", path.join(app.getPath("appData"), appConfig.userDataDirectoryName));
|
|
||||||
|
|
||||||
const isDev = !app.isPackaged;
|
|
||||||
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath;
|
|
||||||
const nodecgRootPath = path.resolve(rootPath, "lib", "nodecg");
|
|
||||||
const mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`;
|
|
||||||
const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`;
|
|
||||||
const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`;
|
|
||||||
|
|
||||||
const hasSingleInstanceLock = app.requestSingleInstanceLock();
|
|
||||||
|
|
||||||
if (!hasSingleInstanceLock) {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodecgManager = createNodecgProcessManager({
|
|
||||||
isDev,
|
|
||||||
nodecgRootPath,
|
|
||||||
nodecgBaseUrl,
|
|
||||||
appConfig,
|
|
||||||
log,
|
|
||||||
});
|
|
||||||
|
|
||||||
type AppShutdownState = "running" | "stopping" | "stopped";
|
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
|
||||||
let loadingWindow: BrowserWindow | null = null;
|
|
||||||
let shutdownState: AppShutdownState = "running";
|
|
||||||
|
|
||||||
function focusExistingWindow(): void {
|
|
||||||
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow;
|
|
||||||
|
|
||||||
if (!targetWindow || targetWindow.isDestroyed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetWindow.isMinimized()) {
|
|
||||||
targetWindow.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
targetWindow.show();
|
|
||||||
targetWindow.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launchApplication(): Promise<void> {
|
|
||||||
// We create both windows early so startup feels instant while NodeCG is booting in the background.
|
|
||||||
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
|
|
||||||
loadingWindow = createLoadingWindow({ appConfig, rootPath });
|
|
||||||
|
|
||||||
await nodecgManager.startNodecgProcess();
|
|
||||||
|
|
||||||
await nodecgManager.waitForNodecgReady(Date.now());
|
|
||||||
|
|
||||||
if (!loadingWindow || loadingWindow.isDestroyed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadingWindow.loadURL(loadingDashboardUrl);
|
|
||||||
loadingWindow.show();
|
|
||||||
|
|
||||||
const loadingShownAt = Date.now();
|
|
||||||
|
|
||||||
if (!mainWindow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await mainWindow.loadURL(mainDashboardUrl);
|
|
||||||
|
|
||||||
// Keep the loading overlay visible for a minimum amount of time to avoid abrupt flashes.
|
|
||||||
const remainingLoadingDelay = getRemainingDelayMs(appConfig.loadDelayMs, loadingShownAt);
|
|
||||||
if (remainingLoadingDelay > 0) {
|
|
||||||
await sleep(remainingLoadingDelay);
|
|
||||||
}
|
|
||||||
|
|
||||||
mainWindow.show();
|
|
||||||
closeLoadingWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLoadingWindow(): void {
|
|
||||||
if (!loadingWindow || loadingWindow.isDestroyed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingWindow.close();
|
|
||||||
loadingWindow = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopNodecgGracefully(): Promise<void> {
|
|
||||||
if (shutdownState === "stopped") {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shutdownState === "stopping") {
|
|
||||||
return nodecgManager.stopNodecgProcessGracefully();
|
|
||||||
}
|
|
||||||
|
|
||||||
shutdownState = "stopping";
|
|
||||||
|
|
||||||
return nodecgManager.stopNodecgProcessGracefully().finally(() => {
|
|
||||||
shutdownState = "stopped";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
app.on("ready", () => {
|
|
||||||
if (!hasSingleInstanceLock) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
app.setAppUserModelId(appConfig.userModelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
launchApplication().catch((error: unknown) => {
|
|
||||||
showFatalError("No se pudo iniciar Scoreko.", error);
|
|
||||||
closeLoadingWindow();
|
|
||||||
app.exit(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("second-instance", () => {
|
|
||||||
focusExistingWindow();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("activate", async () => {
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
|
||||||
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
|
|
||||||
await mainWindow.loadURL(mainDashboardUrl);
|
|
||||||
mainWindow.show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("window-all-closed", () => {
|
|
||||||
if (process.platform !== "darwin") {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("before-quit", (event) => {
|
|
||||||
if (shutdownState !== "running") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block the default quit flow until we ask NodeCG to stop cleanly.
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
stopNodecgGracefully().finally(() => {
|
|
||||||
app.quit();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("will-quit", () => {
|
|
||||||
if (shutdownState === "running") {
|
|
||||||
void stopNodecgGracefully();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("exit", () => {
|
|
||||||
if (shutdownState === "running") {
|
|
||||||
void stopNodecgGracefully();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
|
||||||
showFatalError("Unexpected error in Electron main process.", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason) => {
|
|
||||||
showFatalError("Unhandled promise in Electron main process.", reason);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ChildProcess, SpawnOptions } from "node:child_process";
|
||||||
|
|
||||||
|
export type PlatformProcessKillerDeps = {
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess;
|
||||||
|
killProcess: (pid: number, signal: NodeJS.Signals) => void;
|
||||||
|
log: (...args: unknown[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
import { NODE_RUNTIME_NAME } from "../constants";
|
import { NODE_RUNTIME_NAME } from "../constants";
|
||||||
|
import { killProcessTree } from "./platform-process-killer";
|
||||||
|
|
||||||
type NodecgProcessManagerConfig = {
|
type NodecgProcessManagerConfig = {
|
||||||
isDev: boolean;
|
isDev: boolean;
|
||||||
@@ -31,12 +32,14 @@ type NodecgProcessManagerDeps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type NodecgProcessManager = {
|
export type NodecgProcessManager = {
|
||||||
startNodecgProcess: () => Promise<ChildProcess>;
|
startNodecgProcess: () => Promise<void>;
|
||||||
waitForNodecgReady: (startTime: number) => Promise<void>;
|
waitForNodecgReady: (startTime: number) => Promise<void>;
|
||||||
stopNodecgProcessGracefully: () => Promise<void>;
|
stopNodecgProcessGracefully: () => Promise<void>;
|
||||||
getProcess: () => ChildProcess | null;
|
getState: () => NodecgProcessState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
|
||||||
|
|
||||||
export function createNodecgProcessManager({
|
export function createNodecgProcessManager({
|
||||||
isDev,
|
isDev,
|
||||||
nodecgRootPath,
|
nodecgRootPath,
|
||||||
@@ -48,63 +51,97 @@ export function createNodecgProcessManager({
|
|||||||
const resolvedDeps = resolveDeps(deps);
|
const resolvedDeps = resolveDeps(deps);
|
||||||
|
|
||||||
let nodecgProcess: ChildProcess | null = null;
|
let nodecgProcess: ChildProcess | null = null;
|
||||||
|
let nodecgState: NodecgProcessState = "idle";
|
||||||
|
let startNodecgPromise: Promise<void> | null = null;
|
||||||
let stopNodecgPromise: Promise<void> | null = null;
|
let stopNodecgPromise: Promise<void> | null = null;
|
||||||
let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||||
let lastStderrLine: string | null = null;
|
let lastStderrLine: string | null = null;
|
||||||
|
|
||||||
const startNodecgProcess = async (): Promise<ChildProcess> => {
|
const startNodecgProcess = (): Promise<void> => {
|
||||||
// Fail fast with actionable errors before spawning child processes.
|
if (nodecgProcess && nodecgState === "running") {
|
||||||
validateNodecgInstall(
|
return Promise.resolve();
|
||||||
nodecgRootPath,
|
|
||||||
appConfig.bundleName,
|
|
||||||
resolvedDeps.pathExists,
|
|
||||||
resolvedDeps.hasReadWriteAccess,
|
|
||||||
);
|
|
||||||
|
|
||||||
const portAsNumber = Number.parseInt(appConfig.nodecgPort, 10);
|
|
||||||
const isPortAvailable = await resolvedDeps.probePortAvailable(portAsNumber);
|
|
||||||
if (!isPortAvailable) {
|
|
||||||
throw new Error(
|
|
||||||
`Port ${appConfig.nodecgPort} is already in use. Stop the process using it or set NODECG_PORT before starting.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexPath = path.join(nodecgRootPath, "index.js");
|
if (startNodecgPromise) {
|
||||||
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
|
return startNodecgPromise;
|
||||||
cwd: nodecgRootPath,
|
}
|
||||||
env: {
|
|
||||||
...resolvedDeps.env,
|
|
||||||
NODE_ENV: isDev ? "development" : "production",
|
|
||||||
NODECG_PORT: appConfig.nodecgPort,
|
|
||||||
ELECTRON_RUN_AS_NODE: "1",
|
|
||||||
},
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
detached: resolvedDeps.platform !== "win32",
|
|
||||||
shell: resolvedDeps.platform === "win32",
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stdout?.on("data", (chunk) => {
|
if (nodecgState === "stopping") {
|
||||||
resolvedDeps.stdoutWrite(String(chunk));
|
return Promise.reject(new Error("Cannot start NodeCG while shutdown is in progress."));
|
||||||
});
|
}
|
||||||
|
|
||||||
child.stderr?.on("data", (chunk) => {
|
nodecgState = "starting";
|
||||||
const line = String(chunk);
|
startNodecgPromise = (async () => {
|
||||||
lastStderrLine = line.trim().length > 0 ? line.trim() : lastStderrLine;
|
// Fail fast with actionable errors before spawning child processes.
|
||||||
resolvedDeps.stderrWrite(line);
|
validateNodecgInstall(
|
||||||
});
|
nodecgRootPath,
|
||||||
|
appConfig.bundleName,
|
||||||
|
resolvedDeps.pathExists,
|
||||||
|
resolvedDeps.hasReadWriteAccess,
|
||||||
|
);
|
||||||
|
|
||||||
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
|
const portAsNumber = Number.parseInt(appConfig.nodecgPort, 10);
|
||||||
|
const isPortAvailable = await resolvedDeps.probePortAvailable(portAsNumber);
|
||||||
|
if (!isPortAvailable) {
|
||||||
|
throw new Error(
|
||||||
|
`Port ${appConfig.nodecgPort} is already in use. Stop the process using it or set NODECG_PORT before starting.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
const indexPath = path.join(nodecgRootPath, "index.js");
|
||||||
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
|
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
|
||||||
lastExit = { code, signal };
|
cwd: nodecgRootPath,
|
||||||
nodecgProcess = null;
|
env: {
|
||||||
});
|
...resolvedDeps.env,
|
||||||
|
NODE_ENV: isDev ? "development" : "production",
|
||||||
|
NODECG_PORT: appConfig.nodecgPort,
|
||||||
|
ELECTRON_RUN_AS_NODE: "1",
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: resolvedDeps.platform !== "win32",
|
||||||
|
shell: false,
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
|
||||||
lastExit = null;
|
child.stdout?.on("data", (chunk) => {
|
||||||
lastStderrLine = null;
|
resolvedDeps.stdoutWrite(String(chunk));
|
||||||
nodecgProcess = child;
|
});
|
||||||
return child;
|
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
const line = String(chunk);
|
||||||
|
lastStderrLine = line.trim().length > 0 ? line.trim() : lastStderrLine;
|
||||||
|
resolvedDeps.stderrWrite(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
|
||||||
|
lastExit = { code, signal };
|
||||||
|
|
||||||
|
if (nodecgProcess === child) {
|
||||||
|
nodecgProcess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodecgState !== "stopping") {
|
||||||
|
nodecgState = code === 0 ? "stopped" : "failed";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
lastExit = null;
|
||||||
|
lastStderrLine = null;
|
||||||
|
nodecgProcess = child;
|
||||||
|
nodecgState = "running";
|
||||||
|
})()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
nodecgState = "failed";
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
startNodecgPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return startNodecgPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const waitForNodecgReady = async (startTime: number): Promise<void> => {
|
const waitForNodecgReady = async (startTime: number): Promise<void> => {
|
||||||
@@ -121,7 +158,7 @@ export function createNodecgProcessManager({
|
|||||||
exitDetails,
|
exitDetails,
|
||||||
stderrDetails,
|
stderrDetails,
|
||||||
`NodeCG path: ${nodecgRootPath}`,
|
`NodeCG path: ${nodecgRootPath}`,
|
||||||
"Check that lib/nodecg dependencies are installed and the bundle exists.",
|
"Check that the packaged runtime was installed correctly and the bundle exists.",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -148,6 +185,7 @@ export function createNodecgProcessManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!nodecgProcess || nodecgProcess.killed) {
|
if (!nodecgProcess || nodecgProcess.killed) {
|
||||||
|
nodecgState = "stopped";
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,18 +194,35 @@ export function createNodecgProcessManager({
|
|||||||
|
|
||||||
if (typeof pid !== "number") {
|
if (typeof pid !== "number") {
|
||||||
log("NodeCG pid unavailable, skipping graceful stop");
|
log("NodeCG pid unavailable, skipping graceful stop");
|
||||||
|
nodecgProcess = null;
|
||||||
|
nodecgState = "stopped";
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nodecgState = "stopping";
|
||||||
log(`Stopping NodeCG pid=${pid}`);
|
log(`Stopping NodeCG pid=${pid}`);
|
||||||
killNodecgProcessTree(pid, "SIGTERM", log, resolvedDeps);
|
killProcessTree(pid, "SIGTERM", {
|
||||||
|
platform: resolvedDeps.platform,
|
||||||
|
spawnProcess: resolvedDeps.spawnProcess,
|
||||||
|
killProcess: resolvedDeps.killProcess,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
|
||||||
stopNodecgPromise = new Promise((resolve) => {
|
stopNodecgPromise = new Promise((resolve) => {
|
||||||
|
let completed = false;
|
||||||
|
|
||||||
const complete = () => {
|
const complete = () => {
|
||||||
|
if (completed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
completed = true;
|
||||||
|
|
||||||
if (nodecgProcess === processToStop) {
|
if (nodecgProcess === processToStop) {
|
||||||
nodecgProcess = null;
|
nodecgProcess = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nodecgState = "stopped";
|
||||||
stopNodecgPromise = null;
|
stopNodecgPromise = null;
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
@@ -180,7 +235,13 @@ export function createNodecgProcessManager({
|
|||||||
() => {
|
() => {
|
||||||
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
if (processToStop.exitCode === null && processToStop.signalCode === null) {
|
||||||
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
|
||||||
killNodecgProcessTree(pid, "SIGKILL", log, resolvedDeps);
|
killProcessTree(pid, "SIGKILL", {
|
||||||
|
platform: resolvedDeps.platform,
|
||||||
|
spawnProcess: resolvedDeps.spawnProcess,
|
||||||
|
killProcess: resolvedDeps.killProcess,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
complete();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Math.max(0, appConfig.nodecgKillTimeoutMs),
|
Math.max(0, appConfig.nodecgKillTimeoutMs),
|
||||||
@@ -194,7 +255,7 @@ export function createNodecgProcessManager({
|
|||||||
startNodecgProcess,
|
startNodecgProcess,
|
||||||
waitForNodecgReady,
|
waitForNodecgReady,
|
||||||
stopNodecgProcessGracefully,
|
stopNodecgProcessGracefully,
|
||||||
getProcess: () => nodecgProcess,
|
getState: () => nodecgState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +295,7 @@ function validateNodecgInstall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!pathExists(indexPath)) {
|
if (!pathExists(indexPath)) {
|
||||||
throw new Error(`${indexPath} was not found. Copy a full NodeCG installation into lib/nodecg.`);
|
throw new Error(`${indexPath} was not found. Build the packaged NodeCG runtime before starting Electron.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!pathExists(nodecgBootstrapPath)) {
|
if (!pathExists(nodecgBootstrapPath)) {
|
||||||
@@ -242,8 +303,8 @@ function validateNodecgInstall(
|
|||||||
[
|
[
|
||||||
"NodeCG is present but internal dependencies are missing.",
|
"NodeCG is present but internal dependencies are missing.",
|
||||||
`Not found: ${nodecgBootstrapPath}`,
|
`Not found: ${nodecgBootstrapPath}`,
|
||||||
"Solution: enter lib/nodecg and install dependencies:",
|
"Solution: rebuild the packaged runtime:",
|
||||||
" npm install",
|
" npm run prepare:runtime",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -253,7 +314,7 @@ function validateNodecgInstall(
|
|||||||
[
|
[
|
||||||
`Bundle '${bundleName}' was not found.`,
|
`Bundle '${bundleName}' was not found.`,
|
||||||
`Expected path: ${bundlePath}`,
|
`Expected path: ${bundlePath}`,
|
||||||
"Copy/clone your bundle inside lib/nodecg/bundles before running Electron.",
|
"Build and package the Scoreko bundle before running Electron.",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -305,39 +366,6 @@ function probePortAvailable(port: number): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function killNodecgProcessTree(
|
|
||||||
pid: number,
|
|
||||||
signal: NodeJS.Signals,
|
|
||||||
log: (...args: unknown[]) => void,
|
|
||||||
deps: Pick<NodecgProcessManagerDeps, "platform" | "spawnProcess" | "killProcess">,
|
|
||||||
): boolean {
|
|
||||||
if (deps.platform === "win32") {
|
|
||||||
const force = signal === "SIGKILL" ? "/F" : "";
|
|
||||||
const killer = deps.spawnProcess("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
|
|
||||||
stdio: "ignore",
|
|
||||||
shell: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
killer.on("error", (error) => {
|
|
||||||
log(`taskkill error for pid=${pid}`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
deps.killProcess(-pid, signal);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
deps.killProcess(pid, signal);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
|
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimer(resolve, ms);
|
setTimer(resolve, ms);
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { getManagedNodecgRuntimePath } from "../app/paths";
|
||||||
|
|
||||||
|
type RuntimeProvisionerConfig = {
|
||||||
|
sourceRuntimePath: string;
|
||||||
|
userDataPath: string;
|
||||||
|
appVersion: string;
|
||||||
|
bundleName: string;
|
||||||
|
log: (...args: unknown[]) => void;
|
||||||
|
deps?: Partial<RuntimeProvisionerDeps>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuntimeProvisionerDeps = {
|
||||||
|
existsSync: (candidatePath: string) => boolean;
|
||||||
|
mkdirSync: (candidatePath: string, options: { recursive: true }) => unknown;
|
||||||
|
rmSync: (candidatePath: string, options: { recursive: true; force: true }) => unknown;
|
||||||
|
cpSync: (
|
||||||
|
sourcePath: string,
|
||||||
|
targetPath: string,
|
||||||
|
options: {
|
||||||
|
recursive: true;
|
||||||
|
force: true;
|
||||||
|
dereference: true;
|
||||||
|
filter: (sourcePath: string) => boolean;
|
||||||
|
},
|
||||||
|
) => unknown;
|
||||||
|
readFileSync: (filePath: string) => string | Buffer;
|
||||||
|
writeFileSync: (filePath: string, content: string) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PreparedNodecgRuntime = {
|
||||||
|
runtimePath: string;
|
||||||
|
installed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuntimeManifest = {
|
||||||
|
appVersion?: unknown;
|
||||||
|
bundleName?: unknown;
|
||||||
|
sourceRuntime?: RuntimeManifest | null;
|
||||||
|
bundleVersion?: unknown;
|
||||||
|
generatedAt?: unknown;
|
||||||
|
nodecgVersion?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MANAGED_RUNTIME_MARKER = ".scoreko-installed-runtime.json";
|
||||||
|
const WRITABLE_NODECG_DIRS = ["cfg", "db", "logs"] as const;
|
||||||
|
const MANAGED_RUNTIME_ENTRIES = ["index.js", "package.json", "package-lock.json", "node_modules", "bundles"] as const;
|
||||||
|
|
||||||
|
export function prepareUserNodecgRuntime({
|
||||||
|
sourceRuntimePath,
|
||||||
|
userDataPath,
|
||||||
|
appVersion,
|
||||||
|
bundleName,
|
||||||
|
log,
|
||||||
|
deps,
|
||||||
|
}: RuntimeProvisionerConfig): PreparedNodecgRuntime {
|
||||||
|
const resolvedDeps = resolveDeps(deps);
|
||||||
|
const targetRuntimePath = getManagedNodecgRuntimePath(userDataPath);
|
||||||
|
|
||||||
|
validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync);
|
||||||
|
resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true });
|
||||||
|
|
||||||
|
const installed = shouldInstallRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps);
|
||||||
|
|
||||||
|
if (installed) {
|
||||||
|
log(`Installing managed NodeCG runtime into ${targetRuntimePath}`);
|
||||||
|
installManagedRuntime(sourceRuntimePath, targetRuntimePath, appVersion, bundleName, resolvedDeps);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const writableDir of WRITABLE_NODECG_DIRS) {
|
||||||
|
resolvedDeps.mkdirSync(path.join(targetRuntimePath, writableDir), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { runtimePath: targetRuntimePath, installed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDeps(deps?: Partial<RuntimeProvisionerDeps>): RuntimeProvisionerDeps {
|
||||||
|
return {
|
||||||
|
existsSync: deps?.existsSync ?? fs.existsSync,
|
||||||
|
mkdirSync: deps?.mkdirSync ?? fs.mkdirSync,
|
||||||
|
rmSync: deps?.rmSync ?? fs.rmSync,
|
||||||
|
cpSync: deps?.cpSync ?? fs.cpSync,
|
||||||
|
readFileSync: deps?.readFileSync ?? fs.readFileSync,
|
||||||
|
writeFileSync: deps?.writeFileSync ?? fs.writeFileSync,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSourceRuntime(
|
||||||
|
sourceRuntimePath: string,
|
||||||
|
bundleName: string,
|
||||||
|
existsSync: RuntimeProvisionerDeps["existsSync"],
|
||||||
|
): void {
|
||||||
|
const requiredPaths = [
|
||||||
|
sourceRuntimePath,
|
||||||
|
path.join(sourceRuntimePath, "index.js"),
|
||||||
|
path.join(sourceRuntimePath, "package.json"),
|
||||||
|
path.join(sourceRuntimePath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"),
|
||||||
|
path.join(sourceRuntimePath, "bundles", bundleName, "package.json"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const missingPaths = requiredPaths.filter((candidatePath) => !existsSync(candidatePath));
|
||||||
|
|
||||||
|
if (missingPaths.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
"The packaged NodeCG runtime is incomplete.",
|
||||||
|
...missingPaths.map((missingPath) => `Missing: ${missingPath}`),
|
||||||
|
"Build the runtime with 'npm run prepare:runtime' before packaging or starting Electron.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldInstallRuntime(
|
||||||
|
sourceRuntimePath: string,
|
||||||
|
targetRuntimePath: string,
|
||||||
|
appVersion: string,
|
||||||
|
bundleName: string,
|
||||||
|
deps: RuntimeProvisionerDeps,
|
||||||
|
): boolean {
|
||||||
|
const targetBootstrap = path.join(targetRuntimePath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
|
||||||
|
const targetBundlePackage = path.join(targetRuntimePath, "bundles", bundleName, "package.json");
|
||||||
|
|
||||||
|
if (!deps.existsSync(targetBootstrap) || !deps.existsSync(targetBundlePackage)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMarker = readJson(path.join(targetRuntimePath, MANAGED_RUNTIME_MARKER), deps);
|
||||||
|
const sourceMarker = readJson(path.join(sourceRuntimePath, ".scoreko-runtime.json"), deps);
|
||||||
|
|
||||||
|
return (
|
||||||
|
targetMarker?.appVersion !== appVersion ||
|
||||||
|
targetMarker?.bundleName !== bundleName ||
|
||||||
|
targetMarker?.sourceRuntime?.bundleVersion !== sourceMarker?.bundleVersion ||
|
||||||
|
targetMarker?.sourceRuntime?.generatedAt !== sourceMarker?.generatedAt ||
|
||||||
|
targetMarker?.sourceRuntime?.nodecgVersion !== sourceMarker?.nodecgVersion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installManagedRuntime(
|
||||||
|
sourceRuntimePath: string,
|
||||||
|
targetRuntimePath: string,
|
||||||
|
appVersion: string,
|
||||||
|
bundleName: string,
|
||||||
|
deps: RuntimeProvisionerDeps,
|
||||||
|
): void {
|
||||||
|
for (const entry of MANAGED_RUNTIME_ENTRIES) {
|
||||||
|
deps.rmSync(path.join(targetRuntimePath, entry), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.cpSync(sourceRuntimePath, targetRuntimePath, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
dereference: true,
|
||||||
|
filter: (sourcePath) => {
|
||||||
|
const relativePath = path.relative(sourceRuntimePath, sourcePath);
|
||||||
|
const firstSegment = relativePath.split(path.sep)[0];
|
||||||
|
return !WRITABLE_NODECG_DIRS.includes(firstSegment as (typeof WRITABLE_NODECG_DIRS)[number]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceRuntime = readJson(path.join(sourceRuntimePath, ".scoreko-runtime.json"), deps);
|
||||||
|
deps.writeFileSync(
|
||||||
|
path.join(targetRuntimePath, MANAGED_RUNTIME_MARKER),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
appVersion,
|
||||||
|
bundleName,
|
||||||
|
sourceRuntime,
|
||||||
|
installedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(
|
||||||
|
filePath: string,
|
||||||
|
deps: Pick<RuntimeProvisionerDeps, "existsSync" | "readFileSync">,
|
||||||
|
): RuntimeManifest | null {
|
||||||
|
if (!deps.existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(deps.readFileSync(filePath)));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
import { getDefaultUpdateConfigPath } from "../app/paths";
|
||||||
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
|
import { UpdateFileConfig, 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadUpdateSettings(
|
||||||
|
appConfig: AppRuntimeConfig,
|
||||||
|
rootPath: string,
|
||||||
|
log: (...args: unknown[]) => void,
|
||||||
|
options: UpdateConfigOptions = { allowInsecureHttp: true },
|
||||||
|
): UpdateSettings {
|
||||||
|
const fileConfig = readUpdateFileConfig(appConfig, rootPath, log);
|
||||||
|
const apiUrl = readOptionalHttpUrl(appConfig.updateApiUrl ?? fileConfig.apiUrl, options);
|
||||||
|
const releasePageUrl = readOptionalHttpUrl(appConfig.updateReleasePageUrl ?? fileConfig.releasePageUrl, options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)) && Boolean(apiUrl),
|
||||||
|
...(apiUrl ? { apiUrl } : {}),
|
||||||
|
...(releasePageUrl ? { releasePageUrl } : {}),
|
||||||
|
assetPattern:
|
||||||
|
appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readUpdateFileConfig(
|
||||||
|
appConfig: AppRuntimeConfig,
|
||||||
|
rootPath: string,
|
||||||
|
log: (...args: unknown[]) => void,
|
||||||
|
): UpdateFileConfig {
|
||||||
|
const configPath = appConfig.updateConfigPathOverride ?? getDefaultUpdateConfigPath(rootPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedConfig: unknown = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||||
|
return normalizeUpdateFileConfig(parsedConfig);
|
||||||
|
} catch (error) {
|
||||||
|
log(`Could not read update config at ${configPath}.`, error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUpdateFileConfig(value: unknown): UpdateFileConfig {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: value.enabled,
|
||||||
|
apiUrl: value.apiUrl,
|
||||||
|
releasePageUrl: value.releasePageUrl,
|
||||||
|
assetPattern: value.assetPattern,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalHttpUrl(value: unknown, options: UpdateConfigOptions): string | undefined {
|
||||||
|
const rawValue = readOptionalString(value);
|
||||||
|
if (!rawValue) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateHttpUrl(rawValue, options) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { BrowserWindow, dialog } from "electron";
|
||||||
|
import type { MessageBoxOptions } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessageBox(parentWindow: BrowserWindow | null, options: MessageBoxOptions) {
|
||||||
|
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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`);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
export 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateFileConfig = {
|
||||||
|
enabled?: unknown;
|
||||||
|
apiUrl?: unknown;
|
||||||
|
releasePageUrl?: unknown;
|
||||||
|
assetPattern?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagName,
|
||||||
|
assets,
|
||||||
|
...(readOptionalString(value.name) ? { title: readOptionalString(value.name) } : {}),
|
||||||
|
...(readOptionalUrlString(value.html_url) ? { pageUrl: readOptionalUrlString(value.html_url) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = readOptionalString(value);
|
||||||
|
return text && text.length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOptionalUrlString(value: unknown): string | undefined {
|
||||||
|
const rawValue = readOptionalString(value);
|
||||||
|
if (!rawValue) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateHttpUrl(rawValue, { allowInsecureHttp: true }) ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPresent<T>(value: T | null): value is T {
|
||||||
|
return value !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { app, BrowserWindow, shell } from "electron";
|
||||||
|
|
||||||
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
|
import { askToDownloadUpdate, askToInstallUpdate } 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installerPath = await downloadInstaller(update, {
|
||||||
|
tempDirectory: app.getPath("temp"),
|
||||||
|
allowInsecureHttp: protocolPolicy.allowInsecureHttp,
|
||||||
|
});
|
||||||
|
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,19 +1,27 @@
|
|||||||
import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron";
|
import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron";
|
||||||
|
|
||||||
import { AppRuntimeConfig } from "../config/runtime-config";
|
import { AppRuntimeConfig } from "../config/runtime-config";
|
||||||
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
|
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
|
||||||
import { resolveAppIconPath } from "./icon-path";
|
import { resolveAppIconPath } from "./icon-path";
|
||||||
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-security";
|
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-policy";
|
||||||
|
|
||||||
type WindowFactoryDependencies = {
|
type WindowServiceDependencies = {
|
||||||
appConfig: AppRuntimeConfig;
|
appConfig: AppRuntimeConfig;
|
||||||
|
allowDevTools: boolean;
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
mainDashboardUrl: string;
|
mainDashboardUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: WindowFactoryDependencies): BrowserWindow {
|
export function createMainWindow({
|
||||||
const windowOptions = createWindowOptions({ appConfig, rootPath, isLoadingWindow: false });
|
allowDevTools,
|
||||||
|
appConfig,
|
||||||
|
rootPath,
|
||||||
|
mainDashboardUrl,
|
||||||
|
}: WindowServiceDependencies): BrowserWindow {
|
||||||
|
const windowOptions = createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: false });
|
||||||
const window = new BrowserWindow(windowOptions);
|
const window = new BrowserWindow(windowOptions);
|
||||||
|
|
||||||
|
denyPermissionsByDefault(window);
|
||||||
window.setMenuBarVisibility(false);
|
window.setMenuBarVisibility(false);
|
||||||
|
|
||||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
@@ -44,10 +52,13 @@ export function createMainWindow({ appConfig, rootPath, mainDashboardUrl }: Wind
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createLoadingWindow({
|
export function createLoadingWindow({
|
||||||
|
allowDevTools,
|
||||||
appConfig,
|
appConfig,
|
||||||
rootPath,
|
rootPath,
|
||||||
}: Omit<WindowFactoryDependencies, "mainDashboardUrl">): BrowserWindow {
|
}: Omit<WindowServiceDependencies, "mainDashboardUrl">): BrowserWindow {
|
||||||
const window = new BrowserWindow(createWindowOptions({ appConfig, rootPath, isLoadingWindow: true }));
|
const window = new BrowserWindow(createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: true }));
|
||||||
|
|
||||||
|
denyPermissionsByDefault(window);
|
||||||
|
|
||||||
window.on("page-title-updated", (event) => {
|
window.on("page-title-updated", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -56,11 +67,13 @@ export function createLoadingWindow({
|
|||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindowOptions({
|
export function createWindowOptions({
|
||||||
|
allowDevTools,
|
||||||
appConfig,
|
appConfig,
|
||||||
rootPath,
|
rootPath,
|
||||||
isLoadingWindow,
|
isLoadingWindow,
|
||||||
}: {
|
}: {
|
||||||
|
allowDevTools: boolean;
|
||||||
appConfig: AppRuntimeConfig;
|
appConfig: AppRuntimeConfig;
|
||||||
rootPath: string;
|
rootPath: string;
|
||||||
isLoadingWindow: boolean;
|
isLoadingWindow: boolean;
|
||||||
@@ -74,8 +87,10 @@ function createWindowOptions({
|
|||||||
backgroundColor: DEFAULT_WINDOW_BACKGROUND,
|
backgroundColor: DEFAULT_WINDOW_BACKGROUND,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
devTools: allowDevTools,
|
||||||
|
nodeIntegration: false,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
...(isLoadingWindow ? {} : { nodeIntegration: false }),
|
webSecurity: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,3 +115,9 @@ function createWindowOptions({
|
|||||||
minHeight: DEFAULT_WINDOW_SIZE.minHeight,
|
minHeight: DEFAULT_WINDOW_SIZE.minHeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function denyPermissionsByDefault(window: BrowserWindow): void {
|
||||||
|
window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
||||||
|
callback(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getApplicationPaths,
|
||||||
|
getDashboardUrl,
|
||||||
|
getDefaultUpdateConfigPath,
|
||||||
|
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(getDefaultUpdateConfigPath(rootPath), path.join(rootPath, "static", "updates.json"));
|
||||||
|
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",
|
||||||
|
loadingDashboardRoute: "dashboard/loading/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,269 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
loadingDashboardRoute: "dashboard/loading/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",
|
||||||
|
loadingDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/loading/main.html?standalone=true",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
relaunch: () => events.push("relaunch"),
|
||||||
|
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",
|
||||||
|
"prepare-runtime",
|
||||||
|
"create-manager",
|
||||||
|
"create-main",
|
||||||
|
"create-loading",
|
||||||
|
"start-nodecg",
|
||||||
|
"wait-nodecg",
|
||||||
|
`loading:load:${paths.loadingDashboardUrl}`,
|
||||||
|
"loading:show",
|
||||||
|
`main:load:${paths.mainDashboardUrl}`,
|
||||||
|
"main:show",
|
||||||
|
"loading:close",
|
||||||
|
"schedule-update",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ApplicationController relaunches packaged app after runtime install before starting NodeCG", 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",
|
||||||
|
loadingDashboardUrl: "http://localhost:9090/loading",
|
||||||
|
},
|
||||||
|
deps: {
|
||||||
|
createLoadingWindow: () => {
|
||||||
|
throw new Error("window creation should wait until after relaunch decisions");
|
||||||
|
},
|
||||||
|
createMainWindow: () => {
|
||||||
|
throw new Error("window creation should wait until after relaunch decisions");
|
||||||
|
},
|
||||||
|
createNodecgProcessManager: () => {
|
||||||
|
throw new Error("NodeCG should not start before relaunch");
|
||||||
|
},
|
||||||
|
getAllWindows: () => [],
|
||||||
|
log: (...args) => events.push(String(args[0])),
|
||||||
|
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }),
|
||||||
|
relaunch: () => events.push("relaunch"),
|
||||||
|
scheduleUpdateCheck: () => events.push("schedule-update"),
|
||||||
|
setAppUserModelId: () => events.push("set-app-user-model-id"),
|
||||||
|
exit: (code) => events.push(`exit:${code}`),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.launch();
|
||||||
|
|
||||||
|
assert.equal(controller.getState(), "stopped");
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
"Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.",
|
||||||
|
"relaunch",
|
||||||
|
"exit:0",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
loadingDashboardUrl: "http://localhost:9090/loading",
|
||||||
|
},
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
relaunch: () => events.push("relaunch"),
|
||||||
|
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",
|
||||||
|
loadingDashboardUrl: "http://localhost:9090/loading",
|
||||||
|
},
|
||||||
|
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 }),
|
||||||
|
relaunch: () => events.push("relaunch"),
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ function getBaseConfig(): AppRuntimeConfig {
|
|||||||
loadDelayMs: 10000,
|
loadDelayMs: 10000,
|
||||||
startupTimeoutMs: 30000,
|
startupTimeoutMs: 30000,
|
||||||
nodecgKillTimeoutMs: 2500,
|
nodecgKillTimeoutMs: 2500,
|
||||||
|
updatesEnabled: true,
|
||||||
|
updateAssetPattern: "Scoreko-setup-.*\\.exe$",
|
||||||
|
updateCheckDelayMs: 5000,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation-security";
|
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation-policy";
|
||||||
|
|
||||||
const dashboardUrl = "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html";
|
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/platform-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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import { SpawnOptions } from "node:child_process";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { AppRuntimeConfig } from "../main/config/runtime-config";
|
import { AppRuntimeConfig } from "../main/config/runtime-config";
|
||||||
@@ -29,6 +30,9 @@ function getBaseConfig(): AppRuntimeConfig {
|
|||||||
loadDelayMs: 10000,
|
loadDelayMs: 10000,
|
||||||
startupTimeoutMs: 100,
|
startupTimeoutMs: 100,
|
||||||
nodecgKillTimeoutMs: 10,
|
nodecgKillTimeoutMs: 10,
|
||||||
|
updatesEnabled: true,
|
||||||
|
updateAssetPattern: "Scoreko-setup-.*\\.exe$",
|
||||||
|
updateCheckDelayMs: 5000,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +184,48 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
|
|||||||
await firstStop;
|
await firstStop;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("startNodeCG reuses the same promise while startup is in progress", async () => {
|
||||||
|
const child = new MockChildProcess(2468);
|
||||||
|
let spawnCalls = 0;
|
||||||
|
let resolveProbe: (isAvailable: boolean) => void = () => {
|
||||||
|
throw new Error("probe promise was not created");
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = createNodecgProcessManager({
|
||||||
|
isDev: true,
|
||||||
|
nodecgRootPath: "/fake/nodecg",
|
||||||
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
|
appConfig: getBaseConfig(),
|
||||||
|
log: () => undefined,
|
||||||
|
deps: {
|
||||||
|
pathExists: () => true,
|
||||||
|
hasReadWriteAccess: () => true,
|
||||||
|
probePortAvailable: () =>
|
||||||
|
new Promise<boolean>((resolve) => {
|
||||||
|
resolveProbe = resolve;
|
||||||
|
}),
|
||||||
|
spawnProcess: () => {
|
||||||
|
spawnCalls += 1;
|
||||||
|
return child as unknown as import("node:child_process").ChildProcess;
|
||||||
|
},
|
||||||
|
stdoutWrite: () => undefined,
|
||||||
|
stderrWrite: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstStart = manager.startNodecgProcess();
|
||||||
|
const secondStart = manager.startNodecgProcess();
|
||||||
|
|
||||||
|
assert.equal(firstStart, secondStart);
|
||||||
|
assert.equal(manager.getState(), "starting");
|
||||||
|
|
||||||
|
resolveProbe(true);
|
||||||
|
await firstStart;
|
||||||
|
|
||||||
|
assert.equal(spawnCalls, 1);
|
||||||
|
assert.equal(manager.getState(), "running");
|
||||||
|
});
|
||||||
|
|
||||||
test("stopNodeCG normalizes negative timeout to zero", async () => {
|
test("stopNodeCG normalizes negative timeout to zero", async () => {
|
||||||
const child = new MockChildProcess(7777);
|
const child = new MockChildProcess(7777);
|
||||||
const timeouts: number[] = [];
|
const timeouts: number[] = [];
|
||||||
@@ -238,6 +284,44 @@ test("startNodeCG fails if the port is already in use", async () => {
|
|||||||
}, /is already in use/);
|
}, /is already in use/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("startNodeCG spawns Electron directly on Windows", async () => {
|
||||||
|
const child = new MockChildProcess(3210);
|
||||||
|
let capturedCommand: string | null = null;
|
||||||
|
let capturedArgs: string[] | null = null;
|
||||||
|
const capturedOptions: SpawnOptions[] = [];
|
||||||
|
|
||||||
|
const manager = createNodecgProcessManager({
|
||||||
|
isDev: false,
|
||||||
|
nodecgRootPath: "C:\\Users\\tester\\AppData\\Roaming\\scoreko\\nodecg",
|
||||||
|
nodecgBaseUrl: "http://127.0.0.1:9090",
|
||||||
|
appConfig: getBaseConfig(),
|
||||||
|
log: () => undefined,
|
||||||
|
deps: {
|
||||||
|
platform: "win32",
|
||||||
|
execPath: "C:\\Program Files\\Scoreko\\scoreko.exe",
|
||||||
|
pathExists: () => true,
|
||||||
|
hasReadWriteAccess: () => true,
|
||||||
|
probePortAvailable: async () => true,
|
||||||
|
spawnProcess: (command, args, options) => {
|
||||||
|
capturedCommand = command;
|
||||||
|
capturedArgs = args;
|
||||||
|
capturedOptions.push(options);
|
||||||
|
return child as unknown as import("node:child_process").ChildProcess;
|
||||||
|
},
|
||||||
|
stdoutWrite: () => undefined,
|
||||||
|
stderrWrite: () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.startNodecgProcess();
|
||||||
|
|
||||||
|
assert.equal(capturedCommand, "C:\\Program Files\\Scoreko\\scoreko.exe");
|
||||||
|
assert.deepEqual(capturedArgs, ["C:\\Users\\tester\\AppData\\Roaming\\scoreko\\nodecg\\index.js"]);
|
||||||
|
assert.equal(capturedOptions[0]?.shell, false);
|
||||||
|
assert.equal(capturedOptions[0]?.windowsHide, true);
|
||||||
|
assert.equal(capturedOptions[0]?.env?.ELECTRON_RUN_AS_NODE, "1");
|
||||||
|
});
|
||||||
|
|
||||||
test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness", async () => {
|
test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness", async () => {
|
||||||
const child = new MockChildProcess(4242);
|
const child = new MockChildProcess(4242);
|
||||||
const manager = createNodecgProcessManager({
|
const manager = createNodecgProcessManager({
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { getEnv, getOptionalEnv, parseEnvInt, parseEnvIntInRange, parseEnvPort } from "../main/config/runtime-config";
|
import {
|
||||||
|
getEnv,
|
||||||
|
getOptionalEnv,
|
||||||
|
parseEnvBool,
|
||||||
|
parseEnvInt,
|
||||||
|
parseEnvIntInRange,
|
||||||
|
parseEnvPort,
|
||||||
|
parseOptionalHttpUrl,
|
||||||
|
} from "../main/config/runtime-config";
|
||||||
|
|
||||||
function withEnv(name: string, value: string | undefined, run: () => void): void {
|
function withEnv(name: string, value: string | undefined, run: () => void): void {
|
||||||
const previousValue = process.env[name];
|
const previousValue = process.env[name];
|
||||||
@@ -83,3 +91,31 @@ test("parseEnvPort normalizes valid port", () => {
|
|||||||
assert.equal(parseEnvPort("TEST_ENV_PORT", "9090"), "9090");
|
assert.equal(parseEnvPort("TEST_ENV_PORT", "9090"), "9090");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parseEnvBool accepts common true and false values", () => {
|
||||||
|
withEnv("TEST_ENV_BOOL", "yes", () => {
|
||||||
|
assert.equal(parseEnvBool("TEST_ENV_BOOL", false), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
withEnv("TEST_ENV_BOOL", "off", () => {
|
||||||
|
assert.equal(parseEnvBool("TEST_ENV_BOOL", true), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseEnvBool rejects invalid values", () => {
|
||||||
|
withEnv("TEST_ENV_BOOL", "maybe", () => {
|
||||||
|
assert.throws(() => parseEnvBool("TEST_ENV_BOOL", true), /must be a boolean/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseOptionalHttpUrl accepts HTTP and HTTPS urls", () => {
|
||||||
|
withEnv("TEST_UPDATE_URL", "http://gitea.local/api/v1/repos/owner/repo/releases/latest", () => {
|
||||||
|
assert.equal(parseOptionalHttpUrl("TEST_UPDATE_URL"), "http://gitea.local/api/v1/repos/owner/repo/releases/latest");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseOptionalHttpUrl rejects unsupported protocols", () => {
|
||||||
|
withEnv("TEST_UPDATE_URL", "file:///tmp/latest", () => {
|
||||||
|
assert.throws(() => parseOptionalHttpUrl("TEST_UPDATE_URL"), /valid HTTP\(S\) URL/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { prepareUserNodecgRuntime } from "../main/nodecg/runtime-provisioner";
|
||||||
|
|
||||||
|
type FakeFsState = {
|
||||||
|
paths: Set<string>;
|
||||||
|
files: Map<string, string>;
|
||||||
|
removed: string[];
|
||||||
|
copied: Array<{ from: string; to: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createFakeFs(initialPaths: string[] = [], initialFiles: Record<string, string> = {}) {
|
||||||
|
const state: FakeFsState = {
|
||||||
|
paths: new Set(initialPaths.map((candidatePath) => path.normalize(candidatePath))),
|
||||||
|
files: new Map(Object.entries(initialFiles).map(([filePath, content]) => [path.normalize(filePath), content])),
|
||||||
|
removed: [],
|
||||||
|
copied: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const filePath of state.files.keys()) {
|
||||||
|
state.paths.add(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
deps: {
|
||||||
|
existsSync: (candidatePath: string) => state.paths.has(path.normalize(candidatePath)),
|
||||||
|
mkdirSync: (candidatePath: string) => {
|
||||||
|
state.paths.add(path.normalize(candidatePath));
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
rmSync: (candidatePath: string) => {
|
||||||
|
state.removed.push(path.normalize(candidatePath));
|
||||||
|
state.paths.delete(path.normalize(candidatePath));
|
||||||
|
},
|
||||||
|
cpSync: (from: string, to: string) => {
|
||||||
|
state.copied.push({ from: path.normalize(from), to: path.normalize(to) });
|
||||||
|
state.paths.add(path.normalize(to));
|
||||||
|
state.paths.add(path.join(path.normalize(to), "index.js"));
|
||||||
|
state.paths.add(path.join(path.normalize(to), "package.json"));
|
||||||
|
state.paths.add(path.join(path.normalize(to), "node_modules", "nodecg", "dist", "server", "bootstrap.js"));
|
||||||
|
state.paths.add(path.join(path.normalize(to), "bundles", "scoreko-dev", "package.json"));
|
||||||
|
},
|
||||||
|
readFileSync: (filePath: string) => state.files.get(path.normalize(filePath)) ?? "{}",
|
||||||
|
writeFileSync: (filePath: string, content: string) => {
|
||||||
|
state.files.set(path.normalize(filePath), content);
|
||||||
|
state.paths.add(path.normalize(filePath));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourcePaths(source: string) {
|
||||||
|
return [
|
||||||
|
source,
|
||||||
|
path.join(source, "index.js"),
|
||||||
|
path.join(source, "package.json"),
|
||||||
|
path.join(source, "node_modules", "nodecg", "dist", "server", "bootstrap.js"),
|
||||||
|
path.join(source, "bundles", "scoreko-dev", "package.json"),
|
||||||
|
path.join(source, ".scoreko-runtime.json"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
test("prepareUserNodecgRuntime copies the packaged runtime into userData", () => {
|
||||||
|
const source = path.normalize("/app/lib/nodecg");
|
||||||
|
const userData = path.normalize("/user/scoreko");
|
||||||
|
const { state, deps } = createFakeFs(getSourcePaths(source), {
|
||||||
|
[path.join(source, ".scoreko-runtime.json")]: JSON.stringify({ bundleVersion: "0.1.0", nodecgVersion: "2.6.4" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const preparedRuntime = prepareUserNodecgRuntime({
|
||||||
|
sourceRuntimePath: source,
|
||||||
|
userDataPath: userData,
|
||||||
|
appVersion: "0.1.0",
|
||||||
|
bundleName: "scoreko-dev",
|
||||||
|
log: () => undefined,
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(preparedRuntime.runtimePath, path.join(userData, "nodecg"));
|
||||||
|
assert.equal(preparedRuntime.installed, true);
|
||||||
|
assert.equal(state.copied.length, 1);
|
||||||
|
assert.ok(state.paths.has(path.join(userData, "nodecg", "cfg")));
|
||||||
|
assert.ok(state.paths.has(path.join(userData, "nodecg", "db")));
|
||||||
|
assert.ok(state.paths.has(path.join(userData, "nodecg", "logs")));
|
||||||
|
assert.ok(state.files.has(path.join(userData, "nodecg", ".scoreko-installed-runtime.json")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prepareUserNodecgRuntime keeps an up-to-date runtime in place", () => {
|
||||||
|
const source = path.normalize("/app/lib/nodecg");
|
||||||
|
const userData = path.normalize("/user/scoreko");
|
||||||
|
const target = path.join(userData, "nodecg");
|
||||||
|
const sourceManifest = { bundleVersion: "0.1.0", generatedAt: "2026-05-24T00:00:00.000Z", nodecgVersion: "2.6.4" };
|
||||||
|
const targetManifest = { appVersion: "0.1.0", bundleName: "scoreko-dev", sourceRuntime: sourceManifest };
|
||||||
|
const { state, deps } = createFakeFs(
|
||||||
|
[
|
||||||
|
...getSourcePaths(source),
|
||||||
|
path.join(target, "node_modules", "nodecg", "dist", "server", "bootstrap.js"),
|
||||||
|
path.join(target, "bundles", "scoreko-dev", "package.json"),
|
||||||
|
path.join(target, ".scoreko-installed-runtime.json"),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
[path.join(source, ".scoreko-runtime.json")]: JSON.stringify(sourceManifest),
|
||||||
|
[path.join(target, ".scoreko-installed-runtime.json")]: JSON.stringify(targetManifest),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const preparedRuntime = prepareUserNodecgRuntime({
|
||||||
|
sourceRuntimePath: source,
|
||||||
|
userDataPath: userData,
|
||||||
|
appVersion: "0.1.0",
|
||||||
|
bundleName: "scoreko-dev",
|
||||||
|
log: () => undefined,
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(preparedRuntime.installed, false);
|
||||||
|
assert.equal(state.copied.length, 0);
|
||||||
|
assert.equal(state.removed.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prepareUserNodecgRuntime refreshes managed files when the app version changes", () => {
|
||||||
|
const source = path.normalize("/app/lib/nodecg");
|
||||||
|
const userData = path.normalize("/user/scoreko");
|
||||||
|
const target = path.join(userData, "nodecg");
|
||||||
|
const sourceManifest = { bundleVersion: "0.1.0", generatedAt: "2026-05-24T00:00:00.000Z", nodecgVersion: "2.6.4" };
|
||||||
|
const targetManifest = { appVersion: "0.0.9", bundleName: "scoreko-dev", sourceRuntime: sourceManifest };
|
||||||
|
const { state, deps } = createFakeFs(
|
||||||
|
[
|
||||||
|
...getSourcePaths(source),
|
||||||
|
path.join(target, "node_modules", "nodecg", "dist", "server", "bootstrap.js"),
|
||||||
|
path.join(target, "bundles", "scoreko-dev", "package.json"),
|
||||||
|
path.join(target, ".scoreko-installed-runtime.json"),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
[path.join(source, ".scoreko-runtime.json")]: JSON.stringify(sourceManifest),
|
||||||
|
[path.join(target, ".scoreko-installed-runtime.json")]: JSON.stringify(targetManifest),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const preparedRuntime = prepareUserNodecgRuntime({
|
||||||
|
sourceRuntimePath: source,
|
||||||
|
userDataPath: userData,
|
||||||
|
appVersion: "0.1.0",
|
||||||
|
bundleName: "scoreko-dev",
|
||||||
|
log: () => undefined,
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(preparedRuntime.installed, true);
|
||||||
|
assert.equal(state.copied.length, 1);
|
||||||
|
assert.ok(state.removed.includes(path.join(target, "node_modules")));
|
||||||
|
assert.ok(state.removed.includes(path.join(target, "bundles")));
|
||||||
|
assert.ok(!state.removed.includes(path.join(target, "db")));
|
||||||
|
});
|
||||||
|
|
||||||
|
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, 1);
|
||||||
|
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,58 @@
|
|||||||
|
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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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 { AppRuntimeConfig } from "../main/config/runtime-config";
|
||||||
|
import { loadUpdateSettings, readUpdateFileConfig } 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",
|
||||||
|
loadingDashboardRoute: "dashboard/loading/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 rootPath = makeTempRoot({
|
||||||
|
enabled: true,
|
||||||
|
apiUrl: "https://gitea.local/releases/latest",
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = loadUpdateSettings({ ...baseConfig, updatesEnabled: false }, rootPath, () => 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 rootPath = makeTempRoot({
|
||||||
|
enabled: true,
|
||||||
|
apiUrl: "http://gitea.local/releases/latest",
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = loadUpdateSettings(baseConfig, rootPath, () => undefined, { allowInsecureHttp: false });
|
||||||
|
|
||||||
|
assert.equal(settings.enabled, false);
|
||||||
|
assert.equal(settings.apiUrl, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loadUpdateSettings lets runtime config override file settings", () => {
|
||||||
|
const rootPath = makeTempRoot({
|
||||||
|
enabled: true,
|
||||||
|
apiUrl: "https://file.local/releases/latest",
|
||||||
|
releasePageUrl: "https://file.local/releases",
|
||||||
|
assetPattern: "File-.*\\.exe$",
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings = loadUpdateSettings(
|
||||||
|
{
|
||||||
|
...baseConfig,
|
||||||
|
updateApiUrl: "https://env.local/releases/latest",
|
||||||
|
updateReleasePageUrl: "https://env.local/releases",
|
||||||
|
updateAssetPattern: "Env-.*\\.exe$",
|
||||||
|
},
|
||||||
|
rootPath,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(settings, {
|
||||||
|
enabled: true,
|
||||||
|
apiUrl: "https://env.local/releases/latest",
|
||||||
|
releasePageUrl: "https://env.local/releases",
|
||||||
|
assetPattern: "Env-.*\\.exe$",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readUpdateFileConfig normalizes malformed config into an empty file config", () => {
|
||||||
|
const rootPath = makeTempRoot(["not", "an", "object"]);
|
||||||
|
|
||||||
|
assert.deepEqual(readUpdateFileConfig(baseConfig, rootPath, () => undefined), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readUpdateFileConfig logs invalid JSON and returns an empty file config", () => {
|
||||||
|
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-settings-"));
|
||||||
|
const staticPath = path.join(rootPath, "static");
|
||||||
|
fs.mkdirSync(staticPath, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(staticPath, "updates.json"), "{ invalid", "utf8");
|
||||||
|
const messages: unknown[][] = [];
|
||||||
|
|
||||||
|
const settings = readUpdateFileConfig(baseConfig, rootPath, (...args: unknown[]) => {
|
||||||
|
messages.push(args);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(settings, {});
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTempRoot(config: unknown): string {
|
||||||
|
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-settings-"));
|
||||||
|
const staticPath = path.join(rootPath, "static");
|
||||||
|
|
||||||
|
fs.mkdirSync(staticPath, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(staticPath, "updates.json"), JSON.stringify(config), "utf8");
|
||||||
|
|
||||||
|
return rootPath;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildReleaseUpdate,
|
||||||
|
isVersionNewer,
|
||||||
|
parseGiteaRelease,
|
||||||
|
sanitizeFileName,
|
||||||
|
selectInstallerAsset,
|
||||||
|
} from "../main/updates/update-schema";
|
||||||
|
|
||||||
|
test("isVersionNewer compares semantic versions with optional v prefix", () => {
|
||||||
|
assert.equal(isVersionNewer("v0.2.0", "0.1.9"), true);
|
||||||
|
assert.equal(isVersionNewer("0.1.0", "0.1.0"), false);
|
||||||
|
assert.equal(isVersionNewer("0.1.0", "0.2.0"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selectInstallerAsset picks the first matching exe asset", () => {
|
||||||
|
const asset = selectInstallerAsset(
|
||||||
|
{
|
||||||
|
tagName: "v0.2.0",
|
||||||
|
assets: [
|
||||||
|
{ name: "latest.yml", browserDownloadUrl: "http://gitea/latest.yml" },
|
||||||
|
{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"Scoreko-setup-.*\\.exe$",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(asset, {
|
||||||
|
name: "Scoreko-setup-0.2.0.exe",
|
||||||
|
downloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe",
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildReleaseUpdate returns null when the release is not newer", () => {
|
||||||
|
const update = buildReleaseUpdate(
|
||||||
|
{
|
||||||
|
tagName: "v0.1.0",
|
||||||
|
assets: [{ name: "Scoreko-setup-0.1.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.1.0.exe" }],
|
||||||
|
},
|
||||||
|
"0.1.0",
|
||||||
|
"Scoreko-setup-.*\\.exe$",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(update, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildReleaseUpdate builds update info for newer releases", () => {
|
||||||
|
const update = buildReleaseUpdate(
|
||||||
|
{
|
||||||
|
tagName: "v0.2.0",
|
||||||
|
title: "Scoreko 0.2.0",
|
||||||
|
pageUrl: "http://gitea/releases/v0.2.0",
|
||||||
|
assets: [{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe" }],
|
||||||
|
},
|
||||||
|
"0.1.0",
|
||||||
|
"Scoreko-setup-.*\\.exe$",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(update?.version, "0.2.0");
|
||||||
|
assert.equal(update?.title, "Scoreko 0.2.0");
|
||||||
|
assert.equal(update?.pageUrl, "http://gitea/releases/v0.2.0");
|
||||||
|
assert.equal(update?.installer.name, "Scoreko-setup-0.2.0.exe");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sanitizeFileName removes Windows-unsafe characters", () => {
|
||||||
|
assert.equal(sanitizeFileName('Scoreko:setup*"0.2.0.exe'), "Scoreko_setup__0.2.0.exe");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseGiteaRelease rejects malformed remote metadata", () => {
|
||||||
|
assert.equal(parseGiteaRelease({ name: "missing tag", assets: [] }), null);
|
||||||
|
assert.equal(parseGiteaRelease({ tag_name: "v0.2.0", assets: "wrong" }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildReleaseUpdate rejects insecure download URLs when policy forbids them", () => {
|
||||||
|
const update = buildReleaseUpdate(
|
||||||
|
{
|
||||||
|
tagName: "v0.2.0",
|
||||||
|
assets: [{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe" }],
|
||||||
|
},
|
||||||
|
"0.1.0",
|
||||||
|
"Scoreko-setup-.*\\.exe$",
|
||||||
|
{ allowInsecureHttp: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(update, null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"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