diff --git a/package-lock.json b/package-lock.json index 4ebf4b9..59e6211 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0c5cc81..7aecb1c 100644 --- a/package.json +++ b/package.json @@ -99,17 +99,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" } } diff --git a/src/main/app/application-controller.ts b/src/main/app/application-controller.ts index e4ba01e..9380f21 100644 --- a/src/main/app/application-controller.ts +++ b/src/main/app/application-controller.ts @@ -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; + showErrorScreen: (error: unknown) => Promise; stopNodecgGracefully: () => Promise; }; @@ -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 => { + 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, }; } diff --git a/src/main/app/bootstrap.ts b/src/main/app/bootstrap.ts index c2df16e..e26ce2e 100644 --- a/src/main/app/bootstrap.ts +++ b/src/main/app/bootstrap.ts @@ -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) }); }); }); diff --git a/src/main/app/paths.ts b/src/main/app/paths.ts index 0303462..9508202 100644 --- a/src/main/app/paths.ts +++ b/src/main/app/paths.ts @@ -10,6 +10,7 @@ export type ApplicationPaths = { mainDashboardUrl: string; loadingDashboardUrl: string; staticLoadingHtmlPath: string; + staticErrorHtmlPath: string; }; export function getRootPath(isDev: boolean, compiledMainDir: string, resourcesPath: string): string { @@ -80,5 +81,6 @@ export function getApplicationPaths({ 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"), }; } diff --git a/src/main/app/shutdown-service.ts b/src/main/app/shutdown-service.ts index 1ec253c..ea6f901 100644 --- a/src/main/app/shutdown-service.ts +++ b/src/main/app/shutdown-service.ts @@ -1,4 +1,4 @@ -export type AppShutdownState = "running" | "stopping" | "stopped"; +type AppShutdownState = "running" | "stopping" | "stopped"; export type ShutdownService = { getState: () => AppShutdownState; diff --git a/src/main/errors/error-handler.ts b/src/main/errors/error-handler.ts index feeadfd..af30963 100644 --- a/src/main/errors/error-handler.ts +++ b/src/main/errors/error-handler.ts @@ -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; diff --git a/src/main/logging/logger.ts b/src/main/logging/logger.ts index dce9c65..3f7a5bd 100644 --- a/src/main/logging/logger.ts +++ b/src/main/logging/logger.ts @@ -1,7 +1,14 @@ +import electronLog from "electron-log"; + export type LogLevel = "debug" | "info" | "warn" | "error"; type LogContext = Record; +// 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 = { diff --git a/src/main/nodecg/process-manager.ts b/src/main/nodecg/process-manager.ts index 9465333..c02a436 100644 --- a/src/main/nodecg/process-manager.ts +++ b/src/main/nodecg/process-manager.ts @@ -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, diff --git a/src/main/updates/update-schema.ts b/src/main/updates/update-schema.ts index da8878f..906a118 100644 --- a/src/main/updates/update-schema.ts +++ b/src/main/updates/update-schema.ts @@ -1,6 +1,6 @@ import { isRecord, readNonEmptyString } from "../utils/unknown-values"; -export type GiteaReleaseAsset = { +type GiteaReleaseAsset = { name: string; browserDownloadUrl: string; size?: number; diff --git a/src/main/windows/window-service.ts b/src/main/windows/window-service.ts index 7834ca6..241d121 100644 --- a/src/main/windows/window-service.ts +++ b/src/main/windows/window-service.ts @@ -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"; @@ -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; } @@ -67,7 +74,7 @@ export function createLoadingWindow({ return window; } -export function createWindowOptions({ +function createWindowOptions({ allowDevTools, appConfig, rootPath, diff --git a/src/tests/application-controller.test.ts b/src/tests/application-controller.test.ts index ef1b586..a68998e 100644 --- a/src/tests/application-controller.test.ts +++ b/src/tests/application-controller.test.ts @@ -92,6 +92,7 @@ test("ApplicationController preserves startup ordering and schedules updates aft 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 +144,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", @@ -166,6 +166,7 @@ test("ApplicationController directly launches packaged app after runtime install mainDashboardUrl: "http://localhost:9090/main", loadingDashboardUrl: "http://localhost:9090/loading", staticLoadingHtmlPath: "/app/static/loading.html", + staticErrorHtmlPath: "/app/static/error.html", }, deps: { createLoadingWindow: () => { @@ -205,7 +206,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", @@ -228,6 +228,7 @@ test("ApplicationController activation before readiness routes through launch", 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), @@ -269,6 +270,7 @@ test("ApplicationController shutdown is idempotent", async () => { 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), diff --git a/src/tests/navigation-policy.test.ts b/src/tests/navigation-policy.test.ts index 91e3235..4b721b7 100644 --- a/src/tests/navigation-policy.test.ts +++ b/src/tests/navigation-policy.test.ts @@ -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"; diff --git a/src/tests/platform-process-killer.test.ts b/src/tests/platform-process-killer.test.ts index 8581168..e71bf7d 100644 --- a/src/tests/platform-process-killer.test.ts +++ b/src/tests/platform-process-killer.test.ts @@ -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 }> = []; diff --git a/src/tests/runtime-provisioner.test.ts b/src/tests/runtime-provisioner.test.ts index 3675d92..bebdf7e 100644 --- a/src/tests/runtime-provisioner.test.ts +++ b/src/tests/runtime-provisioner.test.ts @@ -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; @@ -45,7 +45,7 @@ function createFakeFs(initialPaths: string[] = [], initialFiles: Record { + 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")) { diff --git a/static/error.html b/static/error.html new file mode 100644 index 0000000..b32e17a --- /dev/null +++ b/static/error.html @@ -0,0 +1,174 @@ + + + + + + Scoreko - Error al iniciar + + + +
+
+ + + + + + +

Scoreko no pudo iniciar

+ +
Se produjo un error inesperado al iniciar el servidor interno.
+ +

Revisa los logs para más detalles o cierra y vuelve a abrir la aplicación.

+ +
+ + +
+
+
+ + + +