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:
parent
d37319c06f
commit
b6cdd2fc63
15 changed files with 669 additions and 51 deletions
|
|
@ -56,6 +56,9 @@ export default defineConfig(({ mode }) => {
|
|||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
esbuild: {
|
||||
keepNames: true,
|
||||
},
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [scopeBigPictureCss()],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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!",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Удиви меня",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
15
src/renderer/src/components/error-boundary/error-bus.ts
Normal file
15
src/renderer/src/components/error-boundary/error-bus.ts
Normal 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);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
113
src/renderer/src/components/error-boundary/error-fallback.tsx
Normal file
113
src/renderer/src/components/error-boundary/error-fallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
64
src/renderer/src/components/error-boundary/parse-stack.ts
Normal file
64
src/renderer/src/components/error-boundary/parse-stack.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue