Compare commits

...

8 Commits

Author SHA1 Message Date
Pandipipas b0e0fdb9a1 Merge pull request #45 from Pandipipas/dev
Dev
2026-05-31 15:16:52 +02:00
Pandipipas 33665ed896 Cleanup final 2026-05-31 14:50:32 +02:00
Pandipipas 865c3589bd Refactor NodeCG runtime preparation and update handling
- Updated paths and configurations in doctor.mjs and prepare-nodecg-runtime.mjs to use new build-config.mjs imports.
- Enhanced runtime installation checks and permissions validation.
- Introduced new update configuration management in update-config.ts, including loading and validating update settings.
- Implemented update service for managing update checks and downloads in update-service.ts.
- Replaced update-utils.ts with update-schema.ts for better structure and clarity in update handling.
- Added comprehensive tests for update download and settings management.
- Ensured secure handling of download URLs and improved error handling in update processes.
2026-05-24 23:20:59 +02:00
Pandipipas c8e2edc0c0 feat: Implement update management refactor with new dialog and settings handling 2026-05-24 22:31:18 +02:00
Pandipipas 54ab1fcb9f feat: Enhance NodeCG process management and add IPC security tests 2026-05-24 22:13:04 +02:00
Pandipipas 2e1d3a170c feat: Restore Electron renderer and enhance NodeCG runtime management 2026-05-24 16:49:02 +02:00
Pandipipas e3d3936156 feat: Implement application bootstrap and window management
- Added bootstrap functionality to initialize the Electron application.
- Created a new paths module to manage application paths and URLs.
- Introduced a shutdown service to handle graceful application shutdowns.
- Refactored error logging to use a dedicated logger module.
- Implemented process killing logic for NodeCG processes across platforms.
- Established navigation policies for internal and external URL handling in windows.
- Developed window service for creating and managing application windows.
- Added tests for application paths, application controller, navigation policies, process killer, and shutdown service.
2026-05-24 16:14:23 +02:00
Pandipipas c168c3b84a feat: add comprehensive architecture documentation and migration plan for refactor sessions 2026-05-24 15:38:15 +02:00
49 changed files with 4073 additions and 767 deletions
+211
View File
@@ -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.
+203
View File
@@ -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.
+97
View File
@@ -0,0 +1,97 @@
# Final Cleanup Summary
## Scope
Executed the final global cleanup pass using `docs/refactor` as the source of truth.
Source documents reviewed before code changes:
- `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`
- `docs/refactor/PHASE_1_SUMMARY.md`
- `docs/refactor/PHASE_1_FIX_SUMMARY.md`
- `docs/refactor/PHASE_2_SUMMARY.md`
- `docs/refactor/PHASE_3_SUMMARY.md`
- `docs/refactor/PHASE_4_SUMMARY.md`
## Cleanup Completed
- Removed the unused `parseEnvInt` helper and its tests.
- Consolidated duplicated `unknown` parsing helpers into `src/main/utils/unknown-values.ts`.
- Narrowed `ApplicationController` path typing to reuse `ApplicationPaths`.
- Narrowed update config module boundaries so updater settings only accept the runtime config fields they need.
- Narrowed the NodeCG process manager child-process contract to the actual process surface it uses.
- Removed `as unknown as ChildProcess` casts from process-manager tests.
- Fixed update dialog Spanish text encoding.
- Added an explicit return type to the update dialog message-box helper.
- Renamed legacy updater test files:
- `update-settings.test.ts` -> `update-config.test.ts`
- `update-utils.test.ts` -> `update-schema.test.ts`
## Architecture Preserved
- No UX changes.
- No new features.
- No renderer, preload, or IPC layer.
- No NodeCG runtime model changes.
- No Electron permission expansion.
- No broad framework or new lifecycle layer.
- BrowserWindow security posture remains explicit.
- Update validation and download boundaries remain separated.
- Managed runtime preservation of `cfg`, `db`, and `logs` remains unchanged.
## Verification
Passed:
```text
npm.cmd run typecheck
npm.cmd exec -- tsc --noEmit --noUnusedLocals --noUnusedParameters
npm.cmd test
npm.cmd run lint
npm.cmd run build
npm.cmd run doctor
```
Test result:
```text
63 tests passing
```
Sanity searches passed for production/test source:
```text
rg -n "parseEnvInt\(|ActualizaciÃ|estÃ|versiÃ|cerrarÃ|update-utils|update-settings|\bany\b|unknown as|as unknown|@ts-ignore|@ts-expect-error|eslint-disable|nodeIntegration:\s*true|webSecurity:\s*false" src scripts
```
Result:
- No `any` in `src` or `scripts`.
- No `as unknown` casts remain.
- No legacy updater module names remain in `src`.
- No Spanish mojibake remains in update dialog source.
- No unsafe Electron settings were introduced.
IPC/preload sanity:
```text
rg -n "ipcMain|ipcRenderer|contextBridge|preload" src scripts
```
Result:
- Matches are limited to the regression test that guards the no-IPC/no-preload policy.
## Build Notes
The first non-escalated `npm.cmd run build` attempt was blocked by sandbox permissions while creating generated parent-repo output at:
```text
C:\Users\pcantos\Documents\scoreko-dev\shared\dist
```
The escalated rerun passed. The build emitted existing dependency/deprecation warnings during runtime dependency installation, but completed successfully and `npm.cmd run doctor` validated the prepared runtime.
+310
View File
@@ -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.
+92
View File
@@ -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.
+90
View File
@@ -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.
+79
View File
@@ -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.
+107
View File
@@ -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.
+121
View File
@@ -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.
+190
View File
@@ -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.
+420
View File
@@ -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.
+56
View File
@@ -0,0 +1,56 @@
import path from "node:path";
export const electronRoot = process.cwd();
export const bundleRoot = path.resolve(electronRoot, "..");
export const nodecgRuntimeRoot = path.join(electronRoot, "lib", "nodecg");
export const nodecgRuntimeNodeModules = path.join(nodecgRuntimeRoot, "node_modules");
export const bundleName = process.env.NODECG_BUNDLE_NAME?.trim() || "scoreko-dev";
export const runtimeBundleRoot = path.join(nodecgRuntimeRoot, "bundles", bundleName);
export const runtimeNpmCache = process.env.npm_config_cache ?? path.join(electronRoot, ".npm-runtime-cache");
export const electronCache = process.env.ELECTRON_CACHE ?? path.join(electronRoot, ".electron-cache");
export const bundleRootMarkers = ["package.json", "pnpm-lock.yaml"];
export const generatedBundleEntries = ["extension", "node_modules/.vite", "shared/dist", "dashboard", "graphics"];
export const preparedBundleEntries = [
"assets",
"dashboard",
"extension",
"graphics",
"nodecg",
"schemas",
"shared",
"configschema.json",
"LICENSE",
"package.json",
"README.md",
];
export const requiredPreparedBundleEntries = [
"dashboard",
"extension",
"graphics",
"nodecg",
"schemas",
"shared",
"package.json",
];
export function getNpmCommand() {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
export function getLocalBinPath(commandName) {
const extension = process.platform === "win32" ? ".CMD" : "";
return path.join(bundleRoot, "node_modules", ".bin", `${commandName}${extension}`);
}
export function getPathInside(rootPath, relativePath) {
const resolvedRoot = path.resolve(rootPath);
const targetPath = path.resolve(resolvedRoot, relativePath);
const pathFromRoot = path.relative(resolvedRoot, targetPath);
if (!pathFromRoot || pathFromRoot.startsWith("..") || path.isAbsolute(pathFromRoot)) {
throw new Error(`Refusing to access path outside ${resolvedRoot}: ${targetPath}`);
}
return targetPath;
}
+24 -20
View File
@@ -3,14 +3,29 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
const electronRoot = process.cwd(); import {
const bundleRoot = path.resolve(electronRoot, ".."); bundleRoot,
const packageJsonPath = path.join(bundleRoot, "package.json"); bundleRootMarkers,
const pnpmLockPath = path.join(bundleRoot, "pnpm-lock.yaml"); electronRoot,
generatedBundleEntries,
getLocalBinPath,
getPathInside,
} from "./build-config.mjs";
const nodeModulesPath = path.join(bundleRoot, "node_modules"); const nodeModulesPath = path.join(bundleRoot, "node_modules");
if (!existsSync(packageJsonPath) || !existsSync(pnpmLockPath)) { const missingMarkers = bundleRootMarkers
console.error(`Scoreko bundle root was not found at: ${bundleRoot}`); .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); process.exit(1);
} }
@@ -25,7 +40,6 @@ if (!existsSync(nodeModulesPath)) {
process.exit(1); process.exit(1);
} }
const generatedBundleEntries = ["extension", "node_modules/.vite", "shared/dist", "dashboard", "graphics"];
const childEnv = { const childEnv = {
...process.env, ...process.env,
COREPACK_HOME: process.env.COREPACK_HOME ?? path.join(electronRoot, ".corepack"), COREPACK_HOME: process.env.COREPACK_HOME ?? path.join(electronRoot, ".corepack"),
@@ -33,12 +47,7 @@ const childEnv = {
}; };
function removeGeneratedOutput(relativePath) { function removeGeneratedOutput(relativePath) {
const targetPath = path.resolve(bundleRoot, relativePath); const targetPath = getPathInside(bundleRoot, relativePath);
if (!targetPath.startsWith(`${bundleRoot}${path.sep}`)) {
throw new Error(`Refusing to remove path outside the bundle root: ${targetPath}`);
}
rmSync(targetPath, { recursive: true, force: true }); rmSync(targetPath, { recursive: true, force: true });
} }
@@ -60,11 +69,6 @@ function runCommand(command, args) {
} }
} }
function runLocalBin(commandName, args) {
const extension = process.platform === "win32" ? ".CMD" : "";
runCommand(path.join(bundleRoot, "node_modules", ".bin", `${commandName}${extension}`), args);
}
for (const entry of generatedBundleEntries) { for (const entry of generatedBundleEntries) {
removeGeneratedOutput(entry); removeGeneratedOutput(entry);
} }
@@ -73,5 +77,5 @@ for (const entry of ["shared/dist", "dashboard", "graphics", "extension"]) {
mkdirSync(path.join(bundleRoot, entry), { recursive: true }); mkdirSync(path.join(bundleRoot, entry), { recursive: true });
} }
runLocalBin("vite", ["build", "--configLoader", "runner"]); runCommand(getLocalBinPath("vite"), ["build", "--configLoader", "runner"]);
runLocalBin("tsc", ["-b", "tsconfig.extension.json"]); runCommand(getLocalBinPath("tsc"), ["-b", "tsconfig.extension.json"]);
+7 -9
View File
@@ -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,20 +35,19 @@ 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 bootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js"); const bootstrapPath = path.join(nodecgRuntimeRoot, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
const manifestPath = path.join(nodecgRootPath, ".scoreko-runtime.json"); const manifestPath = path.join(nodecgRuntimeRoot, ".scoreko-runtime.json");
const bundleName = (process.env.NODECG_BUNDLE_NAME ?? "scoreko-dev").trim(); const bundlePath = path.join(nodecgRuntimeRoot, "bundles", bundleName);
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
addCheck(fs.existsSync(nodecgRootPath), "Packaged NodeCG runtime", nodecgRootPath); addCheck(fs.existsSync(nodecgRuntimeRoot), "Packaged NodeCG runtime", nodecgRuntimeRoot);
addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath); addCheck(fs.existsSync(indexPath), "Runtime index.js", indexPath);
addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath); addCheck(fs.existsSync(bootstrapPath), "NodeCG bootstrap", bootstrapPath);
addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath); addCheck(fs.existsSync(manifestPath), "Runtime manifest", manifestPath);
addCheck(fs.existsSync(bundlePath), `Packaged bundle '${bundleName}'`, bundlePath); 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 for local development"); 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");
+23 -34
View File
@@ -3,27 +3,17 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } fr
import path from "node:path"; import path from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
const electronRoot = process.cwd(); import {
const bundleRoot = path.resolve(electronRoot, ".."); bundleName,
const runtimeRoot = path.join(electronRoot, "lib", "nodecg"); bundleRoot,
const runtimeNodeModules = path.join(runtimeRoot, "node_modules"); getNpmCommand,
const bundleName = process.env.NODECG_BUNDLE_NAME?.trim() || "scoreko-dev"; nodecgRuntimeNodeModules,
const runtimeBundleRoot = path.join(runtimeRoot, "bundles", bundleName); nodecgRuntimeRoot,
preparedBundleEntries,
const bundleEntries = [ requiredPreparedBundleEntries,
"assets", runtimeBundleRoot,
"dashboard", runtimeNpmCache,
"extension", } from "./build-config.mjs";
"graphics",
"schemas",
"shared",
"configschema.json",
"LICENSE",
"package.json",
"README.md",
];
const requiredBundleEntries = ["dashboard", "extension", "graphics", "schemas", "shared", "package.json"];
function readJson(filePath) { function readJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8")); return JSON.parse(readFileSync(filePath, "utf8"));
@@ -50,7 +40,7 @@ function run(command, args, cwd) {
shell: process.platform === "win32", shell: process.platform === "win32",
env: { env: {
...process.env, ...process.env,
npm_config_cache: process.env.npm_config_cache ?? path.join(electronRoot, ".npm-runtime-cache"), npm_config_cache: runtimeNpmCache,
}, },
}); });
@@ -80,7 +70,7 @@ function getInstalledNodecgVersion() {
} }
function assertBundleBuildExists() { function assertBundleBuildExists() {
for (const entry of requiredBundleEntries) { for (const entry of requiredPreparedBundleEntries) {
const source = path.join(bundleRoot, entry); const source = path.join(bundleRoot, entry);
if (!existsSync(source)) { if (!existsSync(source)) {
throw new Error( throw new Error(
@@ -102,7 +92,7 @@ function createRuntimePackageJson() {
}; };
writeFileSync( writeFileSync(
path.join(runtimeRoot, "package.json"), path.join(nodecgRuntimeRoot, "package.json"),
`${JSON.stringify( `${JSON.stringify(
{ {
private: true, private: true,
@@ -120,23 +110,23 @@ function createRuntimePackageJson() {
)}\n`, )}\n`,
); );
writeFileSync(path.join(runtimeRoot, "index.js"), 'require("nodecg");\n'); writeFileSync(path.join(nodecgRuntimeRoot, "index.js"), 'require("nodecg");\n');
} }
function copyBundle() { function copyBundle() {
mkdirSync(runtimeBundleRoot, { recursive: true }); mkdirSync(runtimeBundleRoot, { recursive: true });
for (const entry of bundleEntries) { for (const entry of preparedBundleEntries) {
copyIfExists(path.join(bundleRoot, entry), path.join(runtimeBundleRoot, entry)); copyIfExists(path.join(bundleRoot, entry), path.join(runtimeBundleRoot, entry));
} }
} }
function writeManifest() { function writeManifest() {
const bundlePackageJson = readJson(path.join(bundleRoot, "package.json")); const bundlePackageJson = readJson(path.join(bundleRoot, "package.json"));
const runtimePackageJson = readJson(path.join(runtimeRoot, "package.json")); const runtimePackageJson = readJson(path.join(nodecgRuntimeRoot, "package.json"));
writeFileSync( writeFileSync(
path.join(runtimeRoot, ".scoreko-runtime.json"), path.join(nodecgRuntimeRoot, ".scoreko-runtime.json"),
`${JSON.stringify( `${JSON.stringify(
{ {
bundleName, bundleName,
@@ -156,23 +146,22 @@ function installRuntimeDependencies() {
return; return;
} }
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; run(getNpmCommand(), ["install", "--omit=dev", "--no-audit", "--no-fund"], nodecgRuntimeRoot);
run(npmCommand, ["install", "--omit=dev", "--no-audit", "--no-fund"], runtimeRoot);
} }
function main() { function main() {
assertBundleBuildExists(); assertBundleBuildExists();
rmSync(runtimeRoot, { recursive: true, force: true }); rmSync(nodecgRuntimeRoot, { recursive: true, force: true });
mkdirSync(runtimeNodeModules, { recursive: true }); mkdirSync(nodecgRuntimeNodeModules, { recursive: true });
mkdirSync(path.join(runtimeRoot, "bundles"), { recursive: true }); mkdirSync(path.join(nodecgRuntimeRoot, "bundles"), { recursive: true });
createRuntimePackageJson(); createRuntimePackageJson();
copyBundle(); copyBundle();
installRuntimeDependencies(); installRuntimeDependencies();
writeManifest(); writeManifest();
console.log(`[prepare-runtime] NodeCG runtime ready at ${runtimeRoot}`); console.log(`[prepare-runtime] NodeCG runtime ready at ${nodecgRuntimeRoot}`);
} }
try { try {
+9 -10
View File
@@ -2,18 +2,17 @@ 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 packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); const packageJson = JSON.parse(readFileSync(path.join(electronRoot, "package.json"), "utf8"));
const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron; const electronVersion = packageJson.devDependencies?.electron ?? packageJson.dependencies?.electron;
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
if (!electronVersion) { if (!electronVersion) {
console.error("Could not determine Electron version from package.json."); console.error("Could not determine Electron version from package.json.");
process.exit(1); process.exit(1);
} }
if (!existsSync(path.join(nodecgDir, "package.json"))) { if (!existsSync(path.join(nodecgRuntimeRoot, "package.json"))) {
console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first."); console.error("No packaged NodeCG runtime found. Run npm run prepare:runtime first.");
process.exit(1); process.exit(1);
} }
@@ -30,8 +29,8 @@ function run(command, args, cwd) {
npm_config_runtime: "electron", npm_config_runtime: "electron",
npm_config_target: electronVersion, npm_config_target: electronVersion,
npm_config_disturl: "https://electronjs.org/headers", npm_config_disturl: "https://electronjs.org/headers",
npm_config_cache: process.env.npm_config_cache ?? path.join(root, ".npm-runtime-cache"), npm_config_cache: runtimeNpmCache,
ELECTRON_CACHE: process.env.ELECTRON_CACHE ?? path.join(root, ".electron-cache"), ELECTRON_CACHE: electronCache,
}, },
}); });
@@ -45,13 +44,13 @@ function run(command, args, cwd) {
}); });
} }
console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgDir}`); console.log(`\n[rebuild-native] Rebuilding better-sqlite3 for Electron ${electronVersion} in: ${nodecgRuntimeRoot}`);
await run(npmCommand, [ await run(getNpmCommand(), [
"rebuild", "rebuild",
"better-sqlite3", "better-sqlite3",
"--runtime=electron", "--runtime=electron",
`--target=${electronVersion}`, `--target=${electronVersion}`,
"--dist-url=https://electronjs.org/headers", "--dist-url=https://electronjs.org/headers",
], nodecgDir); ], nodecgRuntimeRoot);
console.log("\n[rebuild-native] Done."); console.log("\n[rebuild-native] Done.");
+228
View File
@@ -0,0 +1,228 @@
import { AppRuntimeConfig } from "../config/runtime-config";
import { NodecgProcessManager } from "../nodecg/process-manager";
import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner";
import { getRemainingDelayMs } from "../utils/timing";
import { ApplicationPaths } from "./paths";
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: ApplicationPaths;
deps: {
createLoadingWindow: () => ApplicationWindow;
createMainWindow: () => ApplicationWindow;
createNodecgProcessManager: (runtimePath: string) => NodecgProcessManager;
getAllWindows: () => ApplicationWindow[];
log: (...args: unknown[]) => void;
prepareRuntime: (config: {
sourceRuntimePath: string;
userDataPath: string;
appVersion: string;
bundleName: string;
log: (...args: unknown[]) => void;
}) => PreparedNodecgRuntime;
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);
});
}
+137
View File
@@ -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);
});
}
+86
View File
@@ -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),
};
}
+32
View File
@@ -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
View File
@@ -53,16 +53,6 @@ export function getEnv(name: string, fallback: string): string {
return getOptionalEnv(name) ?? fallback; return getOptionalEnv(name) ?? fallback;
} }
export function parseEnvInt(name: string, fallback: number): number {
const rawValue = process.env[name];
if (!rawValue) {
return fallback;
}
const parsedValue = Number.parseInt(rawValue, 10);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
}
export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number { export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number {
// We throw here instead of silently coercing to avoid hidden misconfiguration in production. // We throw here instead of silently coercing to avoid hidden misconfiguration in production.
const rawValue = process.env[name]; const rawValue = process.env[name];
+1 -1
View File
@@ -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 -219
View File
@@ -1,220 +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, NodecgProcessManager } from "./nodecg/process-manager";
import { prepareUserNodecgRuntime } from "./nodecg/runtime-provisioner";
import { scheduleUpdateCheck } from "./updates/update-manager";
import { getRemainingDelayMs } from "./utils/timing";
import { createLoadingWindow, createMainWindow } from "./windows/window-factory";
const appConfig = getRuntimeConfig();
// Force a stable userData folder name; overridable via SCOREKO_APP_USER_DATA_DIRECTORY.
app.setName(appConfig.title);
app.setPath("userData", path.join(app.getPath("appData"), appConfig.userDataDirectoryName));
const isDev = !app.isPackaged;
const rootPath = isDev ? path.resolve(__dirname, "../..") : process.resourcesPath;
const sourceNodecgRuntimePath = path.resolve(rootPath, "lib", "nodecg");
const mainDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.mainDashboardRoute}`;
const loadingDashboardUrl = `http://localhost:${appConfig.nodecgPort}/bundles/${appConfig.bundleName}/${appConfig.loadingDashboardRoute}`;
const nodecgBaseUrl = `http://127.0.0.1:${appConfig.nodecgPort}`;
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
app.quit();
}
type AppShutdownState = "running" | "stopping" | "stopped";
let mainWindow: BrowserWindow | null = null;
let loadingWindow: BrowserWindow | null = null;
let nodecgManager: NodecgProcessManager | null = null;
let shutdownState: AppShutdownState = "running";
function focusExistingWindow(): void {
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow;
if (!targetWindow || targetWindow.isDestroyed()) {
return;
}
if (targetWindow.isMinimized()) {
targetWindow.restore();
}
targetWindow.show();
targetWindow.focus();
}
async function launchApplication(): Promise<void> {
const preparedRuntime = prepareUserNodecgRuntime({
sourceRuntimePath: sourceNodecgRuntimePath,
userDataPath: app.getPath("userData"),
appVersion: app.getVersion(),
bundleName: appConfig.bundleName,
log,
});
if (preparedRuntime.installed && app.isPackaged) {
log("Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.");
app.relaunch();
app.exit(0);
return;
}
nodecgManager = createNodecgProcessManager({
isDev,
nodecgRootPath: preparedRuntime.runtimePath,
nodecgBaseUrl,
appConfig,
log,
});
// We create both windows early so startup feels instant while NodeCG is booting in the background.
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
loadingWindow = createLoadingWindow({ appConfig, rootPath });
await startNodecg();
if (!loadingWindow || loadingWindow.isDestroyed()) {
return;
}
await loadingWindow.loadURL(loadingDashboardUrl);
loadingWindow.show();
const loadingShownAt = Date.now();
if (!mainWindow) {
return;
}
await mainWindow.loadURL(mainDashboardUrl);
// Keep the loading overlay visible for a minimum amount of time to avoid abrupt flashes.
const remainingLoadingDelay = getRemainingDelayMs(appConfig.loadDelayMs, loadingShownAt);
if (remainingLoadingDelay > 0) {
await sleep(remainingLoadingDelay);
}
mainWindow.show();
closeLoadingWindow();
scheduleUpdateCheck({
appConfig,
rootPath,
getParentWindow: () => mainWindow,
beforeInstall: stopNodecgGracefully,
log,
});
}
async function startNodecg(): Promise<void> {
if (!nodecgManager) {
throw new Error("NodeCG process manager is not initialized.");
}
await nodecgManager.startNodecgProcess();
await nodecgManager.waitForNodecgReady(Date.now());
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function closeLoadingWindow(): void {
if (!loadingWindow || loadingWindow.isDestroyed()) {
return;
}
loadingWindow.close();
loadingWindow = null;
}
function stopNodecgGracefully(): Promise<void> {
if (shutdownState === "stopped") {
return Promise.resolve();
}
if (shutdownState === "stopping") {
return nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve();
}
shutdownState = "stopping";
return (nodecgManager?.stopNodecgProcessGracefully() ?? Promise.resolve()).finally(() => {
shutdownState = "stopped";
});
}
app.on("ready", () => {
if (!hasSingleInstanceLock) {
return;
}
if (process.platform === "win32") {
app.setAppUserModelId(appConfig.userModelId);
}
launchApplication().catch((error: unknown) => {
showFatalError("No se pudo iniciar Scoreko.", error);
closeLoadingWindow();
app.exit(1);
});
});
app.on("second-instance", () => {
focusExistingWindow();
});
app.on("activate", async () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createMainWindow({ appConfig, rootPath, mainDashboardUrl });
await mainWindow.loadURL(mainDashboardUrl);
mainWindow.show();
}
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", (event) => {
if (shutdownState !== "running") {
return;
}
// Block the default quit flow until we ask NodeCG to stop cleanly.
event.preventDefault();
stopNodecgGracefully().finally(() => {
app.quit();
});
});
app.on("will-quit", () => {
if (shutdownState === "running") {
void stopNodecgGracefully();
}
});
process.on("exit", () => {
if (shutdownState === "running") {
void stopNodecgGracefully();
}
});
process.on("uncaughtException", (error) => {
showFatalError("Unexpected error in Electron main process.", error);
});
process.on("unhandledRejection", (reason) => {
showFatalError("Unhandled promise in Electron main process.", reason);
});
@@ -0,0 +1,58 @@
import { SpawnOptions } from "node:child_process";
export type PlatformProcessKillerDeps = {
platform: NodeJS.Platform;
spawnProcess: (command: string, args: string[], options: SpawnOptions) => SpawnedKillerProcess;
killProcess: (pid: number, signal: NodeJS.Signals) => void;
log: (...args: unknown[]) => void;
};
type SpawnedKillerProcess = {
on: (event: "error", listener: (error: Error) => void) => unknown;
};
export function killProcessTree(pid: number, signal: NodeJS.Signals, deps: PlatformProcessKillerDeps): boolean {
if (!Number.isSafeInteger(pid) || pid <= 0) {
deps.log(`Invalid pid for process tree termination: ${pid}`);
return false;
}
if (deps.platform === "win32") {
return killWindowsProcessTree(pid, signal, deps);
}
return killPosixProcessTree(pid, signal, deps.killProcess);
}
function killWindowsProcessTree(
pid: number,
signal: NodeJS.Signals,
deps: Pick<PlatformProcessKillerDeps, "spawnProcess" | "log">,
): boolean {
const args = ["/pid", String(pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])];
const killer = deps.spawnProcess("taskkill", args, {
stdio: "ignore",
shell: false,
windowsHide: true,
});
killer.on("error", (error) => {
deps.log(`taskkill error for pid=${pid}`, error);
});
return true;
}
function killPosixProcessTree(pid: number, signal: NodeJS.Signals, killProcess: PlatformProcessKillerDeps["killProcess"]): boolean {
try {
killProcess(-pid, signal);
return true;
} catch {
try {
killProcess(pid, signal);
return true;
} catch {
return false;
}
}
}
+77 -43
View File
@@ -1,10 +1,11 @@
import { ChildProcess, spawn, SpawnOptions } from "node:child_process"; import { spawn, SpawnOptions } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import net from "node:net"; import net from "node:net";
import path from "node:path"; import path from "node:path";
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { NODE_RUNTIME_NAME } from "../constants"; import { NODE_RUNTIME_NAME } from "../constants";
import { killProcessTree } from "./platform-process-killer";
type NodecgProcessManagerConfig = { type NodecgProcessManagerConfig = {
isDev: boolean; isDev: boolean;
@@ -16,7 +17,7 @@ type NodecgProcessManagerConfig = {
}; };
type NodecgProcessManagerDeps = { type NodecgProcessManagerDeps = {
spawnProcess: (command: string, args: string[], options: SpawnOptions) => ChildProcess; spawnProcess: (command: string, args: string[], options: SpawnOptions) => NodecgChildProcess;
pathExists: (candidatePath: string) => boolean; pathExists: (candidatePath: string) => boolean;
fetchUrl: typeof fetch; fetchUrl: typeof fetch;
platform: NodeJS.Platform; platform: NodeJS.Platform;
@@ -30,13 +31,31 @@ type NodecgProcessManagerDeps = {
hasReadWriteAccess: (candidatePath: string) => boolean; hasReadWriteAccess: (candidatePath: string) => boolean;
}; };
type NodecgChildProcess = {
pid?: number;
killed: boolean;
exitCode: number | null;
signalCode: NodeJS.Signals | null;
stdout?: ProcessOutputStream | null;
stderr?: ProcessOutputStream | null;
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
on(event: "error", listener: (error: Error) => void): unknown;
once(event: "exit", listener: () => void): unknown;
};
type ProcessOutputStream = {
on(event: "data", listener: (chunk: unknown) => void): unknown;
};
export type NodecgProcessManager = { export type NodecgProcessManager = {
startNodecgProcess: () => Promise<ChildProcess>; startNodecgProcess: () => Promise<void>;
waitForNodecgReady: (startTime: number) => Promise<void>; waitForNodecgReady: (startTime: number) => Promise<void>;
stopNodecgProcessGracefully: () => Promise<void>; stopNodecgProcessGracefully: () => Promise<void>;
getProcess: () => ChildProcess | null; getState: () => NodecgProcessState;
}; };
export type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
export function createNodecgProcessManager({ export function createNodecgProcessManager({
isDev, isDev,
nodecgRootPath, nodecgRootPath,
@@ -47,12 +66,28 @@ export function createNodecgProcessManager({
}: NodecgProcessManagerConfig): NodecgProcessManager { }: NodecgProcessManagerConfig): NodecgProcessManager {
const resolvedDeps = resolveDeps(deps); const resolvedDeps = resolveDeps(deps);
let nodecgProcess: ChildProcess | null = null; let nodecgProcess: NodecgChildProcess | null = null;
let nodecgState: NodecgProcessState = "idle";
let startNodecgPromise: Promise<void> | null = null;
let stopNodecgPromise: Promise<void> | null = null; let stopNodecgPromise: Promise<void> | null = null;
let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
let lastStderrLine: string | null = null; let lastStderrLine: string | null = null;
const startNodecgProcess = async (): Promise<ChildProcess> => { const startNodecgProcess = (): Promise<void> => {
if (nodecgProcess && nodecgState === "running") {
return Promise.resolve();
}
if (startNodecgPromise) {
return startNodecgPromise;
}
if (nodecgState === "stopping") {
return Promise.reject(new Error("Cannot start NodeCG while shutdown is in progress."));
}
nodecgState = "starting";
startNodecgPromise = (async () => {
// Fail fast with actionable errors before spawning child processes. // Fail fast with actionable errors before spawning child processes.
validateNodecgInstall( validateNodecgInstall(
nodecgRootPath, nodecgRootPath,
@@ -99,13 +134,30 @@ export function createNodecgProcessManager({
child.on("exit", (code, signal) => { child.on("exit", (code, signal) => {
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`); log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
lastExit = { code, signal }; lastExit = { code, signal };
if (nodecgProcess === child) {
nodecgProcess = null; nodecgProcess = null;
}
if (nodecgState !== "stopping") {
nodecgState = code === 0 ? "stopped" : "failed";
}
}); });
lastExit = null; lastExit = null;
lastStderrLine = null; lastStderrLine = null;
nodecgProcess = child; nodecgProcess = child;
return 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> => {
@@ -149,6 +201,7 @@ export function createNodecgProcessManager({
} }
if (!nodecgProcess || nodecgProcess.killed) { if (!nodecgProcess || nodecgProcess.killed) {
nodecgState = "stopped";
return Promise.resolve(); return Promise.resolve();
} }
@@ -157,11 +210,19 @@ 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; let completed = false;
@@ -177,6 +238,7 @@ export function createNodecgProcessManager({
nodecgProcess = null; nodecgProcess = null;
} }
nodecgState = "stopped";
stopNodecgPromise = null; stopNodecgPromise = null;
resolve(); resolve();
}; };
@@ -189,7 +251,12 @@ 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(); complete();
} }
}, },
@@ -204,7 +271,7 @@ export function createNodecgProcessManager({
startNodecgProcess, startNodecgProcess,
waitForNodecgReady, waitForNodecgReady,
stopNodecgProcessGracefully, stopNodecgProcessGracefully,
getProcess: () => nodecgProcess, getState: () => nodecgState,
}; };
} }
@@ -315,39 +382,6 @@ function probePortAvailable(port: number): Promise<boolean> {
}); });
} }
function killNodecgProcessTree(
pid: number,
signal: NodeJS.Signals,
log: (...args: unknown[]) => void,
deps: Pick<NodecgProcessManagerDeps, "platform" | "spawnProcess" | "killProcess">,
): boolean {
if (deps.platform === "win32") {
const force = signal === "SIGKILL" ? "/F" : "";
const killer = deps.spawnProcess("taskkill", ["/pid", String(pid), "/T", ...(force ? [force] : [])], {
stdio: "ignore",
shell: true,
});
killer.on("error", (error) => {
log(`taskkill error for pid=${pid}`, error);
});
return true;
}
try {
deps.killProcess(-pid, signal);
return true;
} catch {
try {
deps.killProcess(pid, signal);
return true;
} catch {
return false;
}
}
}
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> { 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);
+5 -1
View File
@@ -1,6 +1,8 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { getManagedNodecgRuntimePath } from "../app/paths";
type RuntimeProvisionerConfig = { type RuntimeProvisionerConfig = {
sourceRuntimePath: string; sourceRuntimePath: string;
userDataPath: string; userDataPath: string;
@@ -38,6 +40,7 @@ type RuntimeManifest = {
bundleName?: unknown; bundleName?: unknown;
sourceRuntime?: RuntimeManifest | null; sourceRuntime?: RuntimeManifest | null;
bundleVersion?: unknown; bundleVersion?: unknown;
generatedAt?: unknown;
nodecgVersion?: unknown; nodecgVersion?: unknown;
}; };
@@ -54,7 +57,7 @@ export function prepareUserNodecgRuntime({
deps, deps,
}: RuntimeProvisionerConfig): PreparedNodecgRuntime { }: RuntimeProvisionerConfig): PreparedNodecgRuntime {
const resolvedDeps = resolveDeps(deps); const resolvedDeps = resolveDeps(deps);
const targetRuntimePath = path.join(userDataPath, "nodecg"); const targetRuntimePath = getManagedNodecgRuntimePath(userDataPath);
validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync); validateSourceRuntime(sourceRuntimePath, bundleName, resolvedDeps.existsSync);
resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true }); resolvedDeps.mkdirSync(targetRuntimePath, { recursive: true });
@@ -131,6 +134,7 @@ function shouldInstallRuntime(
targetMarker?.appVersion !== appVersion || targetMarker?.appVersion !== appVersion ||
targetMarker?.bundleName !== bundleName || targetMarker?.bundleName !== bundleName ||
targetMarker?.sourceRuntime?.bundleVersion !== sourceMarker?.bundleVersion || targetMarker?.sourceRuntime?.bundleVersion !== sourceMarker?.bundleVersion ||
targetMarker?.sourceRuntime?.generatedAt !== sourceMarker?.generatedAt ||
targetMarker?.sourceRuntime?.nodecgVersion !== sourceMarker?.nodecgVersion targetMarker?.sourceRuntime?.nodecgVersion !== sourceMarker?.nodecgVersion
); );
} }
+89
View File
@@ -0,0 +1,89 @@
import fs from "node:fs";
import { getDefaultUpdateConfigPath } from "../app/paths";
import { AppRuntimeConfig } from "../config/runtime-config";
import { isRecord, readNonEmptyString } from "../utils/unknown-values";
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;
};
type UpdateRuntimeConfig = Pick<
AppRuntimeConfig,
| "updateApiUrl"
| "updateAssetPattern"
| "updateConfigPathOverride"
| "updateReleasePageUrl"
| "updatesEnabled"
>;
export function loadUpdateSettings(
appConfig: UpdateRuntimeConfig,
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 || readNonEmptyString(fileConfig.assetPattern) || DEFAULT_UPDATE_ASSET_PATTERN,
};
}
export function readUpdateFileConfig(
appConfig: Pick<AppRuntimeConfig, "updateConfigPathOverride">,
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 = readNonEmptyString(value);
if (!rawValue) {
return undefined;
}
return validateHttpUrl(rawValue, options) ?? undefined;
}
+49
View File
@@ -0,0 +1,49 @@
import { BrowserWindow, dialog } from "electron";
import type { MessageBoxOptions, MessageBoxReturnValue } from "electron";
import { ReleaseUpdate } from "./update-schema";
export type DownloadUpdateChoice = "download" | "open-release" | "dismiss";
export async function askToDownloadUpdate(
update: ReleaseUpdate,
releasePageUrl: string | undefined,
parentWindow: BrowserWindow | null,
): Promise<DownloadUpdateChoice> {
const result = await showMessageBox(parentWindow, {
type: "info",
title: "Actualización disponible",
message: `Scoreko ${update.version} está disponible.`,
detail: "Puedes descargarla ahora o seguir usando esta versión.",
buttons: releasePageUrl ? ["Descargar", "Ver release", "Ahora no"] : ["Descargar", "Ahora no"],
defaultId: 0,
cancelId: releasePageUrl ? 2 : 1,
});
if (releasePageUrl && result.response === 1) {
return "open-release";
}
return result.response === 0 ? "download" : "dismiss";
}
export async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: BrowserWindow | null): Promise<boolean> {
const result = await showMessageBox(parentWindow, {
type: "question",
title: "Actualización descargada",
message: `Scoreko ${update.version} se ha descargado.`,
detail: "Para instalarla se cerrará Scoreko y se abrirá el instalador.",
buttons: ["Instalar y cerrar", "Luego"],
defaultId: 0,
cancelId: 1,
});
return result.response === 0;
}
function showMessageBox(
parentWindow: BrowserWindow | null,
options: MessageBoxOptions,
): Promise<MessageBoxReturnValue> {
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
}
+103
View File
@@ -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();
});
});
}
-213
View File
@@ -1,213 +0,0 @@
import { app, BrowserWindow, dialog, shell } from "electron";
import type { MessageBoxOptions } from "electron";
import fs from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
import { AppRuntimeConfig } from "../config/runtime-config";
import { buildReleaseUpdate, GiteaRelease, ReleaseUpdate, sanitizeFileName, UpdateFileConfig } from "./update-utils";
type UpdateManagerConfig = {
appConfig: AppRuntimeConfig;
rootPath: string;
getParentWindow: () => BrowserWindow | null;
beforeInstall: () => Promise<void>;
log: (...args: unknown[]) => void;
};
type UpdateSettings = {
enabled: boolean;
apiUrl?: string;
releasePageUrl?: string;
assetPattern: string;
};
export function scheduleUpdateCheck({
appConfig,
rootPath,
getParentWindow,
beforeInstall,
log,
}: UpdateManagerConfig): void {
const settings = loadUpdateSettings(appConfig, rootPath, log);
if (!settings.enabled || !settings.apiUrl) {
log("Update checks disabled or not configured.");
return;
}
setTimeout(() => {
void checkForUpdates({ settings, getParentWindow, beforeInstall, log });
}, appConfig.updateCheckDelayMs);
}
async function checkForUpdates({
settings,
getParentWindow,
beforeInstall,
log,
}: {
settings: UpdateSettings;
getParentWindow: () => BrowserWindow | null;
beforeInstall: () => Promise<void>;
log: (...args: unknown[]) => void;
}): Promise<void> {
try {
if (!settings.apiUrl) {
return;
}
const release = await fetchLatestRelease(settings.apiUrl);
const update = buildReleaseUpdate(release, app.getVersion(), settings.assetPattern);
if (!update) {
log("No Scoreko update available.");
return;
}
const shouldDownload = await askToDownloadUpdate(
update,
settings.releasePageUrl ?? update.pageUrl,
getParentWindow(),
);
if (!shouldDownload) {
return;
}
const installerPath = await downloadInstaller(update);
const shouldInstall = await askToInstallUpdate(update, getParentWindow());
if (!shouldInstall) {
await shell.showItemInFolder(installerPath);
return;
}
await beforeInstall();
const openError = await shell.openPath(installerPath);
if (openError) {
throw new Error(openError);
}
app.exit(0);
} catch (error) {
log("Update check failed.", error);
}
}
function loadUpdateSettings(
appConfig: AppRuntimeConfig,
rootPath: string,
log: (...args: unknown[]) => void,
): UpdateSettings {
const fileConfig = readUpdateFileConfig(appConfig, rootPath, log);
return {
enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)),
apiUrl: appConfig.updateApiUrl ?? readOptionalString(fileConfig.apiUrl),
releasePageUrl: appConfig.updateReleasePageUrl ?? readOptionalString(fileConfig.releasePageUrl),
assetPattern:
appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || "Scoreko-setup-.*\\.exe$",
};
}
function readUpdateFileConfig(
appConfig: AppRuntimeConfig,
rootPath: string,
log: (...args: unknown[]) => void,
): UpdateFileConfig {
const configPath = appConfig.updateConfigPathOverride ?? path.join(rootPath, "static", "updates.json");
if (!fs.existsSync(configPath)) {
return {};
}
try {
return JSON.parse(fs.readFileSync(configPath, "utf8")) as UpdateFileConfig;
} catch (error) {
log(`Could not read update config at ${configPath}.`, error);
return {};
}
}
async function fetchLatestRelease(apiUrl: string): Promise<GiteaRelease> {
const response = await fetch(apiUrl, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Gitea update check failed with HTTP ${response.status}.`);
}
return (await response.json()) as GiteaRelease;
}
async function askToDownloadUpdate(
update: ReleaseUpdate,
releasePageUrl: string | undefined,
parentWindow: BrowserWindow | null,
): Promise<boolean> {
const result = await showMessageBox(parentWindow, {
type: "info",
title: "Actualización disponible",
message: `Scoreko ${update.version} está disponible.`,
detail: "Puedes descargarla ahora o seguir usando esta versión.",
buttons: releasePageUrl ? ["Descargar", "Ver release", "Ahora no"] : ["Descargar", "Ahora no"],
defaultId: 0,
cancelId: releasePageUrl ? 2 : 1,
});
if (releasePageUrl && result.response === 1) {
await shell.openExternal(releasePageUrl);
return false;
}
return result.response === 0;
}
async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: BrowserWindow | null): Promise<boolean> {
const result = await showMessageBox(parentWindow, {
type: "question",
title: "Actualización descargada",
message: `Scoreko ${update.version} se ha descargado.`,
detail: "Para instalarla se cerrará Scoreko y se abrirá el instalador.",
buttons: ["Instalar y cerrar", "Luego"],
defaultId: 0,
cancelId: 1,
});
return result.response === 0;
}
async function downloadInstaller(update: ReleaseUpdate): Promise<string> {
const safeFileName = sanitizeFileName(update.installer.name);
const downloadDirectory = path.join(app.getPath("temp"), "scoreko-updates");
const targetPath = path.join(downloadDirectory, safeFileName);
fs.mkdirSync(downloadDirectory, { recursive: true });
const response = await fetch(update.installer.downloadUrl);
if (!response.ok || !response.body) {
throw new Error(`Could not download update installer. HTTP ${response.status}.`);
}
await new Promise<void>((resolve, reject) => {
const fileStream = fs.createWriteStream(targetPath);
const responseStream = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
responseStream.on("error", reject);
fileStream.on("error", reject);
fileStream.on("finish", resolve);
responseStream.pipe(fileStream);
});
return targetPath;
}
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function showMessageBox(parentWindow: BrowserWindow | null, options: MessageBoxOptions) {
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
}
+200
View File
@@ -0,0 +1,200 @@
import { isRecord, readNonEmptyString } from "../utils/unknown-values";
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;
}
const title = readNonEmptyString(value.name);
const pageUrl = readOptionalUrlString(value.html_url);
return {
tagName,
assets,
...(title ? { title } : {}),
...(pageUrl ? { pageUrl } : {}),
};
}
export function isVersionNewer(candidateVersion: string, currentVersion: string): boolean {
const candidate = normalizeVersion(candidateVersion);
const current = normalizeVersion(currentVersion);
for (let index = 0; index < Math.max(candidate.length, current.length); index += 1) {
const candidatePart = candidate[index] ?? 0;
const currentPart = current[index] ?? 0;
if (candidatePart > currentPart) {
return true;
}
if (candidatePart < currentPart) {
return false;
}
}
return false;
}
export function selectInstallerAsset(
release: GiteaRelease,
assetPattern: string,
policy: UrlPolicy = { allowInsecureHttp: true },
): InstallerAsset | null {
const matcher = new RegExp(assetPattern, "i");
for (const asset of release.assets) {
if (!matcher.test(asset.name)) {
continue;
}
const downloadUrl = validateHttpUrl(asset.browserDownloadUrl, policy);
if (!downloadUrl) {
continue;
}
return {
name: asset.name,
downloadUrl,
...(typeof asset.size === "number" ? { size: asset.size } : {}),
};
}
return null;
}
export function buildReleaseUpdate(
release: GiteaRelease,
currentVersion: string,
assetPattern: string,
policy: UrlPolicy = { allowInsecureHttp: true },
): ReleaseUpdate | null {
const version = release.tagName.replace(/^v/i, "");
if (!version || !isVersionNewer(version, currentVersion)) {
return null;
}
const installer = selectInstallerAsset(release, assetPattern, policy);
if (!installer) {
return null;
}
const pageUrl = release.pageUrl ? validateHttpUrl(release.pageUrl, policy) ?? undefined : undefined;
return {
version,
title: release.title ?? `Scoreko ${version}`,
...(pageUrl ? { pageUrl } : {}),
installer,
};
}
export function sanitizeFileName(fileName: string): string {
const sanitized = fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim();
return sanitized.length > 0 ? sanitized : "scoreko-update-installer";
}
export function validateHttpUrl(value: string, policy: UrlPolicy): string | null {
try {
const url = new URL(value);
if (url.protocol === "https:" || (policy.allowInsecureHttp && url.protocol === "http:")) {
return url.toString();
}
return null;
} catch {
return null;
}
}
function parseGiteaReleaseAsset(value: unknown): GiteaReleaseAsset | null {
if (!isRecord(value)) {
return null;
}
const name = readRequiredString(value.name);
const browserDownloadUrl = readRequiredString(value.browser_download_url);
if (!name || !browserDownloadUrl) {
return null;
}
return {
name,
browserDownloadUrl,
...(typeof value.size === "number" && value.size >= 0 ? { size: value.size } : {}),
};
}
function normalizeVersion(version: string): number[] {
return version
.trim()
.replace(/^v/i, "")
.split(/[+-]/)[0]
.split(".")
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0));
}
function readRequiredString(value: unknown): string | null {
const text = readNonEmptyString(value);
return text && text.length > 0 ? text : null;
}
function readOptionalUrlString(value: unknown): string | undefined {
const rawValue = readNonEmptyString(value);
if (!rawValue) {
return undefined;
}
return validateHttpUrl(rawValue, { allowInsecureHttp: true }) ?? undefined;
}
function isPresent<T>(value: T | null): value is T {
return value !== null;
}
+130
View File
@@ -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,
};
}
-123
View File
@@ -1,123 +0,0 @@
export type GiteaReleaseAsset = {
name?: unknown;
browser_download_url?: unknown;
size?: unknown;
};
export type GiteaRelease = {
tag_name?: unknown;
name?: unknown;
html_url?: unknown;
assets?: unknown;
};
export type InstallerAsset = {
name: string;
downloadUrl: string;
size?: number;
};
export type ReleaseUpdate = {
version: string;
title: string;
pageUrl?: string;
installer: InstallerAsset;
};
export type UpdateFileConfig = {
enabled?: unknown;
apiUrl?: unknown;
releasePageUrl?: unknown;
assetPattern?: unknown;
};
export function isVersionNewer(candidateVersion: string, currentVersion: string): boolean {
const candidate = normalizeVersion(candidateVersion);
const current = normalizeVersion(currentVersion);
for (let index = 0; index < Math.max(candidate.length, current.length); index += 1) {
const candidatePart = candidate[index] ?? 0;
const currentPart = current[index] ?? 0;
if (candidatePart > currentPart) {
return true;
}
if (candidatePart < currentPart) {
return false;
}
}
return false;
}
export function getReleaseVersion(release: GiteaRelease): string | null {
const tagName = typeof release.tag_name === "string" ? release.tag_name.trim() : "";
return tagName.length > 0 ? tagName.replace(/^v/i, "") : null;
}
export function getReleaseTitle(release: GiteaRelease, version: string): string {
const releaseName = typeof release.name === "string" ? release.name.trim() : "";
return releaseName.length > 0 ? releaseName : `Scoreko ${version}`;
}
export function selectInstallerAsset(release: GiteaRelease, assetPattern: string): InstallerAsset | null {
const assets = Array.isArray(release.assets) ? release.assets : [];
const matcher = new RegExp(assetPattern, "i");
for (const asset of assets as GiteaReleaseAsset[]) {
const name = typeof asset.name === "string" ? asset.name : "";
const downloadUrl = typeof asset.browser_download_url === "string" ? asset.browser_download_url : "";
if (!name || !downloadUrl || !matcher.test(name)) {
continue;
}
return {
name,
downloadUrl,
...(typeof asset.size === "number" ? { size: asset.size } : {}),
};
}
return null;
}
export function buildReleaseUpdate(
release: GiteaRelease,
currentVersion: string,
assetPattern: string,
): ReleaseUpdate | null {
const version = getReleaseVersion(release);
if (!version || !isVersionNewer(version, currentVersion)) {
return null;
}
const installer = selectInstallerAsset(release, assetPattern);
if (!installer) {
return null;
}
const pageUrl = typeof release.html_url === "string" && release.html_url.length > 0 ? release.html_url : undefined;
return {
version,
title: getReleaseTitle(release, version),
pageUrl,
installer,
};
}
export function sanitizeFileName(fileName: string): string {
return fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_");
}
function normalizeVersion(version: string): number[] {
return version
.trim()
.replace(/^v/i, "")
.split(/[+-]/)[0]
.split(".")
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0));
}
+7
View File
@@ -0,0 +1,7 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
@@ -1,19 +1,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);
});
}
+59
View File
@@ -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/);
});
+269
View File
@@ -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;
}
@@ -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";
+65
View File
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { SpawnOptions } from "node:child_process";
import test from "node:test";
import { killProcessTree } from "../main/nodecg/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" },
]);
});
+48 -6
View File
@@ -85,7 +85,7 @@ test("waitForNodeCGReady resolves when endpoint returns 404", async () => {
deps: { deps: {
platform: "linux", platform: "linux",
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => { setTimer: (handler: (...args: unknown[]) => void, _timeoutMs: number) => {
handler(); handler();
@@ -118,7 +118,7 @@ test("stopNodeCG sends SIGTERM and then SIGKILL if the process does not exit", a
deps: { deps: {
platform: "linux", platform: "linux",
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
killProcess: (pid, signal) => { killProcess: (pid, signal) => {
killSignals.push({ pid, signal }); killSignals.push({ pid, signal });
@@ -163,7 +163,7 @@ test("stopNodeCG reuses the same promise when invoked in parallel", async () =>
log: () => undefined, log: () => undefined,
deps: { deps: {
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
killProcess: () => undefined, killProcess: () => undefined,
setTimer: () => 0, setTimer: () => 0,
@@ -184,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;
},
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[] = [];
@@ -199,7 +241,7 @@ test("stopNodeCG normalizes negative timeout to zero", async () => {
log: () => undefined, log: () => undefined,
deps: { deps: {
pathExists: () => true, pathExists: () => true,
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => ({ ok: false, status: 404 }) as Response, fetchUrl: async () => ({ ok: false, status: 404 }) as Response,
killProcess: () => undefined, killProcess: () => undefined,
setTimer: (handler, timeoutMs) => { setTimer: (handler, timeoutMs) => {
@@ -264,7 +306,7 @@ test("startNodeCG spawns Electron directly on Windows", async () => {
capturedCommand = command; capturedCommand = command;
capturedArgs = args; capturedArgs = args;
capturedOptions.push(options); capturedOptions.push(options);
return child as unknown as import("node:child_process").ChildProcess; return child;
}, },
stdoutWrite: () => undefined, stdoutWrite: () => undefined,
stderrWrite: () => undefined, stderrWrite: () => undefined,
@@ -291,7 +333,7 @@ test("waitForNodeCGReady exposes diagnostics when NodeCG exits before readiness"
deps: { deps: {
pathExists: () => true, pathExists: () => true,
platform: "linux", platform: "linux",
spawnProcess: () => child as unknown as import("node:child_process").ChildProcess, spawnProcess: () => child,
fetchUrl: async () => { fetchUrl: async () => {
child.emit("exit", 1, null); child.emit("exit", 1, null);
throw new Error("still starting"); throw new Error("still starting");
-13
View File
@@ -5,7 +5,6 @@ import {
getEnv, getEnv,
getOptionalEnv, getOptionalEnv,
parseEnvBool, parseEnvBool,
parseEnvInt,
parseEnvIntInRange, parseEnvIntInRange,
parseEnvPort, parseEnvPort,
parseOptionalHttpUrl, parseOptionalHttpUrl,
@@ -56,18 +55,6 @@ test("getEnv returns the value when present", () => {
}); });
}); });
test("parseEnvInt returns fallback for invalid values", () => {
withEnv("TEST_ENV_INT", "abc", () => {
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 100);
});
});
test("parseEnvInt parses valid integers", () => {
withEnv("TEST_ENV_INT", "4500", () => {
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 4500);
});
});
test("parseEnvIntInRange hard-fails for out-of-range values", () => { test("parseEnvIntInRange hard-fails for out-of-range values", () => {
withEnv("TEST_ENV_INT_RANGE", "999", () => { withEnv("TEST_ENV_INT_RANGE", "999", () => {
assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /must be an integer/); assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /must be an integer/);
+41 -2
View File
@@ -92,7 +92,7 @@ test("prepareUserNodecgRuntime keeps an up-to-date runtime in place", () => {
const source = path.normalize("/app/lib/nodecg"); const source = path.normalize("/app/lib/nodecg");
const userData = path.normalize("/user/scoreko"); const userData = path.normalize("/user/scoreko");
const target = path.join(userData, "nodecg"); const target = path.join(userData, "nodecg");
const sourceManifest = { bundleVersion: "0.1.0", nodecgVersion: "2.6.4" }; const sourceManifest = { bundleVersion: "0.1.0", generatedAt: "2026-05-24T00:00:00.000Z", nodecgVersion: "2.6.4" };
const targetManifest = { appVersion: "0.1.0", bundleName: "scoreko-dev", sourceRuntime: sourceManifest }; const targetManifest = { appVersion: "0.1.0", bundleName: "scoreko-dev", sourceRuntime: sourceManifest };
const { state, deps } = createFakeFs( const { state, deps } = createFakeFs(
[ [
@@ -125,7 +125,7 @@ test("prepareUserNodecgRuntime refreshes managed files when the app version chan
const source = path.normalize("/app/lib/nodecg"); const source = path.normalize("/app/lib/nodecg");
const userData = path.normalize("/user/scoreko"); const userData = path.normalize("/user/scoreko");
const target = path.join(userData, "nodecg"); const target = path.join(userData, "nodecg");
const sourceManifest = { bundleVersion: "0.1.0", nodecgVersion: "2.6.4" }; const sourceManifest = { bundleVersion: "0.1.0", generatedAt: "2026-05-24T00:00:00.000Z", nodecgVersion: "2.6.4" };
const targetManifest = { appVersion: "0.0.9", bundleName: "scoreko-dev", sourceRuntime: sourceManifest }; const targetManifest = { appVersion: "0.0.9", bundleName: "scoreko-dev", sourceRuntime: sourceManifest };
const { state, deps } = createFakeFs( const { state, deps } = createFakeFs(
[ [
@@ -155,3 +155,42 @@ test("prepareUserNodecgRuntime refreshes managed files when the app version chan
assert.ok(state.removed.includes(path.join(target, "bundles"))); assert.ok(state.removed.includes(path.join(target, "bundles")));
assert.ok(!state.removed.includes(path.join(target, "db"))); 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")));
});
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createShutdownService } from "../main/app/shutdown-service";
test("shutdown service reuses the same stop promise while stopping", async () => {
let stopCalls = 0;
let releaseStop: () => void = () => {
throw new Error("stop promise was not created");
};
const service = createShutdownService(
() =>
new Promise<void>((resolve) => {
stopCalls += 1;
releaseStop = resolve;
}),
);
const first = service.stop();
const second = service.stop();
assert.equal(first, second);
assert.equal(stopCalls, 1);
assert.equal(service.getState(), "stopping");
releaseStop();
await first;
assert.equal(service.getState(), "stopped");
await service.stop();
assert.equal(stopCalls, 1);
});
+105
View File
@@ -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-config-"));
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-config-"));
const staticPath = path.join(rootPath, "static");
fs.mkdirSync(staticPath, { recursive: true });
fs.writeFileSync(path.join(staticPath, "updates.json"), JSON.stringify(config), "utf8");
return rootPath;
}
+58
View File
@@ -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/,
);
});
@@ -4,9 +4,10 @@ import test from "node:test";
import { import {
buildReleaseUpdate, buildReleaseUpdate,
isVersionNewer, isVersionNewer,
parseGiteaRelease,
sanitizeFileName, sanitizeFileName,
selectInstallerAsset, selectInstallerAsset,
} from "../main/updates/update-utils"; } from "../main/updates/update-schema";
test("isVersionNewer compares semantic versions with optional v prefix", () => { test("isVersionNewer compares semantic versions with optional v prefix", () => {
assert.equal(isVersionNewer("v0.2.0", "0.1.9"), true); assert.equal(isVersionNewer("v0.2.0", "0.1.9"), true);
@@ -17,9 +18,10 @@ test("isVersionNewer compares semantic versions with optional v prefix", () => {
test("selectInstallerAsset picks the first matching exe asset", () => { test("selectInstallerAsset picks the first matching exe asset", () => {
const asset = selectInstallerAsset( const asset = selectInstallerAsset(
{ {
tagName: "v0.2.0",
assets: [ assets: [
{ name: "latest.yml", browser_download_url: "http://gitea/latest.yml" }, { name: "latest.yml", browserDownloadUrl: "http://gitea/latest.yml" },
{ name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 }, { name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe", size: 100 },
], ],
}, },
"Scoreko-setup-.*\\.exe$", "Scoreko-setup-.*\\.exe$",
@@ -35,8 +37,8 @@ test("selectInstallerAsset picks the first matching exe asset", () => {
test("buildReleaseUpdate returns null when the release is not newer", () => { test("buildReleaseUpdate returns null when the release is not newer", () => {
const update = buildReleaseUpdate( const update = buildReleaseUpdate(
{ {
tag_name: "v0.1.0", tagName: "v0.1.0",
assets: [{ name: "Scoreko-setup-0.1.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.1.0.exe" }], assets: [{ name: "Scoreko-setup-0.1.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.1.0.exe" }],
}, },
"0.1.0", "0.1.0",
"Scoreko-setup-.*\\.exe$", "Scoreko-setup-.*\\.exe$",
@@ -48,10 +50,10 @@ test("buildReleaseUpdate returns null when the release is not newer", () => {
test("buildReleaseUpdate builds update info for newer releases", () => { test("buildReleaseUpdate builds update info for newer releases", () => {
const update = buildReleaseUpdate( const update = buildReleaseUpdate(
{ {
tag_name: "v0.2.0", tagName: "v0.2.0",
name: "Scoreko 0.2.0", title: "Scoreko 0.2.0",
html_url: "http://gitea/releases/v0.2.0", pageUrl: "http://gitea/releases/v0.2.0",
assets: [{ name: "Scoreko-setup-0.2.0.exe", browser_download_url: "http://gitea/Scoreko-setup-0.2.0.exe" }], assets: [{ name: "Scoreko-setup-0.2.0.exe", browserDownloadUrl: "http://gitea/Scoreko-setup-0.2.0.exe" }],
}, },
"0.1.0", "0.1.0",
"Scoreko-setup-.*\\.exe$", "Scoreko-setup-.*\\.exe$",
@@ -66,3 +68,22 @@ test("buildReleaseUpdate builds update info for newer releases", () => {
test("sanitizeFileName removes Windows-unsafe characters", () => { test("sanitizeFileName removes Windows-unsafe characters", () => {
assert.equal(sanitizeFileName('Scoreko:setup*"0.2.0.exe'), "Scoreko_setup__0.2.0.exe"); 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);
});