Compare commits

..

7 Commits

27 changed files with 355 additions and 208 deletions
-1
View File
@@ -11,7 +11,6 @@ SCOREKO_APP_ICON_PATH=static/icons/icon.ico
NODECG_BUNDLE_NAME=scoreko-dev
NODECG_PORT=9090
SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true
SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true
# Timing & Lifecycles (Required)
ELECTRON_LOAD_DELAY_MS=10000
+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
pnpm install
```
Then from `scoreko-electron-dev`:
Then, move into the wrapper folder:
```powershell
cd scoreko-electron-dev
npm install
npm run dist:win
```
The installer is written to `scoreko-electron-dev/release/Scoreko-setup-0.1.0.exe`.
### Useful Commands
## What the build does
- `npm run start`: Builds the bundle and launches Electron locally for testing.
- `npm run dist:win`: Packages everything and creates the `.exe` Windows installer in the `release/` folder.
- `npm run prepare:runtime`: Extracts a fresh NodeCG runtime from the parent bundle (useful if you changed dependencies).
- `npm run rebuild:native`: Rebuilds native Node modules (like SQLite) specifically for Electron's V8 engine.
- `npm run doctor`: Runs a quick sanity check to verify your local configuration and port availability.
- Builds the parent `scoreko-dev` bundle with `pnpm build`.
- Creates `scoreko-electron-dev/lib/nodecg` with a small NodeCG runtime.
- Installs production runtime modules into that runtime.
- Rebuilds `better-sqlite3` for Electron before creating the installer.
- Packages the runtime as an Electron extra resource outside the app archive.
## How it works under the hood
## Runtime behavior
When you build the installer, the script automatically compiles the main `scoreko-dev` bundle, provisions a lightweight NodeCG runtime in `lib/nodecg`, and packages it as an external asset alongside the Electron app.
On first launch, Scoreko copies the packaged NodeCG runtime to the user's app data folder and then relaunches itself before starting NodeCG. This keeps `cfg`, `db`, and `logs` writable on Windows even when the app is installed under `Program Files`, and avoids transient startup failures caused by freshly copied runtime files.
When a user runs Scoreko for the first time, the app copies this NodeCG runtime directly into their local AppData folder. This is a deliberate choice: it ensures that databases, configs, and logs remain fully writable, even if the user installed the app in restricted directories like `Program Files`.
## Useful scripts
## Auto-Updates via Gitea
- `npm run start`: build everything and run Electron locally.
- `npm run prepare:runtime`: recreate `lib/nodecg` from the parent bundle.
- `npm run rebuild:native`: rebuild NodeCG native modules for Electron.
- `npm run dist:win`: create the Windows installer.
- `npm run doctor`: check the prepared runtime and the configured port.
Scoreko supports seamless, opt-in updates through your Gitea instance.
## Updates from Gitea
Scoreko can check a Gitea release feed without forcing the user to update. Edit `static/updates.json` before building:
Before building your production installer, check `static/updates.json`:
```json
{
@@ -52,17 +46,20 @@ Scoreko can check a Gitea release feed without forcing the user to update. Edit
}
```
For each release, bump `package.json` version, build with `npm run dist:win`, create a Gitea release tagged like `v0.2.0`, and attach `release/Scoreko-setup-0.2.0.exe`. When Scoreko sees a newer tag, it asks whether to download and install it.
**To ship an update:**
1. Bump the version in `package.json`.
2. Run `npm run dist:win` to generate the new installer.
3. Create a new release tag in Gitea (e.g., `v0.2.0`) and attach the `.exe`.
4. The app will detect the new version, notify the user, and handle the installation safely.
## Configuration
## Environment Configuration
The defaults match the parent bundle:
The app ships with sensible defaults that match our development bundle:
- `NODECG_BUNDLE_NAME=scoreko-dev`
- `NODECG_PORT=9090`
- `SCOREKO_DASHBOARD_ROUTE=dashboard/scoreko-dev/main.html?standalone=true`
- `SCOREKO_LOADING_ROUTE=dashboard/loading/main.html?standalone=true`
- `SCOREKO_UPDATES_ENABLED=true`
- `SCOREKO_UPDATE_ASSET_PATTERN=Scoreko-setup-.*\.exe$`
Copy `.env.example` only if you need local overrides while developing.
You only need to mess with `.env.example` if you want to override these values locally while testing.
+21 -23
View File
@@ -1,29 +1,27 @@
# Main process architecture
# Main Process Architecture
## Startup flow
This document breaks down how the Electron main process is structured and what happens when the app launches.
1. `src/main/main.ts` loads `appConfig` from `config/runtime-config.ts`.
2. Installs or refreshes the packaged NodeCG runtime in user data when needed (`nodecg/runtime-provisioner.ts`).
3. Creates windows (`windows/window-factory.ts`).
4. Starts NodeCG with `nodecg/process-manager.ts`.
5. Waits for HTTP readiness and shows loading -> main dashboard.
6. Checks the configured Gitea latest-release endpoint for optional updates.
7. On shutdown, runs a single graceful-stop flow to avoid orphan processes.
## Startup Flow
## Main modules
When a user opens Scoreko, the app goes through a precise sequence to ensure NodeCG starts reliably:
- `config/runtime-config.ts`: read/validate env vars.
- `nodecg/runtime-provisioner.ts`: install/refresh the managed runtime in the writable user data folder and report whether it changed.
- `nodecg/process-manager.ts`: start, readiness, and stop for NodeCG; install/permission/port validation.
- `updates/update-manager.ts`: optional Gitea release checks, installer download, and user-controlled install.
- `updates/update-utils.ts`: release version comparison and installer asset selection.
- `windows/window-factory.ts`: window creation and navigation policy.
- `windows/navigation-security.ts`: internal navigation allowlist and safe external schemes.
- `errors/error-presenter.ts`: fatal error presentation.
- `errors/logger.ts`: structured logging (`info/warn/error/debug`).
1. **Configuration:** `src/main/main.ts` kicks things off by loading `appConfig` via `config/runtime-config.ts`.
2. **Runtime Provisioning:** The app checks the user's AppData directory. If the packaged NodeCG runtime is missing or outdated, it extracts a fresh copy (`nodecg/runtime-provisioner.ts`).
3. **Window Creation:** The initial windows (like the loading screen) are instantiated via `windows/window-factory.ts`.
4. **NodeCG Boot:** `nodecg/process-manager.ts` spawns the NodeCG process in the background.
5. **Readiness Check:** The app continuously polls NodeCG until the HTTP server responds. Once ready, it transitions the UI from the loading screen to the main dashboard.
6. **Update Check:** If updates are enabled, the app checks the configured Gitea endpoint in the background to see if a newer version is available.
7. **Graceful Shutdown:** When the user closes the app, it triggers a unified teardown sequence to cleanly kill the NodeCG child process, preventing zombie processes from lingering in the background.
## Principles
## Core Modules
- Mechanical refactors first.
- Incremental hardening with conservative fallback.
- Automated validation via `typecheck`, `build`, `test`, `doctor`, `lint`.
Here is where the heavy lifting happens:
- **`config/runtime-config.ts`**: Handles environment variables and defaults.
- **`nodecg/runtime-provisioner.ts`**: Manages copying the NodeCG runtime out of the read-only Electron package into the writable user data folder.
- **`nodecg/process-manager.ts`**: Handles starting, polling, and killing the NodeCG server. It also validates ports and permissions before launching.
- **`updates/update-manager.ts`**: Coordinates the Gitea update flow (checking versions, downloading installers, prompting the user).
- **`windows/window-factory.ts`**: Centralizes window configuration and security defaults.
- **`windows/navigation-security.ts`**: Intercepts navigation events to block unauthorized domains and safely hand off external links (like docs or emails) to the user's default browser.
- **`errors/error-presenter.ts` & `errors/logger.ts`**: Manages structured logging (`electron-log`) and displaying the fallback error screen if boot fails.
+34 -36
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`.
- If the parent bundle is not installed yet, run `pnpm install` from the repository root first.
## The app says the NodeCG runtime is incomplete
This usually means you haven't bundled the runtime yet.
- Run `npm run prepare:runtime` in the `scoreko-electron-dev` folder.
- If you haven't even installed the parent bundle, go up to the repository root and run `pnpm install` first.
## `NodeCG is present but internal dependencies are missing`
## NodeCG is present but internal dependencies are missing
This happens if dependencies changed or the initial copy was interrupted.
- Re-run `npm run prepare:runtime` to get a fresh copy.
- If you're seeing SQLite errors when the app launches, you probably need to run `npm run rebuild:native` to compile it for Electron's V8 engine.
- Recreate the runtime with `npm run prepare:runtime`.
- If native SQLite errors appear during launch, run `npm run rebuild:native` before packaging.
## "No read/write permissions on NodeCG"
In production, Scoreko runs NodeCG out of your AppData folder to ensure it has write access. During local development, it runs directly from the repo.
If you see this permission error locally, another process probably has a file locked. Close any zombie Scoreko or Node processes and try `npm run start` again.
## `No read/write permissions on NodeCG`
## Port 9090 is already in use
You have another instance of NodeCG (or another web server) running on port 9090.
- Find and kill the process using the port, or change `NODECG_PORT` in your `.env` file to something else (like 9091).
- You can use `npm run doctor` to quickly test port availability.
- Installed builds run NodeCG from the user's app data folder, so this usually means the local development copy is locked.
- Close any running Scoreko/NodeCG process and run `npm run start` again.
## Timeout while waiting for NodeCG
The app waits for the NodeCG HTTP server to respond. If it times out:
- Check your terminal output. NodeCG might be crashing or hanging on startup due to a bundle error.
- If your machine is just slow, you can increase `NODECG_STARTUP_TIMEOUT_MS` in the `.env` file.
## `Port <PORT> is already in use`
## The app crashes immediately on a fresh install
Scoreko copies the runtime to `%AppData%\scoreko\nodecg` and relaunches itself on the very first run.
If it gets stuck in a loop or fails immediately:
- Check if your antivirus or Windows Search Indexer is aggressively locking the files in AppData as they are being copied.
- Try running `npm run rebuild:native` and then repackaging the app with `npm run dist:win`.
- Free the port or set `NODECG_PORT` in `.env`.
- Use `npm run doctor` to validate availability before startup.
## macOS builds are failing complaining about an icon
The `electron-builder` config explicitly looks for a Mac icon at `static/icons/icon.icns`. If you don't have one, generate it and place it there before running the macOS build.
## `Timeout while waiting for NodeCG`
- Check the Electron/NodeCG output in the terminal.
- Increase `NODECG_STARTUP_TIMEOUT_MS` if the environment is slow.
- Recreate the runtime with `npm run prepare:runtime` if the bundle changed.
## First launch after install fails
- Scoreko relaunches itself automatically after a fresh runtime install.
- If it still fails, check whether antivirus or file indexing is locking `%AppData%\scoreko\nodecg`.
- Rebuild the installer with `npm run dist:win` after running `npm run rebuild:native`.
## macOS build fails because of icon
- The configuration expects `static/icons/icon.icns`.
- Create that file before running macOS packaging.
## Updates do not appear
- Check that `static/updates.json` has `"enabled": true` before building the installer.
- The `apiUrl` must point to Gitea's latest release API: `/api/v1/repos/<owner>/<repo>/releases/latest`.
- The release tag must be newer than the installed `package.json` version, for example `v0.2.0`.
- The release must include an installer asset matching `assetPattern`, by default `Scoreko-setup-.*\.exe$`.
## Auto-updates aren't triggering
If you published a new release on Gitea but the app ignores it:
- Double check that `static/updates.json` has `"enabled": true` before you build the installer.
- Ensure your `apiUrl` points exactly to the Gitea API: `http://gitea.../api/v1/repos/<owner>/<repo>/releases/latest`.
- The git tag you created (e.g., `v0.2.0`) must be semantically higher than the version currently in your `package.json`.
- Make sure the installer `.exe` you uploaded to Gitea actually matches the regex in `assetPattern` (default is `Scoreko-setup-.*\.exe$`).
+12 -65
View File
@@ -8,8 +8,10 @@
"name": "scoreko-electron",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"electron-log": "^5.4.4"
},
"devDependencies": {
"@electron/rebuild": "^3.7.1",
"@types/node": "^22.10.5",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
@@ -182,31 +184,6 @@
"node": ">= 4.0.0"
}
},
"node_modules/@electron/node-gyp": {
"version": "10.2.0-electron.1",
"resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"glob": "^8.1.0",
"graceful-fs": "^4.2.6",
"make-fetch-happen": "^10.2.1",
"nopt": "^6.0.0",
"proc-log": "^2.0.1",
"semver": "^7.3.5",
"tar": "^6.2.1",
"which": "^2.0.2"
},
"bin": {
"node-gyp": "bin/node-gyp.js"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/@electron/notarize": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
@@ -273,35 +250,6 @@
"url": "https://github.com/sponsors/gjtorikian/"
}
},
"node_modules/@electron/rebuild": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.2.tgz",
"integrity": "sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2",
"@malept/cross-spawn-promise": "^2.0.0",
"chalk": "^4.0.0",
"debug": "^4.1.1",
"detect-libc": "^2.0.1",
"fs-extra": "^10.0.0",
"got": "^11.7.0",
"node-abi": "^3.45.0",
"node-api-version": "^0.2.0",
"ora": "^5.1.0",
"read-binary-file-arch": "^1.0.6",
"semver": "^7.3.5",
"tar": "^6.0.5",
"yargs": "^17.0.1"
},
"bin": {
"electron-rebuild": "lib/cli.js"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/@electron/universal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz",
@@ -2971,6 +2919,15 @@
"fs-extra": "^10.1.0"
}
},
"node_modules/electron-log": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.4.tgz",
"integrity": "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/electron-publish": {
"version": "25.1.7",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz",
@@ -5383,16 +5340,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/proc-log": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz",
"integrity": "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+10 -7
View File
@@ -91,7 +91,8 @@
"installerHeaderIcon": "static/icons/icon.ico",
"shortcutName": "Scoreko",
"useZip": false,
"deleteAppDataOnUninstall": true
"deleteAppDataOnUninstall": true,
"createDesktopShortcut": "always"
},
"compression": "normal"
},
@@ -99,17 +100,19 @@
"node": ">=22"
},
"devDependencies": {
"@electron/rebuild": "^3.7.1",
"@types/node": "^22.10.5",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"concurrently": "^9.1.2",
"electron": "39.5.1",
"electron-builder": "^25.1.8",
"eslint": "^9.19.0",
"prettier": "^3.4.2",
"rimraf": "^6.0.1",
"typescript": "^5.7.3",
"wait-on": "^8.0.1",
"eslint": "^9.19.0",
"@typescript-eslint/parser": "^8.22.0",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"prettier": "^3.4.2"
"wait-on": "^8.0.1"
},
"dependencies": {
"electron-log": "^5.4.4"
}
}
+24 -3
View File
@@ -5,7 +5,7 @@ import { getRemainingDelayMs } from "../utils/timing";
import { ApplicationPaths } from "./paths";
import { createShutdownService, ShutdownService } from "./shutdown-service";
export type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed";
type ApplicationState = "idle" | "preparing" | "starting" | "ready" | "stopping" | "stopped" | "failed";
export type ApplicationWindow = {
close: () => void;
@@ -53,6 +53,7 @@ export type ApplicationController = {
focusExistingWindow: () => void;
getState: () => ApplicationState;
launch: () => Promise<void>;
showErrorScreen: (error: unknown) => Promise<void>;
stopNodecgGracefully: () => Promise<void>;
};
@@ -60,7 +61,7 @@ export function createApplicationController({
appConfig,
appVersion,
deps,
isPackaged,
isPackaged: _isPackaged,
isWindows,
paths,
}: ApplicationControllerConfig): ApplicationController {
@@ -176,7 +177,7 @@ export function createApplicationController({
} catch (error) {
state = "failed";
launchPromise = null;
closeLoadingWindow();
await showErrorScreen(error);
throw error;
}
};
@@ -206,11 +207,31 @@ export function createApplicationController({
state = "stopped";
};
const showErrorScreen = async (error: unknown): Promise<void> => {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
const encodedMsg = encodeURIComponent(`msg=${encodeURIComponent(message)}`);
const errorUrl = `file://${paths.staticErrorHtmlPath}#${encodedMsg}`;
const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : loadingWindow;
if (!targetWindow || targetWindow.isDestroyed()) {
return;
}
try {
await targetWindow.loadURL(errorUrl);
targetWindow.show();
} catch {
// If even the error screen fails to load, nothing more can be done.
}
};
return {
activate,
focusExistingWindow,
getState: () => state,
launch,
showErrorScreen,
stopNodecgGracefully,
};
}
+2 -2
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { getRuntimeConfig, loadEnvFile, AppRuntimeConfig } from "../config/runtime-config";
import { showFatalError, log } from "../errors/error-handler";
import { logger } from "../logging/logger";
import { createNodecgProcessManager } from "../nodecg/process-manager";
import { prepareUserNodecgRuntime } from "../nodecg/runtime-setup";
import { scheduleUpdateCheck } from "../updates/update-service";
@@ -97,8 +98,7 @@ export function bootstrap(): void {
}
controller.launch().catch((error: unknown) => {
showFatalError("No se pudo iniciar Scoreko.", error);
app.exit(1);
logger.error("launch-failed", { error: error instanceof Error ? error.stack : String(error) });
});
});
+3 -3
View File
@@ -8,8 +8,8 @@ export type ApplicationPaths = {
userDataPath: string;
nodecgBaseUrl: string;
mainDashboardUrl: string;
loadingDashboardUrl: string;
staticLoadingHtmlPath: string;
staticErrorHtmlPath: string;
};
export function getRootPath(isDev: boolean, compiledMainDir: string, resourcesPath: string): string {
@@ -63,7 +63,7 @@ export function getApplicationPaths({
}: {
appConfig: Pick<
AppRuntimeConfig,
"bundleName" | "loadingDashboardRoute" | "mainDashboardRoute" | "nodecgPort" | "userDataDirectoryName"
"bundleName" | "mainDashboardRoute" | "nodecgPort" | "userDataDirectoryName"
>;
appDataPath: string;
compiledMainDir: string;
@@ -78,7 +78,7 @@ export function getApplicationPaths({
userDataPath: getUserDataPath(appDataPath, appConfig.userDataDirectoryName),
nodecgBaseUrl: getNodecgBaseUrl(appConfig.nodecgPort),
mainDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.mainDashboardRoute),
loadingDashboardUrl: getDashboardUrl(appConfig.nodecgPort, appConfig.bundleName, appConfig.loadingDashboardRoute),
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 = {
getState: () => AppShutdownState;
-2
View File
@@ -9,7 +9,6 @@ export type AppRuntimeConfig = {
nodecgPort: string;
bundleName: string;
mainDashboardRoute: string;
loadingDashboardRoute: string;
loadDelayMs: number;
startupTimeoutMs: number;
nodecgKillTimeoutMs: number;
@@ -59,7 +58,6 @@ export function getRuntimeConfig(): AppRuntimeConfig {
nodecgPort: parseRequiredEnvPort("NODECG_PORT"),
bundleName: getRequiredEnv("NODECG_BUNDLE_NAME"),
mainDashboardRoute: getRequiredEnv("SCOREKO_DASHBOARD_ROUTE"),
loadingDashboardRoute: getRequiredEnv("SCOREKO_LOADING_ROUTE"),
loadDelayMs: parseRequiredEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 0, 600000),
startupTimeoutMs: parseRequiredEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 1000, 600000),
nodecgKillTimeoutMs: parseRequiredEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 0, 120000),
+1 -1
View File
@@ -6,7 +6,7 @@ export function log(...args: unknown[]): void {
logger.info("runtime", { args });
}
export function formatErrorMessage(error: unknown): string {
function formatErrorMessage(error: unknown): string {
if (error instanceof Error) {
const stack = error.stack?.trim();
return stack && stack.length > 0 ? stack : error.message;
+8 -11
View File
@@ -1,7 +1,14 @@
import electronLog from "electron-log";
export type LogLevel = "debug" | "info" | "warn" | "error";
type LogContext = Record<string, unknown>;
// Configure electron-log: write to file and (in dev) also to console.
electronLog.initialize();
electronLog.transports.file.level = "debug";
electronLog.transports.console.level = process.env["NODE_ENV"] === "development" ? "debug" : false;
function write(level: LogLevel, message: string, context?: LogContext): void {
const payload = {
ts: new Date().toISOString(),
@@ -13,17 +20,7 @@ function write(level: LogLevel, message: string, context?: LogContext): void {
const line = JSON.stringify(payload);
if (level === "error") {
console.error(line);
return;
}
if (level === "warn") {
console.warn(line);
return;
}
console.log(line);
electronLog[level](line);
}
export const logger = {
+1 -1
View File
@@ -54,7 +54,7 @@ export type NodecgProcessManager = {
getState: () => NodecgProcessState;
};
export type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
export function createNodecgProcessManager({
isDev,
+1 -1
View File
@@ -1,6 +1,6 @@
import { isRecord, readNonEmptyString } from "../utils/unknown-values";
export type GiteaReleaseAsset = {
type GiteaReleaseAsset = {
name: string;
browserDownloadUrl: string;
size?: number;
+28 -4
View File
@@ -1,4 +1,5 @@
import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron";
import electronLog from "electron-log";
import { AppRuntimeConfig } from "../config/runtime-config";
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
@@ -21,7 +22,7 @@ export function createMainWindow({
const windowOptions = createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: false });
const window = new BrowserWindow(windowOptions);
denyPermissionsByDefault(window);
applySecurityPolicies(window, allowDevTools);
window.setMenuBarVisibility(false);
window.webContents.setWindowOpenHandler(({ url }) => {
@@ -33,6 +34,12 @@ export function createMainWindow({
});
window.webContents.on("will-navigate", (event, url) => {
if (url.startsWith("app://open-logs")) {
event.preventDefault();
void shell.showItemInFolder(electronLog.transports.file.getFile().path);
return;
}
if (shouldAllowInternalNavigation(url, mainDashboardUrl)) {
return;
}
@@ -58,7 +65,7 @@ export function createLoadingWindow({
}: Omit<WindowServiceDependencies, "mainDashboardUrl">): BrowserWindow {
const window = new BrowserWindow(createWindowOptions({ allowDevTools, appConfig, rootPath, isLoadingWindow: true }));
denyPermissionsByDefault(window);
applySecurityPolicies(window, allowDevTools);
window.on("page-title-updated", (event) => {
event.preventDefault();
@@ -67,7 +74,7 @@ export function createLoadingWindow({
return window;
}
export function createWindowOptions({
function createWindowOptions({
allowDevTools,
appConfig,
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) => {
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();
});
}
}
-1
View File
@@ -37,7 +37,6 @@ test("getApplicationPaths keeps packaged root under Electron resources", () => {
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
},
appDataPath: "/users/test/AppData/Roaming",
compiledMainDir: "/app/dist/main",
+4 -7
View File
@@ -57,7 +57,6 @@ function getBaseConfig(): AppRuntimeConfig {
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 0,
startupTimeoutMs: 100,
nodecgKillTimeoutMs: 10,
@@ -90,8 +89,8 @@ test("ApplicationController preserves startup ordering and schedules updates aft
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html?standalone=true",
loadingDashboardUrl: "http://localhost:9090/bundles/scoreko-dev/dashboard/loading/main.html?standalone=true",
staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
};
const controller = createApplicationController({
@@ -143,7 +142,6 @@ test("ApplicationController preserves startup ordering and schedules updates aft
"create-manager",
"start-nodecg",
"wait-nodecg",
`loading:load:${paths.loadingDashboardUrl}`,
`main:load:${paths.mainDashboardUrl}`,
"main:show",
"loading:close",
@@ -164,8 +162,8 @@ test("ApplicationController directly launches packaged app after runtime install
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading",
staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
},
deps: {
createLoadingWindow: () => {
@@ -205,7 +203,6 @@ test("ApplicationController directly launches packaged app after runtime install
"create-manager",
"start-nodecg",
"wait-nodecg",
"loading:load:http://localhost:9090/loading",
"main:load:http://localhost:9090/main",
"main:show",
"loading:close",
@@ -226,8 +223,8 @@ test("ApplicationController activation before readiness routes through launch",
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading",
staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
},
deps: {
createLoadingWindow: () => new MockWindow("loading", events),
@@ -267,8 +264,8 @@ test("ApplicationController shutdown is idempotent", async () => {
userDataPath: "/user-data/scoreko",
nodecgBaseUrl: "http://127.0.0.1:9090",
mainDashboardUrl: "http://localhost:9090/main",
loadingDashboardUrl: "http://localhost:9090/loading",
staticLoadingHtmlPath: "/app/static/loading.html",
staticErrorHtmlPath: "/app/static/error.html",
},
deps: {
createLoadingWindow: () => new MockWindow("loading", events),
-1
View File
@@ -13,7 +13,6 @@ function getBaseConfig(): AppRuntimeConfig {
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 10000,
startupTimeoutMs: 30000,
nodecgKillTimeoutMs: 2500,
+1 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
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";
+1 -1
View File
@@ -3,7 +3,7 @@ import { EventEmitter } from "node:events";
import { SpawnOptions } from "node:child_process";
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", () => {
const spawnCalls: Array<{ command: string; args: string[]; options: SpawnOptions }> = [];
-1
View File
@@ -26,7 +26,6 @@ function getBaseConfig(): AppRuntimeConfig {
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 10000,
startupTimeoutMs: 100,
nodecgKillTimeoutMs: 10,
-3
View File
@@ -214,7 +214,6 @@ test("getRuntimeConfig throws if required variables are missing", () => {
NODECG_PORT: "9090",
NODECG_BUNDLE_NAME: "scoreko-dev",
SCOREKO_DASHBOARD_ROUTE: "dashboard/scoreko-dev/main.html?standalone=true",
SCOREKO_LOADING_ROUTE: "dashboard/loading/main.html?standalone=true",
ELECTRON_LOAD_DELAY_MS: "10000",
NODECG_STARTUP_TIMEOUT_MS: "120000",
NODECG_KILL_TIMEOUT_MS: "2500",
@@ -236,7 +235,6 @@ test("getRuntimeConfig parses successfully when all required variables are set",
NODECG_PORT: "9191",
NODECG_BUNDLE_NAME: "scoreko-dev-test",
SCOREKO_DASHBOARD_ROUTE: "dashboard/scoreko-dev/test.html",
SCOREKO_LOADING_ROUTE: "dashboard/loading/test.html",
ELECTRON_LOAD_DELAY_MS: "5000",
NODECG_STARTUP_TIMEOUT_MS: "60000",
NODECG_KILL_TIMEOUT_MS: "1500",
@@ -251,7 +249,6 @@ test("getRuntimeConfig parses successfully when all required variables are set",
assert.equal(config.nodecgPort, "9191");
assert.equal(config.bundleName, "scoreko-dev-test");
assert.equal(config.mainDashboardRoute, "dashboard/scoreko-dev/test.html");
assert.equal(config.loadingDashboardRoute, "dashboard/loading/test.html");
assert.equal(config.loadDelayMs, 5000);
assert.equal(config.startupTimeoutMs, 60000);
assert.equal(config.nodecgKillTimeoutMs, 1500);
+2 -2
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import { prepareUserNodecgRuntime } from "../main/nodecg/runtime-provisioner";
import { prepareUserNodecgRuntime } from "../main/nodecg/runtime-setup";
type FakeFsState = {
paths: Set<string>;
@@ -45,7 +45,7 @@ function createFakeFs(initialPaths: string[] = [], initialFiles: Record<string,
return normalized.endsWith("node_modules") || normalized.endsWith("bundles");
},
}),
symlinkSync: (target: string, linkPath: string, type: string) => {
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")) {
-1
View File
@@ -11,7 +11,6 @@ const baseConfig: AppRuntimeConfig = {
nodecgPort: "9090",
bundleName: "scoreko-dev",
mainDashboardRoute: "dashboard/scoreko-dev/main.html?standalone=true",
loadingDashboardRoute: "dashboard/loading/main.html?standalone=true",
loadDelayMs: 0,
startupTimeoutMs: 30000,
nodecgKillTimeoutMs: 2500,
+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>
+1
View File
@@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline';">
<title>Scoreko</title>
<style>
body {