feat: add error handling screen and logging functionality

This commit is contained in:
2026-06-04 14:09:27 +02:00
parent 6952a9954f
commit 982c771e82
16 changed files with 250 additions and 98 deletions
+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) });
});
});
+2
View File
@@ -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"),
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
export type AppShutdownState = "running" | "stopping" | "stopped";
type AppShutdownState = "running" | "stopping" | "stopped";
export type ShutdownService = {
getState: () => AppShutdownState;
+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;
+8 -1
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";
@@ -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,
+4 -2
View File
@@ -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),
+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 }> = [];
+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")) {