feat: replace gray screen with error screen on renderer crashes

Add a top-level error boundary so an unhandled render error shows the
actual error (message, origin file and line, stack, component trace)
with a restart-to-home button, instead of a blank gray screen.

Add a non-blocking overlay plus window listeners for async and
event-handler errors, and log everything through electron-log. Keep
function names in the production build so stack traces stay readable
over OTA.
This commit is contained in:
Moyasee 2026-07-06 08:51:41 +03:00
parent d37319c06f
commit b6cdd2fc63
15 changed files with 669 additions and 51 deletions

View file

@ -56,6 +56,9 @@ export default defineConfig(({ mode }) => {
build: {
sourcemap: true,
},
esbuild: {
keepNames: true,
},
css: {
postcss: {
plugins: [scopeBigPictureCss()],

View file

@ -1,7 +1,17 @@
{
"language_name": "English",
"app": {
"successfully_signed_in": "Successfully signed in"
"successfully_signed_in": "Successfully signed in",
"error_boundary_title": "Something went wrong",
"error_boundary_description": "The app ran into an unexpected error. You can reload to keep using Hydra.",
"error_boundary_copy_error": "Copy error",
"error_boundary_copied": "Copied",
"error_overlay_title": "Unexpected error",
"error_overlay_dismiss": "Dismiss",
"error_overlay_details": "Details",
"error_boundary_origin": "Origin",
"error_boundary_component_trace": "Component trace",
"error_boundary_restart": "Restart"
},
"home": {
"surprise_me": "Surprise me",

View file

@ -1,7 +1,17 @@
{
"language_name": "Español",
"app": {
"successfully_signed_in": "Iniciaste sesión exitosamente"
"successfully_signed_in": "Iniciaste sesión exitosamente",
"error_boundary_title": "Algo salió mal",
"error_boundary_description": "La aplicación encontró un error inesperado. Puedes recargar para seguir usando Hydra.",
"error_boundary_copy_error": "Copiar error",
"error_boundary_copied": "Copiado",
"error_overlay_title": "Error inesperado",
"error_overlay_dismiss": "Descartar",
"error_overlay_details": "Detalles",
"error_boundary_origin": "Origen",
"error_boundary_component_trace": "Rastreo de componentes",
"error_boundary_restart": "Reiniciar"
},
"home": {
"surprise_me": "¡Sorpréndeme!",

View file

@ -1,7 +1,17 @@
{
"language_name": "Português (Brasil)",
"app": {
"successfully_signed_in": "Autenticado com sucesso"
"successfully_signed_in": "Autenticado com sucesso",
"error_boundary_title": "Algo deu errado",
"error_boundary_description": "O aplicativo encontrou um erro inesperado. Você pode recarregar para continuar usando o Hydra.",
"error_boundary_copy_error": "Copiar erro",
"error_boundary_copied": "Copiado",
"error_overlay_title": "Erro inesperado",
"error_overlay_dismiss": "Dispensar",
"error_overlay_details": "Detalhes",
"error_boundary_origin": "Origem",
"error_boundary_component_trace": "Rastreamento de componentes",
"error_boundary_restart": "Reiniciar"
},
"home": {
"hot": "Populares",
@ -615,15 +625,6 @@
"no_collection": "Sem coleção",
"game_collection_updated": "Coleção atualizada",
"failed_update_game_collection": "Falha ao atualizar coleção",
"details": "Detalhes",
"platform": "Plataforma",
"genres": "Gêneros",
"regions": "Regiões",
"region_us": "EUA",
"region_eu": "Europa",
"region_jp": "Japão",
"region_kr": "Coreia",
"region_asia": "Ásia",
"download_error_invalid_magnet": "Link magnet inválido.",
"download_error_torrent_metadata_timeout": "Tempo esgotado ao buscar metadados do torrent. Por favor, tente novamente.",
"download_error_torrent_metadata_incomplete": "Os metadados do torrent estão incompletos e não podem ser processados agora.",

View file

@ -1,7 +1,17 @@
{
"language_name": "Português (Portugal)",
"app": {
"successfully_signed_in": "Sessão iniciada com sucesso"
"successfully_signed_in": "Sessão iniciada com sucesso",
"error_boundary_title": "Algo correu mal",
"error_boundary_description": "A aplicação encontrou um erro inesperado. Pode recarregar para continuar a usar o Hydra.",
"error_boundary_copy_error": "Copiar erro",
"error_boundary_copied": "Copiado",
"error_overlay_title": "Erro inesperado",
"error_overlay_dismiss": "Dispensar",
"error_overlay_details": "Detalhes",
"error_boundary_origin": "Origem",
"error_boundary_component_trace": "Rastreio de componentes",
"error_boundary_restart": "Reiniciar"
},
"home": {
"hot": "Populares",

View file

@ -1,7 +1,17 @@
{
"language_name": "Русский",
"app": {
"successfully_signed_in": "Успешный вход"
"successfully_signed_in": "Успешный вход",
"error_boundary_title": "Что-то пошло не так",
"error_boundary_description": "В приложении произошла непредвиденная ошибка. Перезагрузите, чтобы продолжить пользоваться Hydra.",
"error_boundary_copy_error": "Скопировать ошибку",
"error_boundary_copied": "Скопировано",
"error_overlay_title": "Непредвиденная ошибка",
"error_overlay_dismiss": "Закрыть",
"error_overlay_details": "Подробнее",
"error_boundary_origin": "Источник",
"error_boundary_component_trace": "Трассировка компонентов",
"error_boundary_restart": "Перезапустить"
},
"home": {
"surprise_me": "Удиви меня",

View file

@ -0,0 +1,45 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { logger } from "../../logger";
import { errorBus } from "./error-bus";
import { ErrorFallback } from "./error-fallback";
export interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
error: Error | null;
componentStack?: string;
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
logger.error("Unhandled render error", error, info.componentStack);
errorBus.notifyBoundaryHandled(error.message);
this.setState({ componentStack: info.componentStack ?? undefined });
}
render() {
const { error, componentStack } = this.state;
if (error) {
return <ErrorFallback error={error} componentStack={componentStack} />;
}
return this.props.children;
}
}

View file

@ -0,0 +1,15 @@
type BoundaryListener = (message: string) => void;
const listeners = new Set<BoundaryListener>();
export const errorBus = {
notifyBoundaryHandled(message: string) {
listeners.forEach((listener) => listener(message));
},
onBoundaryHandled(listener: BoundaryListener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};

View file

@ -0,0 +1,99 @@
@use "../../scss/globals.scss";
.error-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: calc(globals.$spacing-unit * 4);
background: linear-gradient(
0deg,
globals.$dark-background-color 50%,
globals.$background-color 100%
);
overflow-y: auto;
&__content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 640px;
width: 100%;
gap: calc(globals.$spacing-unit * 2);
}
&__icon {
color: globals.$error-color;
}
&__title {
font-size: 24px;
color: globals.$muted-color;
}
&__description {
color: globals.$body-color;
}
&__message {
width: 100%;
color: globals.$error-color;
font-family: monospace;
font-size: globals.$body-font-size;
word-break: break-word;
}
&__origin {
width: 100%;
font-family: monospace;
font-size: globals.$small-font-size;
color: globals.$muted-color;
word-break: break-word;
user-select: text;
}
&__origin-label {
color: globals.$warning-color;
margin-right: globals.$spacing-unit;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__component-trace {
width: 100%;
summary {
cursor: pointer;
color: globals.$muted-color;
opacity: 0.7;
font-size: globals.$small-font-size;
text-align: left;
margin-bottom: globals.$spacing-unit;
}
}
&__stack {
width: 100%;
max-height: 240px;
overflow: auto;
margin: 0;
padding: calc(globals.$spacing-unit * 2);
text-align: left;
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 8px;
font-size: globals.$small-font-size;
color: globals.$body-color;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
margin-top: globals.$spacing-unit;
}
}

View file

@ -0,0 +1,113 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertIcon, SyncIcon } from "@primer/octicons-react";
import { Button } from "../button/button";
import { formatOrigin, getErrorOrigin } from "./parse-stack";
import "./error-fallback.scss";
export interface ErrorFallbackProps {
error: unknown;
componentStack?: string;
}
const getErrorMessage = (error: unknown) => {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try {
return JSON.stringify(error);
} catch {
return String(error);
}
};
const getErrorStack = (error: unknown) =>
error instanceof Error ? (error.stack ?? "") : "";
export function ErrorFallback({
error,
componentStack,
}: Readonly<ErrorFallbackProps>) {
const { t } = useTranslation("app");
const [copied, setCopied] = useState(false);
const message = getErrorMessage(error);
const stack = getErrorStack(error);
const origin = getErrorOrigin(stack);
const handleRestart = () => {
window.location.hash = "#/";
window.location.reload();
};
const handleCopy = () => {
const payload = [
message,
origin ? `Origin: ${formatOrigin(origin)}` : "",
stack,
componentStack ? `Component trace:${componentStack}` : "",
]
.filter(Boolean)
.join("\n\n");
navigator.clipboard.writeText(payload).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="error-fallback">
<div className="error-fallback__content">
<AlertIcon size={48} className="error-fallback__icon" />
<h1 className="error-fallback__title">{t("error_boundary_title")}</h1>
<p className="error-fallback__description">
{t("error_boundary_description")}
</p>
<p className="error-fallback__message">{message}</p>
{origin && (
<p className="error-fallback__origin">
<span className="error-fallback__origin-label">
{t("error_boundary_origin")}
</span>
{formatOrigin(origin)}
</p>
)}
{stack && (
<pre className="error-fallback__stack">
<code>{stack}</code>
</pre>
)}
{componentStack && (
<details className="error-fallback__component-trace">
<summary>{t("error_boundary_component_trace")}</summary>
<pre className="error-fallback__stack">
<code>{componentStack.trim()}</code>
</pre>
</details>
)}
<div className="error-fallback__actions">
<Button theme="primary" onClick={handleRestart}>
<SyncIcon />
{t("error_boundary_restart")}
</Button>
<Button theme="outline" onClick={handleCopy}>
{copied
? t("error_boundary_copied")
: t("error_boundary_copy_error")}
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,94 @@
@use "../../scss/globals.scss";
.global-error-overlay {
position: fixed;
bottom: calc(globals.$spacing-unit * 2);
right: calc(globals.$spacing-unit * 2);
z-index: 200;
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
max-width: 420px;
width: 100%;
pointer-events: none;
&__card {
pointer-events: auto;
background-color: globals.$dark-background-color;
border: 1px solid globals.$error-color;
border-radius: 8px;
padding: calc(globals.$spacing-unit * 1.5);
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
&__header {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__icon {
color: globals.$error-color;
flex-shrink: 0;
}
&__title {
color: globals.$muted-color;
font-weight: 700;
font-size: globals.$body-font-size;
}
&__dismiss {
margin-left: auto;
color: globals.$body-color;
cursor: pointer;
display: flex;
align-items: center;
&:hover {
color: globals.$muted-color;
}
}
&__message {
color: globals.$body-color;
font-family: monospace;
font-size: globals.$small-font-size;
word-break: break-word;
user-select: text;
}
&__origin {
color: globals.$warning-color;
font-family: monospace;
font-size: globals.$small-font-size;
word-break: break-word;
user-select: text;
}
&__details {
summary {
cursor: pointer;
color: globals.$muted-color;
opacity: 0.7;
font-size: globals.$small-font-size;
}
}
&__stack {
margin: globals.$spacing-unit 0 0;
max-height: 180px;
overflow: auto;
padding: globals.$spacing-unit;
background-color: globals.$background-color;
border-radius: 4px;
font-size: globals.$small-font-size;
color: globals.$body-color;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
}
}

View file

@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertIcon, XIcon } from "@primer/octicons-react";
import { errorBus } from "./error-bus";
import { formatOrigin, getErrorOrigin } from "./parse-stack";
import "./global-error-overlay.scss";
interface CapturedError {
id: number;
message: string;
stack: string;
origin: string;
}
const MAX_VISIBLE = 3;
const getMessage = (value: unknown) => {
if (value instanceof Error) return value.message;
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const getStack = (value: unknown) =>
value instanceof Error && value.stack ? value.stack : "";
export function GlobalErrorOverlay() {
const { t } = useTranslation("app");
const [errors, setErrors] = useState<CapturedError[]>([]);
const idRef = useRef(0);
const claimedRef = useRef<Set<string>>(new Set());
useEffect(() => {
const pushError = (value: unknown) => {
const message = getMessage(value);
if (!message || claimedRef.current.has(message)) return;
idRef.current += 1;
const stack = getStack(value);
const origin = getErrorOrigin(stack);
setErrors((prev) =>
[
...prev,
{
id: idRef.current,
message,
stack,
origin: origin ? formatOrigin(origin) : "",
},
].slice(-MAX_VISIBLE)
);
};
const onError = (event: ErrorEvent) =>
pushError(event.error ?? event.message);
const onRejection = (event: PromiseRejectionEvent) =>
pushError(event.reason);
const unsubscribe = errorBus.onBoundaryHandled((message) => {
claimedRef.current.add(message);
setErrors((prev) => prev.filter((error) => error.message !== message));
setTimeout(() => claimedRef.current.delete(message), 1000);
});
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
return () => {
unsubscribe();
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
};
}, []);
const dismiss = (id: number) => {
setErrors((prev) => prev.filter((error) => error.id !== id));
};
if (errors.length === 0) return null;
return (
<div className="global-error-overlay">
{errors.map((error) => (
<div key={error.id} className="global-error-overlay__card">
<div className="global-error-overlay__header">
<AlertIcon size={16} className="global-error-overlay__icon" />
<span className="global-error-overlay__title">
{t("error_overlay_title")}
</span>
<button
type="button"
className="global-error-overlay__dismiss"
onClick={() => dismiss(error.id)}
aria-label={t("error_overlay_dismiss")}
>
<XIcon size={14} />
</button>
</div>
<p className="global-error-overlay__message">{error.message}</p>
{error.origin && (
<p className="global-error-overlay__origin">{error.origin}</p>
)}
{error.stack && (
<details className="global-error-overlay__details">
<summary>{t("error_overlay_details")}</summary>
<pre className="global-error-overlay__stack">
<code>{error.stack}</code>
</pre>
</details>
)}
</div>
))}
</div>
);
}

View file

@ -0,0 +1,64 @@
export interface StackOrigin {
functionName: string | null;
file: string;
line: number;
column: number;
}
const FRAME_RE = /at\s+(?:(.*?)\s+)?\(?([^\s()]+):(\d+):(\d+)\)?\s*$/;
const IGNORED_FILE_HINTS = [
"/node_modules/",
"/@fs/",
"/@vite/",
"/@react-refresh",
"chunk-",
"react-dom",
"react-jsx",
];
const cleanFile = (raw: string) => {
let file = raw;
try {
if (file.includes("://")) file = new URL(file).pathname;
} catch {
/* keep raw value */
}
file = file.split("?")[0];
const srcIndex = file.indexOf("/src/");
if (srcIndex >= 0) return file.slice(srcIndex + 1);
return file.replace(/^.*\//, "");
};
export const getErrorOrigin = (stack?: string): StackOrigin | null => {
if (!stack) return null;
for (const line of stack.split("\n")) {
const match = FRAME_RE.exec(line.trim());
if (!match) continue;
const [, functionName, rawFile, lineNo, columnNo] = match;
if (IGNORED_FILE_HINTS.some((hint) => rawFile.includes(hint))) continue;
return {
functionName: functionName || null,
file: cleanFile(rawFile),
line: Number(lineNo),
column: Number(columnNo),
};
}
return null;
};
export const formatOrigin = (origin: StackOrigin) => {
const location = `${origin.file}:${origin.line}:${origin.column}`;
return origin.functionName
? `${location} (${origin.functionName})`
: location;
};

View file

@ -27,3 +27,6 @@ export * from "./progress-bar/progress-bar";
export * from "./classics-onboarding-modal/classics-onboarding-modal";
export * from "./classics-scan-indicator/classics-scan-indicator";
export * from "./classics-spinner/classics-spinner";
export * from "./error-boundary/error-fallback";
export * from "./error-boundary/error-boundary";
export * from "./error-boundary/global-error-overlay";

View file

@ -22,6 +22,8 @@ import resources from "@locales";
import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies";
import * as Sentry from "@sentry/react";
import { ErrorBoundary } from "./components/error-boundary/error-boundary";
import { GlobalErrorOverlay } from "./components/error-boundary/global-error-overlay";
import { levelDBService } from "./services/leveldb.service";
import Catalogue from "./pages/catalogue/catalogue";
import Home from "./pages/home/home";
@ -63,6 +65,14 @@ Sentry.init({
release: "hydra-launcher@" + (await globalThis.electron.getVersion()),
});
window.addEventListener("unhandledrejection", (event) => {
logger.error("Unhandled promise rejection", event.reason);
});
window.addEventListener("error", (event) => {
logger.error("Uncaught error", event.error ?? event.message);
});
const isStaging = await globalThis.electron.isStaging();
addCookieInterceptor(isStaging);
@ -112,46 +122,52 @@ globalThis.electron.onUserPreferencesUpdated((preferences) => {
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<HashRouter>
<AchievementNotificationOverlay />
<Routes>
<Route element={<App />}>
<Route path="/" element={<Home />} />
<Route path="/catalogue" element={<Catalogue />} />
<Route path="/library" element={<Library />} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} />
<Route path="/notifications" element={<Notifications />} />
</Route>
<ErrorBoundary>
<HashRouter>
<AchievementNotificationOverlay />
<Routes>
<Route element={<App />}>
<Route path="/" element={<Home />} />
<Route path="/catalogue" element={<Catalogue />} />
<Route path="/library" element={<Library />} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} />
<Route path="/notifications" element={<Notifications />} />
</Route>
<Route path="/theme-editor" element={<ThemeEditor />} />
<Route
path="/achievement-notification"
element={<AchievementNotification />}
/>
<Route path="/game-launcher" element={<GameLauncher />} />
<Route path="/friends-window" element={<FriendsWindow />} />
<Route path="/auth-window" element={<AuthWindow />} />
<Route path="/big-picture" element={<BigPictureApp />}>
<Route index element={<BigPictureHome />} />
<Route path="catalogue" element={<BigPictureCatalogue />} />
<Route path="component-lab" element={<BigPictureComponentLab />} />
<Route path="downloads" element={<BigPictureDownloads />} />
<Route path="settings" element={<BigPictureSettings />} />
<Route path="library" element={<BigPictureLibrary />} />
<Route path="profile/:userId?" element={<BigPictureProfile />} />
<Route path="game/:shop/:objectId" element={<BigPictureGame />} />
<Route path="/theme-editor" element={<ThemeEditor />} />
<Route
path="game/:shop/:objectId/achievements"
element={<BigPictureGameAchievements />}
path="/achievement-notification"
element={<AchievementNotification />}
/>
</Route>
</Routes>
</HashRouter>
<Route path="/game-launcher" element={<GameLauncher />} />
<Route path="/friends-window" element={<FriendsWindow />} />
<Route path="/auth-window" element={<AuthWindow />} />
<Route path="/big-picture" element={<BigPictureApp />}>
<Route index element={<BigPictureHome />} />
<Route path="catalogue" element={<BigPictureCatalogue />} />
<Route
path="component-lab"
element={<BigPictureComponentLab />}
/>
<Route path="downloads" element={<BigPictureDownloads />} />
<Route path="settings" element={<BigPictureSettings />} />
<Route path="library" element={<BigPictureLibrary />} />
<Route path="profile/:userId?" element={<BigPictureProfile />} />
<Route path="game/:shop/:objectId" element={<BigPictureGame />} />
<Route
path="game/:shop/:objectId/achievements"
element={<BigPictureGameAchievements />}
/>
</Route>
</Routes>
</HashRouter>
</ErrorBoundary>
<GlobalErrorOverlay />
</Provider>
</React.StrictMode>
);