Compare commits

..

20 Commits

Author SHA1 Message Date
Pandipipas 03446a3b4b Merge pull request #47 from Pandipipas/dev
Dev
2026-06-04 17:24:55 +02:00
Pandipipas f0a35bf655 feat: add option to always create desktop shortcut on installation 2026-06-04 15:00:37 +02:00
Pandipipas 934500a1db refactor: remove loading dashboard route references from configuration and tests 2026-06-04 14:51:21 +02:00
Pandipipas 2496f13055 refactor: update security policies in window creation and enhance loading page CSP 2026-06-04 14:42:32 +02:00
Pandipipas 982c771e82 feat: add error handling screen and logging functionality 2026-06-04 14:09:27 +02:00
Pandipipas 6952a9954f refactor: remove outdated phase summary documents and restructure error handling
- Deleted obsolete phase summary documents (PHASE_1_FIX_SUMMARY.md, PHASE_1_SUMMARY.md, PHASE_2_SUMMARY.md, PHASE_3_SUMMARY.md, PHASE_4_SUMMARY.md, SESSION_HANDOFF.md, TARGET_ARCHITECTURE.md) to streamline documentation.
- Introduced error handling module (error-handler.ts) to centralize error logging and presentation.
- Updated bootstrap and application controller to utilize the new error handling module.
- Refactored runtime provisioning logic into a dedicated module (runtime-setup.ts) for better organization.
- Implemented platform-specific process termination logic in process-killer.ts to enhance process management.
- Enhanced navigation policy with a new module (navigation.ts) to improve URL handling and security.
- Updated window service to integrate new navigation logic for internal and external URL handling.
2026-06-04 04:51:03 +02:00
Pandipipas 7102e3dd01 Refactor loading screen with updated styles and dynamic quotes 2026-06-04 04:09:00 +02:00
Pandipipas 5da609cce4 Add loading window functionality with HTML and update application controller 2026-06-04 03:07:40 +02:00
Pandipipas 0ea4c6e01b Enhance application controller and runtime provisioner with loading window visibility and improved file handling 2026-06-04 01:28:28 +02:00
Pandipipas 143ff7e8db Merge pull request #46 from Pandipipas/dev
Dev
2026-06-04 01:27:44 +02:00
Pandipipas beb22cb438 Hide installation and uninstallation details in the custom header of the installer script 2026-06-02 19:36:30 +02:00
Pandipipas 88223d744c Add before-pack script and installer configuration for NSIS 2026-06-01 11:16:07 +02:00
Pandipipas ed5a7d0994 b 2026-05-31 20:39:55 +02:00
Pandipipas d01ae1fa6b Fix complete. Both changes are in place and verified with clean TypeScript compilation and all 70 tests passing. 2026-05-31 19:47:30 +02:00
Pandipipas 3f756feca6 a 2026-05-31 18:57:18 +02:00
Pandipipas ca74a23d19 Improving Installer and Updater Process 2026-05-31 18:52:51 +02:00
Pandipipas 8e6b79ca68 deleted unnecesary 2026-05-31 18:45:57 +02:00
Pandipipas ce59c5db89 env config 2026-05-31 18:35:59 +02:00
Pandipipas 92e2da1758 Augmented NODECG_STARTUP_TIMEOUT_MS 2026-05-31 17:50:54 +02:00
Pandipipas 42a298925b Investigating Electron Startup Failures 2026-05-31 16:24:14 +02:00
52 changed files with 1084 additions and 2345 deletions
+14 -11
View File
@@ -1,24 +1,27 @@
# Runtime / app # SCOREKO Configuration File Template
# Copy this file to '.env' in the application root and edit as needed.
# Application Information (Required)
SCOREKO_APP_TITLE=Scoreko SCOREKO_APP_TITLE=Scoreko
SCOREKO_APP_USER_MODEL_ID=com.scoreko.desktop SCOREKO_APP_USER_MODEL_ID=com.scoreko.desktop
SCOREKO_APP_USER_DATA_DIRECTORY=scoreko SCOREKO_APP_USER_DATA_DIRECTORY=scoreko
# SCOREKO_APP_ICON_PATH=static/icons/icon.ico SCOREKO_APP_ICON_PATH=static/icons/icon.ico
# NodeCG # NodeCG Managed Runtime Configuration (Required)
NODECG_BUNDLE_NAME=scoreko-dev NODECG_BUNDLE_NAME=scoreko-dev
NODECG_PORT=9090 NODECG_PORT=9090
SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true
SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true
# Timing # Timing & Lifecycles (Required)
ELECTRON_LOAD_DELAY_MS=10000 ELECTRON_LOAD_DELAY_MS=10000
NODECG_STARTUP_TIMEOUT_MS=30000 NODECG_STARTUP_TIMEOUT_MS=120000
NODECG_KILL_TIMEOUT_MS=2500 NODECG_KILL_TIMEOUT_MS=2500
# Updates # Automated Updates Configuration (Required)
SCOREKO_UPDATES_ENABLED=true SCOREKO_UPDATES_ENABLED=true
# SCOREKO_UPDATE_API_URL=http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest
# SCOREKO_UPDATE_RELEASE_PAGE_URL=http://gitea.local/OWNER/REPO/releases
SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$
SCOREKO_UPDATE_CHECK_DELAY_MS=5000 SCOREKO_UPDATE_CHECK_DELAY_MS=5000
# SCOREKO_UPDATE_CONFIG_PATH=static/updates.json
# Optional Update Release Source (Only required if SCOREKO_UPDATES_ENABLED is true)
SCOREKO_UPDATE_API_URL=http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest
SCOREKO_UPDATE_RELEASE_PAGE_URL=http://gitea.local/OWNER/REPO/releases
SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$
+1
View File
@@ -7,3 +7,4 @@ lib
.localappdata .localappdata
.npm-cache .npm-cache
.npm-runtime-cache .npm-runtime-cache
.env
+26 -29
View File
@@ -1,47 +1,41 @@
# scoreko-electron # Scoreko Desktop
Windows desktop installer for Scoreko. The packaged app includes Electron, NodeCG, the compiled `scoreko-dev` bundle, and the production modules needed to run it, so end users do not need Node.js, pnpm, or a cloned repository. This is the Windows desktop wrapper for Scoreko. It bundles Electron, NodeCG, and our custom `scoreko-dev` bundle into a single standalone executable. Users just double-click the installer and everything works—no Node.js, pnpm, or command line required.
## Build on a development machine ## Local Development
From the repository root: If you're working on the app locally, start by installing dependencies at the repository root:
```powershell ```powershell
pnpm install pnpm install
``` ```
Then from `scoreko-electron-dev`: Then, move into the wrapper folder:
```powershell ```powershell
cd scoreko-electron-dev
npm install npm install
npm run dist:win
``` ```
The installer is written to `scoreko-electron-dev/release/Scoreko-setup-0.1.0.exe`. ### Useful Commands
## What the build does - `npm run start`: Builds the bundle and launches Electron locally for testing.
- `npm run dist:win`: Packages everything and creates the `.exe` Windows installer in the `release/` folder.
- `npm run prepare:runtime`: Extracts a fresh NodeCG runtime from the parent bundle (useful if you changed dependencies).
- `npm run rebuild:native`: Rebuilds native Node modules (like SQLite) specifically for Electron's V8 engine.
- `npm run doctor`: Runs a quick sanity check to verify your local configuration and port availability.
- Builds the parent `scoreko-dev` bundle with `pnpm build`. ## How it works under the hood
- Creates `scoreko-electron-dev/lib/nodecg` with a small NodeCG runtime.
- Installs production runtime modules into that runtime.
- Rebuilds `better-sqlite3` for Electron before creating the installer.
- Packages the runtime as an Electron extra resource outside the app archive.
## Runtime behavior When you build the installer, the script automatically compiles the main `scoreko-dev` bundle, provisions a lightweight NodeCG runtime in `lib/nodecg`, and packages it as an external asset alongside the Electron app.
On first launch, Scoreko copies the packaged NodeCG runtime to the user's app data folder and then relaunches itself before starting NodeCG. This keeps `cfg`, `db`, and `logs` writable on Windows even when the app is installed under `Program Files`, and avoids transient startup failures caused by freshly copied runtime files. When a user runs Scoreko for the first time, the app copies this NodeCG runtime directly into their local AppData folder. This is a deliberate choice: it ensures that databases, configs, and logs remain fully writable, even if the user installed the app in restricted directories like `Program Files`.
## Useful scripts ## Auto-Updates via Gitea
- `npm run start`: build everything and run Electron locally. Scoreko supports seamless, opt-in updates through your Gitea instance.
- `npm run prepare:runtime`: recreate `lib/nodecg` from the parent bundle.
- `npm run rebuild:native`: rebuild NodeCG native modules for Electron.
- `npm run dist:win`: create the Windows installer.
- `npm run doctor`: check the prepared runtime and the configured port.
## Updates from Gitea Before building your production installer, check `static/updates.json`:
Scoreko can check a Gitea release feed without forcing the user to update. Edit `static/updates.json` before building:
```json ```json
{ {
@@ -52,17 +46,20 @@ Scoreko can check a Gitea release feed without forcing the user to update. Edit
} }
``` ```
For each release, bump `package.json` version, build with `npm run dist:win`, create a Gitea release tagged like `v0.2.0`, and attach `release/Scoreko-setup-0.2.0.exe`. When Scoreko sees a newer tag, it asks whether to download and install it. **To ship an update:**
1. Bump the version in `package.json`.
2. Run `npm run dist:win` to generate the new installer.
3. Create a new release tag in Gitea (e.g., `v0.2.0`) and attach the `.exe`.
4. The app will detect the new version, notify the user, and handle the installation safely.
## Configuration ## Environment Configuration
The defaults match the parent bundle: The app ships with sensible defaults that match our development bundle:
- `NODECG_BUNDLE_NAME=scoreko-dev` - `NODECG_BUNDLE_NAME=scoreko-dev`
- `NODECG_PORT=9090` - `NODECG_PORT=9090`
- `SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true` - `SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true`
- `SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true`
- `SCOREKO_UPDATES_ENABLED=true` - `SCOREKO_UPDATES_ENABLED=true`
- `SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$` - `SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$`
Copy `.env.example` only if you need local overrides while developing. You only need to mess with `.env.example` if you want to override these values locally while testing.
+21 -24
View File
@@ -1,30 +1,27 @@
# Main process architecture # Main Process Architecture
## Startup flow This document breaks down how the Electron main process is structured and what happens when the app launches.
1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`. ## Startup Flow
2. Installs or refreshes the packaged NodeCG runtime in user data when needed (`nodecg/runtime-provisioner.ts`).
3. Creates windows (`windows/window-factory.ts`).
4. In packaged builds, relaunches once after a fresh runtime install so NodeCG starts from a settled user-data runtime.
5. Starts NodeCG with `nodecg/process-manager.ts`.
6. Waits for HTTP readiness and shows loading -> main dashboard.
7. Checks the configured Gitea latest-release endpoint for optional updates.
8. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
## Main modules When a user opens Scoreko, the app goes through a precise sequence to ensure NodeCG starts reliably:
- `config/runtime-config.ts`: read/validate env vars. 1. **Configuration:** `src/main/main.ts` kicks things off by loading `appConfig` via `config/runtime-config.ts`.
- `nodecg/runtime-provisioner.ts`: install/refresh the managed runtime in the writable user data folder and report whether it changed. 2. **Runtime Provisioning:** The app checks the user's AppData directory. If the packaged NodeCG runtime is missing or outdated, it extracts a fresh copy (`nodecg/runtime-provisioner.ts`).
- `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation. 3. **Window Creation:** The initial windows (like the loading screen) are instantiated via `windows/window-factory.ts`.
- `updates/update-manager.ts`: optional Gitea release checks, installer download, and user-controlled install. 4. **NodeCG Boot:** `nodecg/process-manager.ts` spawns the NodeCG process in the background.
- `updates/update-utils.ts`: release version comparison and installer asset selection. 5. **Readiness Check:** The app continuously polls NodeCG until the HTTP server responds. Once ready, it transitions the UI from the loading screen to the main dashboard.
- `windows/window-factory.ts`: window creation and navigation policy. 6. **Update Check:** If updates are enabled, the app checks the configured Gitea endpoint in the background to see if a newer version is available.
- `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes. 7. **Graceful Shutdown:** When the user closes the app, it triggers a unified teardown sequence to cleanly kill the NodeCG child process, preventing zombie processes from lingering in the background.
- `errors/error-presenter.ts`: fatal error presentation.
- `errors/logger.ts`: structured logging (`info/warn/error/debug`).
## Principles ## Core Modules
- Mechanical refactors first. Here is where the heavy lifting happens:
- Incremental hardening with conservative fallback.
- Automated validation via `typecheck`, `build`, `test`, `doctor`, `lint`. - **`config/runtime-config.ts`**: Handles environment variables and defaults.
- **`nodecg/runtime-provisioner.ts`**: Manages copying the NodeCG runtime out of the read-only Electron package into the writable user data folder.
- **`nodecg/process-manager.ts`**: Handles starting, polling, and killing the NodeCG server. It also validates ports and permissions before launching.
- **`updates/update-manager.ts`**: Coordinates the Gitea update flow (checking versions, downloading installers, prompting the user).
- **`windows/window-factory.ts`**: Centralizes window configuration and security defaults.
- **`windows/navigation-security.ts`**: Intercepts navigation events to block unauthorized domains and safely hand off external links (like docs or emails) to the user's default browser.
- **`errors/error-presenter.ts` & `errors/logger.ts`**: Manages structured logging (`electron-log`) and displaying the fallback error screen if boot fails.
-211
View File
@@ -1,211 +0,0 @@
# 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
@@ -1,203 +0,0 @@
# 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
@@ -1,97 +0,0 @@
# 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
@@ -1,310 +0,0 @@
# 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
@@ -1,92 +0,0 @@
# 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
@@ -1,90 +0,0 @@
# 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
@@ -1,79 +0,0 @@
# 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
@@ -1,107 +0,0 @@
# 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
@@ -1,121 +0,0 @@
# 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
@@ -1,190 +0,0 @@
# 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
@@ -1,420 +0,0 @@
# 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.
+34 -36
View File
@@ -1,45 +1,43 @@
# Troubleshooting # Troubleshooting Guide
## `The packaged NodeCG runtime is incomplete` Here are some common issues you might run into while developing or using the Scoreko desktop app, along with quick fixes.
- Run `npm run prepare:runtime` from `scoreko-electron-dev`. ## The app says the NodeCG runtime is incomplete
- If the parent bundle is not installed yet, run `pnpm install` from the repository root first. This usually means you haven't bundled the runtime yet.
- Run `npm run prepare:runtime` in the `scoreko-electron-dev` folder.
- If you haven't even installed the parent bundle, go up to the repository root and run `pnpm install` first.
## `NodeCG is present but internal dependencies are missing` ## NodeCG is present but internal dependencies are missing
This happens if dependencies changed or the initial copy was interrupted.
- Re-run `npm run prepare:runtime` to get a fresh copy.
- If you're seeing SQLite errors when the app launches, you probably need to run `npm run rebuild:native` to compile it for Electron's V8 engine.
- Recreate the runtime with `npm run prepare:runtime`. ## "No read/write permissions on NodeCG"
- If native SQLite errors appear during launch, run `npm run rebuild:native` before packaging. In production, Scoreko runs NodeCG out of your AppData folder to ensure it has write access. During local development, it runs directly from the repo.
If you see this permission error locally, another process probably has a file locked. Close any zombie Scoreko or Node processes and try `npm run start` again.
## `No read/write permissions on NodeCG` ## Port 9090 is already in use
You have another instance of NodeCG (or another web server) running on port 9090.
- Find and kill the process using the port, or change `NODECG_PORT` in your `.env` file to something else (like 9091).
- You can use `npm run doctor` to quickly test port availability.
- Installed builds run NodeCG from the user's app data folder, so this usually means the local development copy is locked. ## Timeout while waiting for NodeCG
- Close any running Scoreko/NodeCG process and run `npm run start` again. The app waits for the NodeCG HTTP server to respond. If it times out:
- Check your terminal output. NodeCG might be crashing or hanging on startup due to a bundle error.
- If your machine is just slow, you can increase `NODECG_STARTUP_TIMEOUT_MS` in the `.env` file.
## `Port <PORT> is already in use` ## The app crashes immediately on a fresh install
Scoreko copies the runtime to `%AppData%\scoreko\nodecg` and relaunches itself on the very first run.
If it gets stuck in a loop or fails immediately:
- Check if your antivirus or Windows Search Indexer is aggressively locking the files in AppData as they are being copied.
- Try running `npm run rebuild:native` and then repackaging the app with `npm run dist:win`.
- Free the port or set `NODECG_PORT` in `.env`. ## macOS builds are failing complaining about an icon
- Use `npm run doctor` to validate availability before startup. The `electron-builder` config explicitly looks for a Mac icon at `static/icons/icon.icns`. If you don't have one, generate it and place it there before running the macOS build.
## `Timeout while waiting for NodeCG` ## Auto-updates aren't triggering
If you published a new release on Gitea but the app ignores it:
- Check the Electron/NodeCG output in the terminal. - Double check that `static/updates.json` has `"enabled": true` before you build the installer.
- Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow. - Ensure your `apiUrl` points exactly to the Gitea API: `http://gitea.../api/v1/repos/<owner>/<repo>/releases/latest`.
- Recreate the runtime with `npm run prepare:runtime` if the bundle changed. - The git tag you created (e.g., `v0.2.0`) must be semantically higher than the version currently in your `package.json`.
- Make sure the installer `.exe` you uploaded to Gitea actually matches the regex in `assetPattern` (default is `Scoreko-setup-.*\.exe$`).
## First launch after install fails
- Scoreko relaunches itself automatically after a fresh runtime install.
- If it still fails, check whether antivirus or file indexing is locking `%AppData%\scoreko\nodecg`.
- Rebuild the installer with `npm run dist:win` after running `npm run rebuild:native`.
## macOS build fails because of icon
- The configuration expects `static/icons/icon.icns`.
- Create that file before running macOS packaging.
## Updates do not appear
- Check that `static/updates.json` has `"enabled": true` before building the installer.
- The `apiUrl` must point to Gitea's latest release API: `/api/v1/repos/<owner>/<repo>/releases/latest`.
- The release tag must be newer than the installed `package.json` version, for example `v0.2.0`.
- The release must include an installer asset matching `assetPattern`, by default `Scoreko-setup-.*\.exe$`.
+12 -65
View File
@@ -8,8 +8,10 @@
"name": "scoreko-electron", "name": "scoreko-electron",
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": {
"electron-log": "^5.4.4"
},
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^3.7.1",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@typescript-eslint/eslint-plugin": "^8.22.0", "@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0", "@typescript-eslint/parser": "^8.22.0",
@@ -182,31 +184,6 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/@electron/node-gyp": {
"version": "10.2.0-electron.1",
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"glob": "^8.1.0",
"graceful-fs": "^4.2.6",
"make-fetch-happen": "^10.2.1",
"nopt": "^6.0.0",
"proc-log": "^2.0.1",
"semver": "^7.3.5",
"tar": "^6.2.1",
"which": "^2.0.2"
},
"bin": {
"node-gyp": "bin/node-gyp.js"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/@electron/notarize": { "node_modules/@electron/notarize": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
@@ -273,35 +250,6 @@
"url": "https://github.com/sponsors/gjtorikian/" "url": "https://github.com/sponsors/gjtorikian/"
} }
}, },
"node_modules/@electron/rebuild": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.2.tgz",
"integrity": "sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"@malept/cross-spawn-promise": "^2.0.0",
"chalk": "^4.0.0",
"debug": "^4.1.1",
"detect-libc": "^2.0.1",
"fs-extra": "^10.0.0",
"got": "^11.7.0",
"node-abi": "^3.45.0",
"node-api-version": "^0.2.0",
"ora": "^5.1.0",
"read-binary-file-arch": "^1.0.6",
"semver": "^7.3.5",
"tar": "^6.0.5",
"yargs": "^17.0.1"
},
"bin": {
"electron-rebuild": "lib/cli.js"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/@electron/universal": { "node_modules/@electron/universal": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz",
@@ -2971,6 +2919,15 @@
"fs-extra": "^10.1.0" "fs-extra": "^10.1.0"
} }
}, },
"node_modules/electron-log": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.4.tgz",
"integrity": "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/electron-publish": { "node_modules/electron-publish": {
"version": "25.1.7", "version": "25.1.7",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz",
@@ -5383,16 +5340,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/proc-log": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz",
"integrity": "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+18 -8
View File
@@ -34,6 +34,7 @@
"dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac" "dist:mac": "npm run build && npm run rebuild:native && electron-builder --mac"
}, },
"build": { "build": {
"beforePack": "./scripts/before-pack.mjs",
"appId": "com.scoreko.desktop", "appId": "com.scoreko.desktop",
"productName": "Scoreko", "productName": "Scoreko",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
@@ -53,6 +54,10 @@
{ {
"from": "static", "from": "static",
"to": "static" "to": "static"
},
{
"from": ".env",
"to": ".env"
} }
], ],
"mac": { "mac": {
@@ -74,9 +79,10 @@
], ],
"icon": "static/icons/icon.ico", "icon": "static/icons/icon.ico",
"executableName": "scoreko", "executableName": "scoreko",
"signAndEditExecutable": false "signAndEditExecutable": true
}, },
"nsis": { "nsis": {
"include": "static/installer.nsh",
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"artifactName": "${productName}-setup-${version}.${ext}", "artifactName": "${productName}-setup-${version}.${ext}",
@@ -84,7 +90,9 @@
"uninstallerIcon": "static/icons/icon.ico", "uninstallerIcon": "static/icons/icon.ico",
"installerHeaderIcon": "static/icons/icon.ico", "installerHeaderIcon": "static/icons/icon.ico",
"shortcutName": "Scoreko", "shortcutName": "Scoreko",
"useZip": false "useZip": false,
"deleteAppDataOnUninstall": true,
"createDesktopShortcut": "always"
}, },
"compression": "normal" "compression": "normal"
}, },
@@ -92,17 +100,19 @@
"node": ">=22" "node": ">=22"
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^3.7.1",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"electron": "39.5.1", "electron": "39.5.1",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"eslint": "^9.19.0",
"prettier": "^3.4.2",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"wait-on": "^8.0.1", "wait-on": "^8.0.1"
"eslint": "^9.19.0", },
"@typescript-eslint/parser": "^8.22.0", "dependencies": {
"@typescript-eslint/eslint-plugin": "^8.22.0", "electron-log": "^5.4.4"
"prettier": "^3.4.2"
} }
} }
+13
View File
@@ -0,0 +1,13 @@
// scripts/beforePack.mjs
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default async function () {
const src = path.resolve(__dirname, '../static/installSection.nsh');
const dest = path.resolve(__dirname, '../node_modules/app-builder-lib/templates/nsis/installSection.nsh');
fs.copyFileSync(src, dest);
console.log('✅ installSection.nsh parcheado');
}
+33 -8
View File
@@ -7,12 +7,31 @@ import { bundleName, nodecgRuntimeRoot } from "./build-config.mjs";
const checks = []; const checks = [];
function loadEnv() {
if (!fs.existsSync(".env")) {
console.error("FAIL Configuración: Archivo .env obligatorio no encontrado.");
console.error("Por favor, crea un archivo .env basado en .env.example en la raíz del proyecto.");
process.exit(1);
}
try {
process.loadEnvFile(".env");
console.log("OK Configuración: Archivo .env cargado correctamente.\n");
} catch (error) {
console.error(`FAIL Configuración: Error al leer el archivo .env: ${error.message}`);
process.exit(1);
}
}
function addCheck(ok, title, details) { function addCheck(ok, title, details) {
checks.push({ ok, title, details }); checks.push({ ok, title, details });
} }
function parsePort(name, fallback) { function parsePort(name) {
const raw = process.env[name] ?? fallback; const raw = process.env[name];
if (!raw) {
addCheck(false, `${name} missing`, `The required environment variable ${name} is not defined in the .env file.`);
return null;
}
const parsed = Number.parseInt(raw, 10); const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
addCheck(false, `${name} invalid`, `It must be an integer between 1 and 65535. Received value: '${raw}'.`); addCheck(false, `${name} invalid`, `It must be an integer between 1 and 65535. Received value: '${raw}'.`);
@@ -23,8 +42,12 @@ function parsePort(name, fallback) {
return parsed; return parsed;
} }
function parseIntInRange(name, fallback, min, max) { function parseIntInRange(name, min, max) {
const raw = process.env[name] ?? String(fallback); const raw = process.env[name];
if (!raw) {
addCheck(false, `${name} missing`, `The required environment variable ${name} is not defined in the .env file.`);
return;
}
const parsed = Number.parseInt(raw, 10); const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) { if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
addCheck(false, `${name} invalid`, `It must be an integer between ${min} and ${max}. Received value: '${raw}'.`); addCheck(false, `${name} invalid`, `It must be an integer between ${min} and ${max}. Received value: '${raw}'.`);
@@ -73,10 +96,12 @@ function checkPortAvailability(port) {
} }
async function main() { async function main() {
const port = parsePort("NODECG_PORT", "9090"); loadEnv();
parseIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000);
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000); const port = parsePort("NODECG_PORT");
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000); parseIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000);
parseIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000);
parseIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000);
checkNodecgInstall(); checkNodecgInstall();
if (port) { if (port) {
+33 -18
View File
@@ -1,11 +1,11 @@
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { NodecgProcessManager } from "../nodecg/process-manager"; import { NodecgProcessManager } from "../nodecg/process-manager";
import { PreparedNodecgRuntime } from "../nodecg/runtime-provisioner"; import { PreparedNodecgRuntime } from "../nodecg/runtime-setup";
import { getRemainingDelayMs } from "../utils/timing"; import { getRemainingDelayMs } from "../utils/timing";
import { ApplicationPaths } from "./paths"; import { ApplicationPaths } from "./paths";
import { createShutdownService, ShutdownService } from "./shutdown-service"; import { createShutdownService, ShutdownService } from "./shutdown-service";
export type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed"; type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed";
export type ApplicationWindow = { export type ApplicationWindow = {
close: () => void; close: () => void;
@@ -13,6 +13,7 @@ export type ApplicationWindow = {
isDestroyed: () => boolean; isDestroyed: () => boolean;
isMinimized: () => boolean; isMinimized: () => boolean;
loadURL: (url: string) => Promise<unknown>; loadURL: (url: string) => Promise<unknown>;
loadFile: (filePath: string) => Promise<unknown>;
restore: () => void; restore: () => void;
show: () => void; show: () => void;
}; };
@@ -36,7 +37,6 @@ export type ApplicationControllerConfig = {
bundleName: string; bundleName: string;
log: (...args: unknown[]) => void; log: (...args: unknown[]) => void;
}) => PreparedNodecgRuntime; }) => PreparedNodecgRuntime;
relaunch: () => void;
scheduleUpdateCheck: (config: { scheduleUpdateCheck: (config: {
getParentWindow: () => ApplicationWindow | null; getParentWindow: () => ApplicationWindow | null;
beforeInstall: () => Promise<void>; beforeInstall: () => Promise<void>;
@@ -53,6 +53,7 @@ export type ApplicationController = {
focusExistingWindow: () => void; focusExistingWindow: () => void;
getState: () => ApplicationState; getState: () => ApplicationState;
launch: () => Promise<void>; launch: () => Promise<void>;
showErrorScreen: (error: unknown) => Promise<void>;
stopNodecgGracefully: () => Promise<void>; stopNodecgGracefully: () => Promise<void>;
}; };
@@ -60,7 +61,7 @@ export function createApplicationController({
appConfig, appConfig,
appVersion, appVersion,
deps, deps,
isPackaged, isPackaged: _isPackaged,
isWindows, isWindows,
paths, paths,
}: ApplicationControllerConfig): ApplicationController { }: ApplicationControllerConfig): ApplicationController {
@@ -120,6 +121,13 @@ export function createApplicationController({
deps.setAppUserModelId(appConfig.userModelId); deps.setAppUserModelId(appConfig.userModelId);
} }
mainWindow = deps.createMainWindow();
loadingWindow = deps.createLoadingWindow();
await loadingWindow.loadFile(paths.staticLoadingHtmlPath);
loadingWindow.show();
await sleep(50);
state = "preparing"; state = "preparing";
const preparedRuntime = deps.prepareRuntime({ const preparedRuntime = deps.prepareRuntime({
sourceRuntimePath: paths.sourceNodecgRuntimePath, sourceRuntimePath: paths.sourceNodecgRuntimePath,
@@ -129,19 +137,9 @@ export function createApplicationController({
log: deps.log, 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); nodecgManager = deps.createNodecgProcessManager(preparedRuntime.runtimePath);
mainWindow = deps.createMainWindow();
loadingWindow = deps.createLoadingWindow();
state = "starting"; state = "starting";
await startNodecg(); await startNodecg();
@@ -150,9 +148,6 @@ export function createApplicationController({
return; return;
} }
await loadingWindow.loadURL(paths.loadingDashboardUrl);
loadingWindow.show();
const loadingShownAt = now(); const loadingShownAt = now();
if (!mainWindow) { if (!mainWindow) {
@@ -182,7 +177,7 @@ export function createApplicationController({
} catch (error) { } catch (error) {
state = "failed"; state = "failed";
launchPromise = null; launchPromise = null;
closeLoadingWindow(); await showErrorScreen(error);
throw error; throw error;
} }
}; };
@@ -212,11 +207,31 @@ export function createApplicationController({
state = "stopped"; state = "stopped";
}; };
const showErrorScreen = async (error: unknown): Promise<void> => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
const encodedMsg = encodeURIComponent(`msg=${encodeURIComponent(message)}`);
const errorUrl = `file://${paths.staticErrorHtmlPath}#${encodedMsg}`;
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow;
if (!targetWindow || targetWindow.isDestroyed()) {
return;
}
try {
await targetWindow.loadURL(errorUrl);
targetWindow.show();
} catch {
// If even the error screen fails to load, nothing more can be done.
}
};
return { return {
activate, activate,
focusExistingWindow, focusExistingWindow,
getState: () => state, getState: () => state,
launch, launch,
showErrorScreen,
stopNodecgGracefully, stopNodecgGracefully,
}; };
} }
+25 -10
View File
@@ -1,24 +1,41 @@
import { app, BrowserWindow } from "electron"; import { app, BrowserWindow } from "electron";
import path from "node:path"; import path from "node:path";
import { getRuntimeConfig } from "../config/runtime-config"; import { getRuntimeConfig, loadEnvFile, AppRuntimeConfig } from "../config/runtime-config";
import { showFatalError, log } from "../errors/error-presenter"; import { showFatalError, log } from "../errors/error-handler";
import { logger } from "../logging/logger";
import { createNodecgProcessManager } from "../nodecg/process-manager"; import { createNodecgProcessManager } from "../nodecg/process-manager";
import { prepareUserNodecgRuntime } from "../nodecg/runtime-provisioner"; import { prepareUserNodecgRuntime } from "../nodecg/runtime-setup";
import { scheduleUpdateCheck } from "../updates/update-service"; import { scheduleUpdateCheck } from "../updates/update-service";
import { createLoadingWindow, createMainWindow } from "../windows/window-service"; import { createLoadingWindow, createMainWindow } from "../windows/window-service";
import { createApplicationController } from "./application-controller"; import { createApplicationController } from "./application-controller";
import { getApplicationPaths } from "./paths"; import { getApplicationPaths, getRootPath } from "./paths";
export function bootstrap(): void { export function bootstrap(): void {
const appConfig = getRuntimeConfig();
const isDev = !app.isPackaged; const isDev = !app.isPackaged;
const compiledMainDir = path.resolve(__dirname, "..");
const resourcesPath = process.resourcesPath;
const rootPath = getRootPath(isDev, compiledMainDir, resourcesPath);
const envFilePath = path.join(rootPath, ".env");
let appConfig: AppRuntimeConfig;
try {
loadEnvFile(envFilePath);
appConfig = getRuntimeConfig();
} catch (error: unknown) {
app.on("ready", () => {
showFatalError("No se pudo cargar la configuración de la aplicación.", error);
app.exit(1);
});
return;
}
const paths = getApplicationPaths({ const paths = getApplicationPaths({
appConfig, appConfig,
appDataPath: app.getPath("appData"), appDataPath: app.getPath("appData"),
compiledMainDir: path.resolve(__dirname, ".."), compiledMainDir,
isDev, isDev,
resourcesPath: process.resourcesPath, resourcesPath,
}); });
app.setName(appConfig.title); app.setName(appConfig.title);
@@ -61,7 +78,6 @@ export function bootstrap(): void {
getAllWindows: () => BrowserWindow.getAllWindows(), getAllWindows: () => BrowserWindow.getAllWindows(),
log, log,
prepareRuntime: prepareUserNodecgRuntime, prepareRuntime: prepareUserNodecgRuntime,
relaunch: () => app.relaunch(),
scheduleUpdateCheck: ({ getParentWindow, beforeInstall }) => { scheduleUpdateCheck: ({ getParentWindow, beforeInstall }) => {
scheduleUpdateCheck({ scheduleUpdateCheck({
appConfig, appConfig,
@@ -82,8 +98,7 @@ export function bootstrap(): void {
} }
controller.launch().catch((error: unknown) => { controller.launch().catch((error: unknown) => {
showFatalError("No se pudo iniciar Scoreko.", error); logger.error("launch-failed", { error: error instanceof Error ? error.stack : String(error) });
app.exit(1);
}); });
}); });
+5 -7
View File
@@ -8,7 +8,8 @@ export type ApplicationPaths = {
userDataPath: string; userDataPath: string;
nodecgBaseUrl: string; nodecgBaseUrl: string;
mainDashboardUrl: string; mainDashboardUrl: string;
loadingDashboardUrl: string; staticLoadingHtmlPath: string;
staticErrorHtmlPath: string;
}; };
export function getRootPath(isDev: boolean, compiledMainDir: string, resourcesPath: string): string { export function getRootPath(isDev: boolean, compiledMainDir: string, resourcesPath: string): string {
@@ -27,10 +28,6 @@ export function getSourceNodecgRuntimePath(rootPath: string): string {
return path.resolve(rootPath, "lib", "nodecg"); 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 { export function getUpdateDownloadDirectory(tempDirectory: string): string {
return path.join(tempDirectory, "scoreko-updates"); return path.join(tempDirectory, "scoreko-updates");
} }
@@ -66,7 +63,7 @@ export function getApplicationPaths({
}: { }: {
appConfig: Pick< appConfig: Pick<
AppRuntimeConfig, AppRuntimeConfig,
"bundleName" | "loadingDashboardRoute" | "mainDashboardRoute" | "nodecgPort" | "userDataDirectoryName" "bundleName" | "mainDashboardRoute" | "nodecgPort" | "userDataDirectoryName"
>; >;
appDataPath: string; appDataPath: string;
compiledMainDir: string; compiledMainDir: string;
@@ -81,6 +78,7 @@ export function getApplicationPaths({
userDataPath: getUserDataPath(appDataPath, appConfig.userDataDirectoryName), userDataPath: getUserDataPath(appDataPath, appConfig.userDataDirectoryName),
nodecgBaseUrl: getNodecgBaseUrl(appConfig.nodecgPort), nodecgBaseUrl: getNodecgBaseUrl(appConfig.nodecgPort),
mainDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.mainDashboardRoute), mainDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.mainDashboardRoute),
loadingDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.loadingDashboardRoute), staticLoadingHtmlPath: path.join(rootPath, "static", "loading.html"),
staticErrorHtmlPath: path.join(rootPath, "static", "error.html"),
}; };
} }
+1 -1
View File
@@ -1,4 +1,4 @@
export type AppShutdownState = "running" | "stopping" | "stopped"; type AppShutdownState = "running" | "stopping" | "stopped";
export type ShutdownService = { export type ShutdownService = {
getState: () => AppShutdownState; getState: () => AppShutdownState;
+86 -17
View File
@@ -1,3 +1,6 @@
import fs from "node:fs";
import path from "node:path";
export type AppRuntimeConfig = { export type AppRuntimeConfig = {
title: string; title: string;
userModelId: string; userModelId: string;
@@ -6,7 +9,6 @@ export type AppRuntimeConfig = {
nodecgPort: string; nodecgPort: string;
bundleName: string; bundleName: string;
mainDashboardRoute: string; mainDashboardRoute: string;
loadingDashboardRoute: string;
loadDelayMs: number; loadDelayMs: number;
startupTimeoutMs: number; startupTimeoutMs: number;
nodecgKillTimeoutMs: number; nodecgKillTimeoutMs: number;
@@ -14,36 +16,67 @@ export type AppRuntimeConfig = {
updateApiUrl?: string; updateApiUrl?: string;
updateReleasePageUrl?: string; updateReleasePageUrl?: string;
updateAssetPattern?: string; updateAssetPattern?: string;
updateConfigPathOverride?: string;
updateCheckDelayMs: number; updateCheckDelayMs: number;
}; };
const MIN_TCP_PORT = 1; const MIN_TCP_PORT = 1;
const MAX_TCP_PORT = 65535; const MAX_TCP_PORT = 65535;
export function loadEnvFile(envFilePath: string): void {
const resolvedPath = resolveEnvFilePath(envFilePath);
try {
process.loadEnvFile(resolvedPath);
} catch (error) {
throw new Error(
`Error al leer el archivo de configuración .env: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
function resolveEnvFilePath(envFilePath: string): string {
if (fs.existsSync(envFilePath)) {
return envFilePath;
}
const dir = path.dirname(envFilePath);
const fallbackPath = path.join(dir, ".env.example");
if (fs.existsSync(fallbackPath)) {
return fallbackPath;
}
throw new Error(
`Archivo de configuración obligatorio no encontrado: ${envFilePath}\n\nPor favor, crea un archivo .env basado en .env.example en la raíz de la aplicación.`,
);
}
export function getRuntimeConfig(): AppRuntimeConfig { export function getRuntimeConfig(): AppRuntimeConfig {
// Centralized defaults keep local development and packaged builds consistent.
return { return {
title: getEnv("SCOREKO_APP_TITLE", "Scoreko"), title: getRequiredEnv("SCOREKO_APP_TITLE"),
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"), userModelId: getRequiredEnv("SCOREKO_APP_USER_MODEL_ID"),
userDataDirectoryName: getEnv("SCOREKO_APP_USER_DATA_DIRECTORY", "scoreko"), userDataDirectoryName: getRequiredEnv("SCOREKO_APP_USER_DATA_DIRECTORY"),
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"), iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
nodecgPort: parseEnvPort("NODECG_PORT", "9090"), nodecgPort: parseRequiredEnvPort("NODECG_PORT"),
bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"), bundleName: getRequiredEnv("NODECG_BUNDLE_NAME"),
mainDashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"), mainDashboardRoute: getRequiredEnv("SCOREKO_DASHBOARD_ROUTE"),
loadingDashboardRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"), loadDelayMs: parseRequiredEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000),
loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000), startupTimeoutMs: parseRequiredEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000),
startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000), nodecgKillTimeoutMs: parseRequiredEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000),
nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000), updatesEnabled: parseRequiredEnvBool("SCOREKO_UPDATES_ENABLED"),
updatesEnabled: parseEnvBool("SCOREKO_UPDATES_ENABLED", true),
updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"), updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"),
updateReleasePageUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_RELEASE_PAGE_URL"), updateReleasePageUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_RELEASE_PAGE_URL"),
updateAssetPattern: getOptionalEnv("SCOREKO_UPDATE_ASSET_PATTERN"), updateAssetPattern: getOptionalEnv("SCOREKO_UPDATE_ASSET_PATTERN"),
updateConfigPathOverride: getOptionalEnv("SCOREKO_UPDATE_CONFIG_PATH"), updateCheckDelayMs: parseRequiredEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 0, 600000),
updateCheckDelayMs: parseEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 5000, 0, 600000),
}; };
} }
export function getRequiredEnv(name: string): string {
const value = process.env[name]?.trim();
if (!value || value.length === 0) {
throw new Error(`La variable de entorno requerida '${name}' no está definida en el archivo .env.`);
}
return value;
}
export function getOptionalEnv(name: string): string | undefined { export function getOptionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim(); const value = process.env[name]?.trim();
return value && value.length > 0 ? value : undefined; return value && value.length > 0 ? value : undefined;
@@ -53,8 +86,18 @@ export function getEnv(name: string, fallback: string): string {
return getOptionalEnv(name) ?? fallback; return getOptionalEnv(name) ?? fallback;
} }
export function parseRequiredEnvIntInRange(name: string, min: number, max: number): number {
const rawValue = getRequiredEnv(name);
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
throw new Error(
`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`,
);
}
return parsedValue;
}
export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number { export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number {
// We throw here instead of silently coercing to avoid hidden misconfiguration in production.
const rawValue = process.env[name]; const rawValue = process.env[name];
if (!rawValue) { if (!rawValue) {
return fallback; return fallback;
@@ -70,6 +113,19 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
return parsedValue; return parsedValue;
} }
export function parseRequiredEnvBool(name: string): boolean {
const rawValue = getRequiredEnv(name).toLowerCase();
if (["1", "true", "yes", "on"].includes(rawValue)) {
return true;
}
if (["0", "false", "no", "off"].includes(rawValue)) {
return false;
}
throw new Error(`The ${name} variable must be a boolean. Received value: '${rawValue}'.`);
}
export function parseEnvBool(name: string, fallback: boolean): boolean { export function parseEnvBool(name: string, fallback: boolean): boolean {
const rawValue = process.env[name]?.trim().toLowerCase(); const rawValue = process.env[name]?.trim().toLowerCase();
if (!rawValue) { if (!rawValue) {
@@ -105,6 +161,19 @@ export function parseOptionalHttpUrl(name: string): string | undefined {
} }
} }
export function parseRequiredEnvPort(name: string): string {
const rawValue = getRequiredEnv(name);
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue) || parsedValue < MIN_TCP_PORT || parsedValue > MAX_TCP_PORT) {
throw new Error(
`The ${name} variable must be a valid TCP port (${MIN_TCP_PORT}-${MAX_TCP_PORT}). Received value: '${rawValue}'.`,
);
}
return String(parsedValue);
}
export function parseEnvPort(name: string, fallback: string): string { export function parseEnvPort(name: string, fallback: string): string {
const rawValue = getEnv(name, fallback); const rawValue = getEnv(name, fallback);
const parsedValue = Number.parseInt(rawValue, 10); const parsedValue = Number.parseInt(rawValue, 10);
@@ -6,7 +6,7 @@ export function log(...args: unknown[]): void {
logger.info("runtime", { args }); logger.info("runtime", { args });
} }
export function formatErrorMessage(error: unknown): string { function formatErrorMessage(error: unknown): string {
if (error instanceof Error) { if (error instanceof Error) {
const stack = error.stack?.trim(); const stack = error.stack?.trim();
return stack && stack.length > 0 ? stack : error.message; return stack && stack.length > 0 ? stack : error.message;
+8 -11
View File
@@ -1,7 +1,14 @@
import electronLog from "electron-log";
export type LogLevel = "debug" | "info" | "warn" | "error"; export type LogLevel = "debug" | "info" | "warn" | "error";
type LogContext = Record<string, unknown>; type LogContext = Record<string, unknown>;
// Configure electron-log: write to file and (in dev) also to console.
electronLog.initialize();
electronLog.transports.file.level = "debug";
electronLog.transports.console.level = process.env["NODE_ENV"] === "development" ? "debug" : false;
function write(level: LogLevel, message: string, context?: LogContext): void { function write(level: LogLevel, message: string, context?: LogContext): void {
const payload = { const payload = {
ts: new Date().toISOString(), ts: new Date().toISOString(),
@@ -13,17 +20,7 @@ function write(level: LogLevel, message: string, context?: LogContext): void {
const line = JSON.stringify(payload); const line = JSON.stringify(payload);
if (level === "error") { electronLog[level](line);
console.error(line);
return;
}
if (level === "warn") {
console.warn(line);
return;
}
console.log(line);
} }
export const logger = { export const logger = {
+2 -2
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { NODE_RUNTIME_NAME } from "../constants"; import { NODE_RUNTIME_NAME } from "../constants";
import { killProcessTree } from "./platform-process-killer"; import { killProcessTree } from "./process-killer";
type NodecgProcessManagerConfig = { type NodecgProcessManagerConfig = {
isDev: boolean; isDev: boolean;
@@ -54,7 +54,7 @@ export type NodecgProcessManager = {
getState: () => NodecgProcessState; getState: () => NodecgProcessState;
}; };
export type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed"; type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
export function createNodecgProcessManager({ export function createNodecgProcessManager({
isDev, isDev,
@@ -23,11 +23,13 @@ type RuntimeProvisionerDeps = {
recursive: true; recursive: true;
force: true; force: true;
dereference: true; dereference: true;
filter: (sourcePath: string) => boolean; filter?: (sourcePath: string) => boolean;
}, },
) => unknown; ) => unknown;
readFileSync: (filePath: string) => string | Buffer; readFileSync: (filePath: string) => string | Buffer;
writeFileSync: (filePath: string, content: string) => unknown; writeFileSync: (filePath: string, content: string) => unknown;
statSync: (filePath: string) => { isDirectory: () => boolean };
symlinkSync: (target: string, path: string, type: "junction") => unknown;
}; };
export type PreparedNodecgRuntime = { export type PreparedNodecgRuntime = {
@@ -84,6 +86,8 @@ function resolveDeps(deps?: Partial<RuntimeProvisionerDeps>): RuntimeProvisioner
cpSync: deps?.cpSync ?? fs.cpSync, cpSync: deps?.cpSync ?? fs.cpSync,
readFileSync: deps?.readFileSync ?? fs.readFileSync, readFileSync: deps?.readFileSync ?? fs.readFileSync,
writeFileSync: deps?.writeFileSync ?? fs.writeFileSync, writeFileSync: deps?.writeFileSync ?? fs.writeFileSync,
statSync: deps?.statSync ?? fs.statSync,
symlinkSync: deps?.symlinkSync ?? fs.symlinkSync,
}; };
} }
@@ -150,16 +154,20 @@ function installManagedRuntime(
deps.rmSync(path.join(targetRuntimePath, entry), { recursive: true, force: true }); deps.rmSync(path.join(targetRuntimePath, entry), { recursive: true, force: true });
} }
deps.cpSync(sourceRuntimePath, targetRuntimePath, { for (const entry of MANAGED_RUNTIME_ENTRIES) {
recursive: true, const sourcePath = path.join(sourceRuntimePath, entry);
force: true, const targetPath = path.join(targetRuntimePath, entry);
dereference: true,
filter: (sourcePath) => { if (!deps.existsSync(sourcePath)) {
const relativePath = path.relative(sourceRuntimePath, sourcePath); continue;
const firstSegment = relativePath.split(path.sep)[0]; }
return !WRITABLE_NODECG_DIRS.includes(firstSegment as (typeof WRITABLE_NODECG_DIRS)[number]);
}, if (deps.statSync(sourcePath).isDirectory()) {
}); deps.symlinkSync(sourcePath, targetPath, "junction");
} else {
deps.cpSync(sourcePath, targetPath, { recursive: true, force: true, dereference: true });
}
}
const sourceRuntime = readJson(path.join(sourceRuntimePath, ".scoreko-runtime.json"), deps); const sourceRuntime = readJson(path.join(sourceRuntimePath, ".scoreko-runtime.json"), deps);
deps.writeFileSync( deps.writeFileSync(
+7 -49
View File
@@ -1,9 +1,6 @@
import fs from "node:fs";
import { getDefaultUpdateConfigPath } from "../app/paths";
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { isRecord, readNonEmptyString } from "../utils/unknown-values"; import { readNonEmptyString } from "../utils/unknown-values";
import { UpdateFileConfig, validateHttpUrl } from "./update-schema"; import { validateHttpUrl } from "./update-schema";
const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$"; const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$";
@@ -20,11 +17,7 @@ type UpdateConfigOptions = {
type UpdateRuntimeConfig = Pick< type UpdateRuntimeConfig = Pick<
AppRuntimeConfig, AppRuntimeConfig,
| "updateApiUrl" "updateApiUrl" | "updateAssetPattern" | "updateReleasePageUrl" | "updatesEnabled"
| "updateAssetPattern"
| "updateConfigPathOverride"
| "updateReleasePageUrl"
| "updatesEnabled"
>; >;
export function loadUpdateSettings( export function loadUpdateSettings(
@@ -33,49 +26,14 @@ export function loadUpdateSettings(
log: (...args: unknown[]) => void, log: (...args: unknown[]) => void,
options: UpdateConfigOptions = { allowInsecureHttp: true }, options: UpdateConfigOptions = { allowInsecureHttp: true },
): UpdateSettings { ): UpdateSettings {
const fileConfig = readUpdateFileConfig(appConfig, rootPath, log); const apiUrl = readOptionalHttpUrl(appConfig.updateApiUrl, options);
const apiUrl = readOptionalHttpUrl(appConfig.updateApiUrl ?? fileConfig.apiUrl, options); const releasePageUrl = readOptionalHttpUrl(appConfig.updateReleasePageUrl, options);
const releasePageUrl = readOptionalHttpUrl(appConfig.updateReleasePageUrl ?? fileConfig.releasePageUrl, options);
return { return {
enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)) && Boolean(apiUrl), enabled: appConfig.updatesEnabled && Boolean(apiUrl),
...(apiUrl ? { apiUrl } : {}), ...(apiUrl ? { apiUrl } : {}),
...(releasePageUrl ? { releasePageUrl } : {}), ...(releasePageUrl ? { releasePageUrl } : {}),
assetPattern: assetPattern: appConfig.updateAssetPattern || DEFAULT_UPDATE_ASSET_PATTERN,
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,
}; };
} }
+16
View File
@@ -41,6 +41,22 @@ export async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: Br
return result.response === 0; return result.response === 0;
} }
export async function showDownloadFailedDialog(
update: ReleaseUpdate,
error: unknown,
parentWindow: BrowserWindow | null,
): Promise<void> {
const errorMessage = error instanceof Error ? error.message : String(error);
await showMessageBox(parentWindow, {
type: "error",
title: "Error de descarga",
message: `No se pudo descargar la actualización para Scoreko ${update.version}.`,
detail: `Detalles del error: ${errorMessage}\n\nPor favor, comprueba tu conexión a internet e inténtalo de nuevo.`,
buttons: ["Aceptar"],
defaultId: 0,
});
}
function showMessageBox( function showMessageBox(
parentWindow: BrowserWindow | null, parentWindow: BrowserWindow | null,
options: MessageBoxOptions, options: MessageBoxOptions,
+7
View File
@@ -23,6 +23,13 @@ export async function downloadInstaller(update: ReleaseUpdate, config: UpdateDow
const targetPath = getSafeChildPath(downloadDirectory, safeFileName); const targetPath = getSafeChildPath(downloadDirectory, safeFileName);
const stagingPath = getSafeChildPath(downloadDirectory, `${safeFileName}.${process.pid}.${Date.now()}.download`); const stagingPath = getSafeChildPath(downloadDirectory, `${safeFileName}.${process.pid}.${Date.now()}.download`);
if (fs.existsSync(targetPath)) {
const stats = fs.statSync(targetPath);
if (typeof update.installer.size === "number" && stats.size === update.installer.size) {
return targetPath;
}
}
fs.mkdirSync(downloadDirectory, { recursive: true }); fs.mkdirSync(downloadDirectory, { recursive: true });
fs.rmSync(stagingPath, { force: true }); fs.rmSync(stagingPath, { force: true });
+1 -8
View File
@@ -1,6 +1,6 @@
import { isRecord, readNonEmptyString } from "../utils/unknown-values"; import { isRecord, readNonEmptyString } from "../utils/unknown-values";
export type GiteaReleaseAsset = { type GiteaReleaseAsset = {
name: string; name: string;
browserDownloadUrl: string; browserDownloadUrl: string;
size?: number; size?: number;
@@ -26,13 +26,6 @@ export type ReleaseUpdate = {
installer: InstallerAsset; installer: InstallerAsset;
}; };
export type UpdateFileConfig = {
enabled?: unknown;
apiUrl?: unknown;
releasePageUrl?: unknown;
assetPattern?: unknown;
};
type UrlPolicy = { type UrlPolicy = {
allowInsecureHttp: boolean; allowInsecureHttp: boolean;
}; };
+12 -5
View File
@@ -1,7 +1,7 @@
import { app, BrowserWindow, shell } from "electron"; import { app, BrowserWindow, shell } from "electron";
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { askToDownloadUpdate, askToInstallUpdate } from "./update-dialogs"; import { askToDownloadUpdate, askToInstallUpdate, showDownloadFailedDialog } from "./update-dialogs";
import { loadUpdateSettings, UpdateSettings } from "./update-config"; import { loadUpdateSettings, UpdateSettings } from "./update-config";
import { downloadInstaller } from "./update-download"; import { downloadInstaller } from "./update-download";
import { buildReleaseUpdate, GiteaRelease, parseGiteaRelease } from "./update-schema"; import { buildReleaseUpdate, GiteaRelease, parseGiteaRelease } from "./update-schema";
@@ -76,10 +76,17 @@ async function checkForUpdates({
return; return;
} }
const installerPath = await downloadInstaller(update, { let installerPath: string;
tempDirectory: app.getPath("temp"), try {
allowInsecureHttp: protocolPolicy.allowInsecureHttp, installerPath = await downloadInstaller(update, {
}); tempDirectory: app.getPath("temp"),
allowInsecureHttp: protocolPolicy.allowInsecureHttp,
});
} catch (error) {
log("Update installer download failed.", error);
await showDownloadFailedDialog(update, error, getParentWindow());
return;
}
const shouldInstall = await askToInstallUpdate(update, getParentWindow()); const shouldInstall = await askToInstallUpdate(update, getParentWindow());
if (!shouldInstall) { if (!shouldInstall) {
await shell.showItemInFolder(installerPath); await shell.showItemInFolder(installerPath);
+29 -5
View File
@@ -1,9 +1,10 @@
import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron"; import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron";
import electronLog from "electron-log";
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants"; import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
import { resolveAppIconPath } from "./icon-path"; import { resolveAppIconPath } from "./icon-path";
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-policy"; import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation";
type WindowServiceDependencies = { type WindowServiceDependencies = {
appConfig: AppRuntimeConfig; appConfig: AppRuntimeConfig;
@@ -21,7 +22,7 @@ export function createMainWindow({
const windowOptions = createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: false }); const windowOptions = createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: false });
const window = new BrowserWindow(windowOptions); const window = new BrowserWindow(windowOptions);
denyPermissionsByDefault(window); applySecurityPolicies(window, allowDevTools);
window.setMenuBarVisibility(false); window.setMenuBarVisibility(false);
window.webContents.setWindowOpenHandler(({ url }) => { window.webContents.setWindowOpenHandler(({ url }) => {
@@ -33,6 +34,12 @@ export function createMainWindow({
}); });
window.webContents.on("will-navigate", (event, url) => { window.webContents.on("will-navigate", (event, url) => {
if (url.startsWith("app://open-logs")) {
event.preventDefault();
void shell.showItemInFolder(electronLog.transports.file.getFile().path);
return;
}
if (shouldAllowInternalNavigation(url, mainDashboardUrl)) { if (shouldAllowInternalNavigation(url, mainDashboardUrl)) {
return; return;
} }
@@ -58,7 +65,7 @@ export function createLoadingWindow({
}: Omit<WindowServiceDependencies, "mainDashboardUrl">): BrowserWindow { }: Omit<WindowServiceDependencies, "mainDashboardUrl">): BrowserWindow {
const window = new BrowserWindow(createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: true })); const window = new BrowserWindow(createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: true }));
denyPermissionsByDefault(window); applySecurityPolicies(window, allowDevTools);
window.on("page-title-updated", (event) => { window.on("page-title-updated", (event) => {
event.preventDefault(); event.preventDefault();
@@ -67,7 +74,7 @@ export function createLoadingWindow({
return window; return window;
} }
export function createWindowOptions({ function createWindowOptions({
allowDevTools, allowDevTools,
appConfig, appConfig,
rootPath, rootPath,
@@ -116,8 +123,25 @@ export function createWindowOptions({
}; };
} }
function denyPermissionsByDefault(window: BrowserWindow): void { function applySecurityPolicies(window: BrowserWindow, allowDevTools: boolean): void {
window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => { window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false); callback(false);
}); });
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: http://localhost:* http://127.0.0.1:*; connect-src * ws: wss:; img-src * data: blob:; media-src * data: blob:; font-src * data:;"
]
}
});
});
if (!allowDevTools) {
window.webContents.on("devtools-opened", () => {
window.webContents.closeDevTools();
});
}
} }
-3
View File
@@ -5,7 +5,6 @@ import test from "node:test";
import { import {
getApplicationPaths, getApplicationPaths,
getDashboardUrl, getDashboardUrl,
getDefaultUpdateConfigPath,
getManagedNodecgRuntimePath, getManagedNodecgRuntimePath,
getNodecgBaseUrl, getNodecgBaseUrl,
getRootPath, getRootPath,
@@ -23,7 +22,6 @@ test("app path helpers build deterministic development paths and URLs", () => {
assert.equal(getSourceNodecgRuntimePath(rootPath), path.resolve(rootPath, "lib", "nodecg")); assert.equal(getSourceNodecgRuntimePath(rootPath), path.resolve(rootPath, "lib", "nodecg"));
assert.equal(getUserDataPath("/app-data", "scoreko"), path.join("/app-data", "scoreko")); 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(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(getUpdateDownloadDirectory("/tmp"), path.join("/tmp", "scoreko-updates"));
assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090"); assert.equal(getNodecgBaseUrl("9090"), "http://127.0.0.1:9090");
assert.equal( assert.equal(
@@ -39,7 +37,6 @@ test("getApplicationPaths keeps packaged root under Electron resources", () => {
nodecgPort: "9090", nodecgPort: "9090",
bundleName: "scoreko-dev", bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true", mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
}, },
appDataPath: "/users/test/AppData/Roaming", appDataPath: "/users/test/AppData/Roaming",
compiledMainDir: "/app/dist/main", compiledMainDir: "/app/dist/main",
+41 -21
View File
@@ -35,6 +35,10 @@ class MockWindow implements ApplicationWindow {
this.events.push(`${this.name}:load:${url}`); this.events.push(`${this.name}:load:${url}`);
} }
async loadFile(filePath: string): Promise<void> {
this.events.push(`${this.name}:loadFile:${filePath}`);
}
restore(): void { restore(): void {
this.events.push(`${this.name}:restore`); this.events.push(`${this.name}:restore`);
this.minimized = false; this.minimized = false;
@@ -53,7 +57,6 @@ function getBaseConfig(): AppRuntimeConfig {
nodecgPort: "9090", nodecgPort: "9090",
bundleName: "scoreko-dev", bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true", mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 0, loadDelayMs: 0,
startupTimeoutMs: 100, startupTimeoutMs: 100,
nodecgKillTimeoutMs: 10, nodecgKillTimeoutMs: 10,
@@ -86,7 +89,8 @@ test("ApplicationController preserves startup ordering and schedules updates aft
userDataPath: "/user-data/scoreko", userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090", nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html?standalone=true", 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", staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
}; };
const controller = createApplicationController({ const controller = createApplicationController({
@@ -114,7 +118,6 @@ test("ApplicationController preserves startup ordering and schedules updates aft
events.push("prepare-runtime"); events.push("prepare-runtime");
return { runtimePath: "/user-data/scoreko/nodecg", installed: false }; return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
}, },
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"), scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"), setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`), exit: (code) => events.push(`exit:${code}`),
@@ -130,14 +133,15 @@ test("ApplicationController preserves startup ordering and schedules updates aft
assert.equal(controller.getState(), "ready"); assert.equal(controller.getState(), "ready");
assert.deepEqual(events, [ assert.deepEqual(events, [
"set-app-user-model-id", "set-app-user-model-id",
"prepare-runtime",
"create-manager",
"create-main", "create-main",
"create-loading", "create-loading",
`loading:loadFile:${paths.staticLoadingHtmlPath}`,
"loading:show",
"sleep:50",
"prepare-runtime",
"create-manager",
"start-nodecg", "start-nodecg",
"wait-nodecg", "wait-nodecg",
`loading:load:${paths.loadingDashboardUrl}`,
"loading:show",
`main:load:${paths.mainDashboardUrl}`, `main:load:${paths.mainDashboardUrl}`,
"main:show", "main:show",
"loading:close", "loading:close",
@@ -145,7 +149,7 @@ test("ApplicationController preserves startup ordering and schedules updates aft
]); ]);
}); });
test("ApplicationController relaunches packaged app after runtime install before starting NodeCG", async () => { test("ApplicationController directly launches packaged app after runtime install without relaunching", async () => {
const events: string[] = []; const events: string[] = [];
const controller = createApplicationController({ const controller = createApplicationController({
appConfig: getBaseConfig(), appConfig: getBaseConfig(),
@@ -158,35 +162,51 @@ test("ApplicationController relaunches packaged app after runtime install before
userDataPath: "/user-data/scoreko", userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090", nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main", mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading", staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
}, },
deps: { deps: {
createLoadingWindow: () => { createLoadingWindow: () => {
throw new Error("window creation should wait until after relaunch decisions"); events.push("create-loading");
return new MockWindow("loading", events);
}, },
createMainWindow: () => { createMainWindow: () => {
throw new Error("window creation should wait until after relaunch decisions"); events.push("create-main");
return new MockWindow("main", events);
}, },
createNodecgProcessManager: () => { createNodecgProcessManager: () => {
throw new Error("NodeCG should not start before relaunch"); events.push("create-manager");
return createMockManager(events);
}, },
getAllWindows: () => [], getAllWindows: () => [],
log: (...args) => events.push(String(args[0])), log: (...args) => events.push(String(args[0])),
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }), prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: true }),
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"), scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"), setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`), exit: (code) => events.push(`exit:${code}`),
now: () => 0,
sleep: async (ms) => {
events.push(`sleep:${ms}`);
},
}, },
}); });
await controller.launch(); await controller.launch();
assert.equal(controller.getState(), "stopped"); assert.equal(controller.getState(), "ready");
assert.deepEqual(events, [ assert.deepEqual(events, [
"Runtime was installed or refreshed; relaunching Scoreko before starting NodeCG.", "create-main",
"relaunch", "create-loading",
"exit:0", "loading:loadFile:/app/static/loading.html",
"loading:show",
"sleep:50",
"create-manager",
"start-nodecg",
"wait-nodecg",
"main:load:http://localhost:9090/main",
"main:show",
"loading:close",
"schedule-update",
]); ]);
}); });
@@ -203,7 +223,8 @@ test("ApplicationController activation before readiness routes through launch",
userDataPath: "/user-data/scoreko", userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090", nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main", mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading", staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
}, },
deps: { deps: {
createLoadingWindow: () => new MockWindow("loading", events), createLoadingWindow: () => new MockWindow("loading", events),
@@ -215,7 +236,6 @@ test("ApplicationController activation before readiness routes through launch",
events.push("prepare-runtime"); events.push("prepare-runtime");
return { runtimePath: "/user-data/scoreko/nodecg", installed: false }; return { runtimePath: "/user-data/scoreko/nodecg", installed: false };
}, },
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"), scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"), setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`), exit: (code) => events.push(`exit:${code}`),
@@ -244,7 +264,8 @@ test("ApplicationController shutdown is idempotent", async () => {
userDataPath: "/user-data/scoreko", userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090", nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main", mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading", staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
}, },
deps: { deps: {
createLoadingWindow: () => new MockWindow("loading", events), createLoadingWindow: () => new MockWindow("loading", events),
@@ -253,7 +274,6 @@ test("ApplicationController shutdown is idempotent", async () => {
getAllWindows: () => [], getAllWindows: () => [],
log: () => undefined, log: () => undefined,
prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: false }), prepareRuntime: () => ({ runtimePath: "/user-data/scoreko/nodecg", installed: false }),
relaunch: () => events.push("relaunch"),
scheduleUpdateCheck: () => events.push("schedule-update"), scheduleUpdateCheck: () => events.push("schedule-update"),
setAppUserModelId: () => events.push("set-app-user-model-id"), setAppUserModelId: () => events.push("set-app-user-model-id"),
exit: (code) => events.push(`exit:${code}`), exit: (code) => events.push(`exit:${code}`),
-1
View File
@@ -13,7 +13,6 @@ function getBaseConfig(): AppRuntimeConfig {
nodecgPort: "9090", nodecgPort: "9090",
bundleName: "scoreko-dev", bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true", mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 10000, loadDelayMs: 10000,
startupTimeoutMs: 30000, startupTimeoutMs: 30000,
nodecgKillTimeoutMs: 2500, nodecgKillTimeoutMs: 2500,
+1 -1
View File
@@ -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-policy"; import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation";
const dashboardUrl = "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html"; const dashboardUrl = "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html";
+1 -1
View File
@@ -3,7 +3,7 @@ import { EventEmitter } from "node:events";
import { SpawnOptions } from "node:child_process"; import { SpawnOptions } from "node:child_process";
import test from "node:test"; import test from "node:test";
import { killProcessTree } from "../main/nodecg/platform-process-killer"; import { killProcessTree } from "../main/nodecg/process-killer";
test("killProcessTree validates pid before building Windows taskkill command", () => { test("killProcessTree validates pid before building Windows taskkill command", () => {
const spawnCalls: Array<{ command: string; args: string[]; options: SpawnOptions }> = []; const spawnCalls: Array<{ command: string; args: string[]; options: SpawnOptions }> = [];
-1
View File
@@ -26,7 +26,6 @@ function getBaseConfig(): AppRuntimeConfig {
nodecgPort: "9090", nodecgPort: "9090",
bundleName: "scoreko-dev", bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true", mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 10000, loadDelayMs: 10000,
startupTimeoutMs: 100, startupTimeoutMs: 100,
nodecgKillTimeoutMs: 10, nodecgKillTimeoutMs: 10,
+151
View File
@@ -1,5 +1,6 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import path from "node:path";
import { import {
getEnv, getEnv,
@@ -8,6 +9,12 @@ import {
parseEnvIntInRange, parseEnvIntInRange,
parseEnvPort, parseEnvPort,
parseOptionalHttpUrl, parseOptionalHttpUrl,
loadEnvFile,
getRuntimeConfig,
getRequiredEnv,
parseRequiredEnvIntInRange,
parseRequiredEnvBool,
parseRequiredEnvPort,
} from "../main/config/runtime-config"; } from "../main/config/runtime-config";
function withEnv(name: string, value: string | undefined, run: () => void): void { function withEnv(name: string, value: string | undefined, run: () => void): void {
@@ -31,6 +38,30 @@ function withEnv(name: string, value: string | undefined, run: () => void): void
} }
} }
function withEnvs(envs: Record<string, string | undefined>, run: () => void): void {
const previousValues: Record<string, string | undefined> = {};
for (const name of Object.keys(envs)) {
previousValues[name] = process.env[name];
if (envs[name] === undefined) {
delete process.env[name];
} else {
process.env[name] = envs[name];
}
}
try {
run();
} finally {
for (const name of Object.keys(envs)) {
if (previousValues[name] === undefined) {
delete process.env[name];
} else {
process.env[name] = previousValues[name];
}
}
}
}
test("getOptionalEnv returns undefined for missing variable", () => { test("getOptionalEnv returns undefined for missing variable", () => {
withEnv("TEST_OPTIONAL_ENV", undefined, () => { withEnv("TEST_OPTIONAL_ENV", undefined, () => {
assert.equal(getOptionalEnv("TEST_OPTIONAL_ENV"), undefined); assert.equal(getOptionalEnv("TEST_OPTIONAL_ENV"), undefined);
@@ -106,3 +137,123 @@ test("parseOptionalHttpUrl rejects unsupported protocols", () => {
assert.throws(() => parseOptionalHttpUrl("TEST_UPDATE_URL"), /valid HTTP\(S\) URL/); assert.throws(() => parseOptionalHttpUrl("TEST_UPDATE_URL"), /valid HTTP\(S\) URL/);
}); });
}); });
test("loadEnvFile throws on non-existent file", () => {
const missingPath = path.join(__dirname, "does-not-exist-.env");
assert.throws(() => loadEnvFile(missingPath), /Archivo de configuración obligatorio no encontrado/);
});
test("getRequiredEnv throws on missing or empty variable", () => {
withEnv("TEST_REQUIRED_ENV", undefined, () => {
assert.throws(() => getRequiredEnv("TEST_REQUIRED_ENV"), /no está definida/);
});
withEnv("TEST_REQUIRED_ENV", " ", () => {
assert.throws(() => getRequiredEnv("TEST_REQUIRED_ENV"), /no está definida/);
});
});
test("getRequiredEnv returns trimmed value when present", () => {
withEnv("TEST_REQUIRED_ENV", " scoreko-app ", () => {
assert.equal(getRequiredEnv("TEST_REQUIRED_ENV"), "scoreko-app");
});
});
test("parseRequiredEnvIntInRange validates required integer and throws if missing", () => {
withEnv("TEST_REQ_INT", undefined, () => {
assert.throws(() => parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), /no está definida/);
});
withEnv("TEST_REQ_INT", "150", () => {
assert.throws(() => parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), /must be an integer/);
});
withEnv("TEST_REQ_INT", "42", () => {
assert.equal(parseRequiredEnvIntInRange("TEST_REQ_INT", 0, 100), 42);
});
});
test("parseRequiredEnvBool validates required boolean and throws if missing", () => {
withEnv("TEST_REQ_BOOL", undefined, () => {
assert.throws(() => parseRequiredEnvBool("TEST_REQ_BOOL"), /no está definida/);
});
withEnv("TEST_REQ_BOOL", "maybe", () => {
assert.throws(() => parseRequiredEnvBool("TEST_REQ_BOOL"), /must be a boolean/);
});
withEnv("TEST_REQ_BOOL", "true", () => {
assert.equal(parseRequiredEnvBool("TEST_REQ_BOOL"), true);
});
withEnv("TEST_REQ_BOOL", "off", () => {
assert.equal(parseRequiredEnvBool("TEST_REQ_BOOL"), false);
});
});
test("parseRequiredEnvPort validates required port and throws if missing", () => {
withEnv("TEST_REQ_PORT", undefined, () => {
assert.throws(() => parseRequiredEnvPort("TEST_REQ_PORT"), /no está definida/);
});
withEnv("TEST_REQ_PORT", "70000", () => {
assert.throws(() => parseRequiredEnvPort("TEST_REQ_PORT"), /valid TCP port/);
});
withEnv("TEST_REQ_PORT", "9090", () => {
assert.equal(parseRequiredEnvPort("TEST_REQ_PORT"), "9090");
});
});
test("getRuntimeConfig throws if required variables are missing", () => {
withEnvs(
{
SCOREKO_APP_TITLE: undefined,
SCOREKO_APP_USER_MODEL_ID: "com.scoreko.desktop",
SCOREKO_APP_USER_DATA_DIRECTORY: "scoreko",
NODECG_PORT: "9090",
NODECG_BUNDLE_NAME: "scoreko-dev",
SCOREKO_DASHBOARD_ROUTE: "dashboard/scoreko-dev/main.html?standalone=true",
ELECTRON_LOAD_DELAY_MS: "10000",
NODECG_STARTUP_TIMEOUT_MS: "120000",
NODECG_KILL_TIMEOUT_MS: "2500",
SCOREKO_UPDATES_ENABLED: "true",
SCOREKO_UPDATE_CHECK_DELAY_MS: "5000",
},
() => {
assert.throws(() => getRuntimeConfig(), /SCOREKO_APP_TITLE/);
},
);
});
test("getRuntimeConfig parses successfully when all required variables are set", () => {
withEnvs(
{
SCOREKO_APP_TITLE: "Scoreko Test App",
SCOREKO_APP_USER_MODEL_ID: "com.scoreko.test",
SCOREKO_APP_USER_DATA_DIRECTORY: "scoreko-test",
NODECG_PORT: "9191",
NODECG_BUNDLE_NAME: "scoreko-dev-test",
SCOREKO_DASHBOARD_ROUTE: "dashboard/scoreko-dev/test.html",
ELECTRON_LOAD_DELAY_MS: "5000",
NODECG_STARTUP_TIMEOUT_MS: "60000",
NODECG_KILL_TIMEOUT_MS: "1500",
SCOREKO_UPDATES_ENABLED: "false",
SCOREKO_UPDATE_CHECK_DELAY_MS: "3000",
},
() => {
const config = getRuntimeConfig();
assert.equal(config.title, "Scoreko Test App");
assert.equal(config.userModelId, "com.scoreko.test");
assert.equal(config.userDataDirectoryName, "scoreko-test");
assert.equal(config.nodecgPort, "9191");
assert.equal(config.bundleName, "scoreko-dev-test");
assert.equal(config.mainDashboardRoute, "dashboard/scoreko-dev/test.html");
assert.equal(config.loadDelayMs, 5000);
assert.equal(config.startupTimeoutMs, 60000);
assert.equal(config.nodecgKillTimeoutMs, 1500);
assert.equal(config.updatesEnabled, false);
assert.equal(config.updateCheckDelayMs, 3000);
},
);
});
+21 -8
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { prepareUserNodecgRuntime } from "../main/nodecg/runtime-provisioner"; import { prepareUserNodecgRuntime } from "../main/nodecg/runtime-setup";
type FakeFsState = { type FakeFsState = {
paths: Set<string>; paths: Set<string>;
@@ -38,10 +38,21 @@ function createFakeFs(initialPaths: string[] = [], initialFiles: Record<string,
cpSync: (from: string, to: string) => { cpSync: (from: string, to: string) => {
state.copied.push({ from: path.normalize(from), to: path.normalize(to) }); state.copied.push({ from: path.normalize(from), to: path.normalize(to) });
state.paths.add(path.normalize(to)); state.paths.add(path.normalize(to));
state.paths.add(path.join(path.normalize(to), "index.js")); },
state.paths.add(path.join(path.normalize(to), "package.json")); statSync: (filePath: string) => ({
state.paths.add(path.join(path.normalize(to), "node_modules", "nodecg", "dist", "server", "bootstrap.js")); isDirectory: () => {
state.paths.add(path.join(path.normalize(to), "bundles", "scoreko-dev", "package.json")); const normalized = path.normalize(filePath);
return normalized.endsWith("node_modules") || normalized.endsWith("bundles");
},
}),
symlinkSync: (target: string, linkPath: string, _type: string) => {
state.copied.push({ from: path.normalize(target), to: path.normalize(linkPath) });
state.paths.add(path.normalize(linkPath));
if (target.endsWith("node_modules")) {
state.paths.add(path.join(path.normalize(linkPath), "nodecg", "dist", "server", "bootstrap.js"));
} else if (target.endsWith("bundles")) {
state.paths.add(path.join(path.normalize(linkPath), "scoreko-dev", "package.json"));
}
}, },
readFileSync: (filePath: string) => state.files.get(path.normalize(filePath)) ?? "{}", readFileSync: (filePath: string) => state.files.get(path.normalize(filePath)) ?? "{}",
writeFileSync: (filePath: string, content: string) => { writeFileSync: (filePath: string, content: string) => {
@@ -57,7 +68,9 @@ function getSourcePaths(source: string) {
source, source,
path.join(source, "index.js"), path.join(source, "index.js"),
path.join(source, "package.json"), path.join(source, "package.json"),
path.join(source, "node_modules"),
path.join(source, "node_modules", "nodecg", "dist", "server", "bootstrap.js"), path.join(source, "node_modules", "nodecg", "dist", "server", "bootstrap.js"),
path.join(source, "bundles"),
path.join(source, "bundles", "scoreko-dev", "package.json"), path.join(source, "bundles", "scoreko-dev", "package.json"),
path.join(source, ".scoreko-runtime.json"), path.join(source, ".scoreko-runtime.json"),
]; ];
@@ -81,7 +94,7 @@ test("prepareUserNodecgRuntime copies the packaged runtime into userData", () =>
assert.equal(preparedRuntime.runtimePath, path.join(userData, "nodecg")); assert.equal(preparedRuntime.runtimePath, path.join(userData, "nodecg"));
assert.equal(preparedRuntime.installed, true); assert.equal(preparedRuntime.installed, true);
assert.equal(state.copied.length, 1); assert.equal(state.copied.length, 4);
assert.ok(state.paths.has(path.join(userData, "nodecg", "cfg"))); assert.ok(state.paths.has(path.join(userData, "nodecg", "cfg")));
assert.ok(state.paths.has(path.join(userData, "nodecg", "db"))); assert.ok(state.paths.has(path.join(userData, "nodecg", "db")));
assert.ok(state.paths.has(path.join(userData, "nodecg", "logs"))); assert.ok(state.paths.has(path.join(userData, "nodecg", "logs")));
@@ -150,7 +163,7 @@ test("prepareUserNodecgRuntime refreshes managed files when the app version chan
}); });
assert.equal(preparedRuntime.installed, true); assert.equal(preparedRuntime.installed, true);
assert.equal(state.copied.length, 1); assert.equal(state.copied.length, 4);
assert.ok(state.removed.includes(path.join(target, "node_modules"))); assert.ok(state.removed.includes(path.join(target, "node_modules")));
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")));
@@ -190,7 +203,7 @@ test("prepareUserNodecgRuntime refreshes managed files when the source runtime w
}); });
assert.equal(preparedRuntime.installed, true); assert.equal(preparedRuntime.installed, true);
assert.equal(state.copied.length, 1); assert.equal(state.copied.length, 4);
assert.ok(state.removed.includes(path.join(target, "bundles"))); assert.ok(state.removed.includes(path.join(target, "bundles")));
assert.ok(!state.removed.includes(path.join(target, "cfg"))); assert.ok(!state.removed.includes(path.join(target, "cfg")));
}); });
+21 -57
View File
@@ -1,11 +1,8 @@
import assert from "node:assert/strict"; 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 test from "node:test";
import { AppRuntimeConfig } from "../main/config/runtime-config"; import { AppRuntimeConfig } from "../main/config/runtime-config";
import { loadUpdateSettings, readUpdateFileConfig } from "../main/updates/update-config"; import { loadUpdateSettings } from "../main/updates/update-config";
const baseConfig: AppRuntimeConfig = { const baseConfig: AppRuntimeConfig = {
title: "Scoreko", title: "Scoreko",
@@ -14,7 +11,6 @@ const baseConfig: AppRuntimeConfig = {
nodecgPort: "9090", nodecgPort: "9090",
bundleName: "scoreko-dev", bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true", mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 0, loadDelayMs: 0,
startupTimeoutMs: 30000, startupTimeoutMs: 30000,
nodecgKillTimeoutMs: 2500, nodecgKillTimeoutMs: 2500,
@@ -23,37 +19,36 @@ const baseConfig: AppRuntimeConfig = {
}; };
test("loadUpdateSettings keeps updates disabled when the runtime config disables them", () => { test("loadUpdateSettings keeps updates disabled when the runtime config disables them", () => {
const rootPath = makeTempRoot({ const settings = loadUpdateSettings(
enabled: true, {
apiUrl: "https://gitea.local/releases/latest", ...baseConfig,
}); updatesEnabled: false,
updateApiUrl: "https://gitea.local/releases/latest",
const settings = loadUpdateSettings({ ...baseConfig, updatesEnabled: false }, rootPath, () => undefined); },
"",
() => undefined,
);
assert.equal(settings.enabled, false); assert.equal(settings.enabled, false);
assert.equal(settings.apiUrl, "https://gitea.local/releases/latest"); assert.equal(settings.apiUrl, "https://gitea.local/releases/latest");
}); });
test("loadUpdateSettings fails closed on insecure production update URLs", () => { test("loadUpdateSettings fails closed on insecure production update URLs", () => {
const rootPath = makeTempRoot({ const settings = loadUpdateSettings(
enabled: true, {
apiUrl: "http://gitea.local/releases/latest", ...baseConfig,
}); updateApiUrl: "http://gitea.local/releases/latest",
},
const settings = loadUpdateSettings(baseConfig, rootPath, () => undefined, { allowInsecureHttp: false }); "",
() => undefined,
{ allowInsecureHttp: false },
);
assert.equal(settings.enabled, false); assert.equal(settings.enabled, false);
assert.equal(settings.apiUrl, undefined); assert.equal(settings.apiUrl, undefined);
}); });
test("loadUpdateSettings lets runtime config override file settings", () => { test("loadUpdateSettings lets runtime config specify settings", () => {
const rootPath = makeTempRoot({
enabled: true,
apiUrl: "https://file.local/releases/latest",
releasePageUrl: "https://file.local/releases",
assetPattern: "File-.*\\.exe$",
});
const settings = loadUpdateSettings( const settings = loadUpdateSettings(
{ {
...baseConfig, ...baseConfig,
@@ -61,7 +56,7 @@ test("loadUpdateSettings lets runtime config override file settings", () => {
updateReleasePageUrl: "https://env.local/releases", updateReleasePageUrl: "https://env.local/releases",
updateAssetPattern: "Env-.*\\.exe$", updateAssetPattern: "Env-.*\\.exe$",
}, },
rootPath, "",
() => undefined, () => undefined,
); );
@@ -72,34 +67,3 @@ test("loadUpdateSettings lets runtime config override file settings", () => {
assetPattern: "Env-.*\\.exe$", 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;
}
+35
View File
@@ -56,3 +56,38 @@ test("downloadInstaller rejects insecure production download URLs", async () =>
/unsupported protocol/, /unsupported protocol/,
); );
}); });
test("downloadInstaller reuses existing file if size matches and does not download again", async () => {
const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "scoreko-update-download-"));
const downloadDirectory = path.join(tempDirectory, "scoreko-updates");
fs.mkdirSync(downloadDirectory, { recursive: true });
const installerPath = path.join(downloadDirectory, "Scoreko_setup_0.2.0.exe");
fs.writeFileSync(installerPath, "cached-installer-bytes");
const cachedSize = fs.statSync(installerPath).size;
const previousFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error("Should not fetch when using cached file!");
};
try {
const resultPath = await downloadInstaller(
{
version: "0.2.0",
title: "Scoreko 0.2.0",
installer: {
name: "Scoreko/setup:0.2.0.exe",
downloadUrl: "https://updates.local/Scoreko-setup-0.2.0.exe",
size: cachedSize,
},
},
{ tempDirectory, allowInsecureHttp: false },
);
assert.equal(resultPath, installerPath);
assert.equal(fs.readFileSync(resultPath, "utf8"), "cached-installer-bytes");
} finally {
globalThis.fetch = previousFetch;
}
});
+174
View File
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline'">
<title>Scoreko - Error al iniciar</title>
<style>
body {
background-color: #121212;
color: #f5f5f5;
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
overflow: hidden;
user-select: none;
}
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px;
box-sizing: border-box;
}
.error-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 560px;
width: 100%;
}
.error-icon {
width: 56px;
height: 56px;
color: #ef5350;
flex-shrink: 0;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: #f5f5f5;
}
.error-message {
font-size: 0.875rem;
line-height: 1.6;
color: rgba(245, 245, 245, 0.65);
margin: 0;
word-break: break-word;
max-height: 220px;
overflow-y: auto;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 12px 16px;
text-align: left;
font-family: ui-monospace, "Cascadia Code", "Consolas", monospace;
white-space: pre-wrap;
width: 100%;
box-sizing: border-box;
}
.hint {
font-size: 0.8125rem;
color: rgba(245, 245, 245, 0.45);
margin: 0;
}
.actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
button {
appearance: none;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s ease;
}
button:hover {
opacity: 0.85;
}
button:active {
opacity: 0.7;
}
#btn-logs {
background: rgba(255,255,255,0.08);
color: #f5f5f5;
border: 1px solid rgba(255,255,255,0.12);
}
#btn-quit {
background: #1976d2;
color: #fff;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.15);
border-radius: 3px;
}
</style>
</head>
<body>
<div class="error-page">
<div class="error-content">
<svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<h1 id="error-title">Scoreko no pudo iniciar</h1>
<pre class="error-message" id="error-detail">Se produjo un error inesperado al iniciar el servidor interno.</pre>
<p class="hint">Revisa los logs para más detalles o cierra y vuelve a abrir la aplicación.</p>
<div class="actions">
<button id="btn-logs">Ver logs</button>
<button id="btn-quit">Cerrar Scoreko</button>
</div>
</div>
</div>
<script>
// Read optional error detail injected via URL hash: error.html#msg=...
try {
const hash = decodeURIComponent(window.location.hash.slice(1));
const params = new URLSearchParams(hash);
const msg = params.get('msg');
if (msg) {
document.getElementById('error-detail').textContent = msg;
}
} catch (_) {
// ignore parse errors
}
document.getElementById('btn-quit').addEventListener('click', () => {
window.close();
});
// btn-logs: the main process listens for this via ipc if a preload is wired,
// otherwise we just note the action (no-op in sandbox mode).
document.getElementById('btn-logs').addEventListener('click', () => {
// Signal to main process via hash navigation that the user wants to open logs.
// The main process's will-navigate handler opens external URLs, so we use a
// custom app:// scheme that is caught and handled there.
window.location.href = 'app://open-logs';
});
</script>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
!include installer.nsh
InitPluginsDir
${IfNot} ${Silent}
SetDetailsPrint both
${endif}
StrCpy $appExe "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
# must be called before uninstallOldVersion
!insertmacro setLinkVars
!ifdef ONE_CLICK
!ifdef HEADER_ICO
File /oname=$PLUGINSDIR\installerHeaderico.ico "${HEADER_ICO}"
!endif
${IfNot} ${Silent}
!ifdef HEADER_ICO
SpiderBanner::Show /MODERN /ICON "$PLUGINSDIR\installerHeaderico.ico"
!else
SpiderBanner::Show /MODERN
!endif
FindWindow $0 "#32770" "" $hwndparent
FindWindow $0 "#32770" "" $hwndparent $0
GetDlgItem $0 $0 1000
SendMessage $0 ${WM_SETTEXT} 0 "STR:$(installing)"
StrCpy $1 $hwndparent
System::Call 'user32::ShutdownBlockReasonCreate(${SYSTYPE_PTR}r1, w "$(installing)")'
${endif}
!insertmacro CHECK_APP_RUNNING
!else
${ifNot} ${UAC_IsInnerInstance}
!insertmacro CHECK_APP_RUNNING
${endif}
!endif
Var /GLOBAL keepShortcuts
StrCpy $keepShortcuts "false"
!insertMacro setIsTryToKeepShortcuts
${if} $isTryToKeepShortcuts == "true"
ReadRegStr $R1 SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" KeepShortcuts
${if} $R1 == "true"
${andIf} ${FileExists} "$appExe"
StrCpy $keepShortcuts "true"
${endIf}
${endif}
!insertmacro uninstallOldVersion SHELL_CONTEXT
!insertmacro handleUninstallResult SHELL_CONTEXT
${if} $installMode == "all"
!insertmacro uninstallOldVersion HKEY_CURRENT_USER
!insertmacro handleUninstallResult HKEY_CURRENT_USER
${endIf}
SetOutPath $INSTDIR
!ifdef UNINSTALLER_ICON
File /oname=uninstallerIcon.ico "${UNINSTALLER_ICON}"
!endif
!insertmacro installApplicationFiles
!insertmacro registryAddInstallInfo
!insertmacro addStartMenuLink $keepShortcuts
!insertmacro addDesktopLink $keepShortcuts
${if} ${FileExists} "$newStartMenuLink"
StrCpy $launchLink "$newStartMenuLink"
${else}
StrCpy $launchLink "$INSTDIR\${APP_EXECUTABLE_FILENAME}"
${endIf}
!ifmacrodef registerFileAssociations
!insertmacro registerFileAssociations
!endif
!ifmacrodef customInstall
!insertmacro customInstall
!endif
!macro doStartApp
# otherwise app window will be in background
HideWindow
!insertmacro StartApp
!macroend
!ifdef ONE_CLICK
# https://github.com/electron-userland/electron-builder/pull/3093#issuecomment-403734568
!ifdef RUN_AFTER_FINISH
${ifNot} ${Silent}
${orIf} ${isForceRun}
!insertmacro doStartApp
${endIf}
!else
${if} ${isForceRun}
!insertmacro doStartApp
${endIf}
!endif
!insertmacro quitSuccess
!else
# for assisted installer run only if silent, because assisted installer has run after finish option
${if} ${isForceRun}
${andIf} ${Silent}
!insertmacro doStartApp
${endIf}
!endif
+4
View File
@@ -0,0 +1,4 @@
!macro customHeader
ShowInstDetails hide
ShowUninstDetails hide
!macroend
+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline';">
<title>Scoreko</title>
<style>
body {
background-color: #121212;
color: #f5f5f5;
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
overflow: hidden;
user-select: none;
}
.loading-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.q-spinner {
animation: q-spin 2s linear infinite;
transform-origin: center center;
width: 50px;
height: 50px;
color: #1976d2;
}
.q-spinner circle {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
animation: q-spin-dash 1.5s ease-in-out infinite;
stroke-linecap: round;
}
@keyframes q-spin {
100% { transform: rotate(360deg); }
}
@keyframes q-spin-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}
.quote {
max-width: 520px;
margin-top: 16px;
font-size: 1rem;
line-height: 1.75rem;
font-weight: 400;
}
.status {
font-style: italic;
opacity: 0.7;
font-size: 0.875rem;
font-weight: 500;
}
::-webkit-scrollbar {
display: none;
}
</style>
</head>
<body>
<div class="loading-page">
<div class="loading-content">
<svg class="q-spinner" viewBox="25 25 50 50">
<circle cx="50" cy="50" r="20" fill="none" stroke="currentColor" stroke-width="5" stroke-miterlimit="10"></circle>
</svg>
<div class="quote" id="quoteText"></div>
<div class="status">Loading...</div>
</div>
</div>
<script>
const loadQuotes = [
"Complaining about Paul's damage",
'Nerfing Gigas',
'Mashing hopkick',
'Sidestepping your electric',
'Punishing hellsweep with 1,1,2',
'Emailing Harada',
];
const randomIndex = Math.floor(Math.random() * loadQuotes.length);
document.getElementById('quoteText').textContent = loadQuotes[randomIndex];
</script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
{
"enabled": false,
"apiUrl": "http://gitea.local/api/v1/repos/OWNER/REPO/releases/latest",
"releasePageUrl": "http://gitea.local/OWNER/REPO/releases",
"assetPattern": "Scoreko-setup-.*\\.exe$"
}