Добавляет UpdateChecker (десктоп) и React ErrorBoundary
UpdateChecker: динамический импорт @tauri-apps/plugin-updater (no-op вне Tauri/на мобилке), диалог с прогрессом, downloadAndInstall + relaunch. ErrorBoundary оборачивает всё приложение — раньше необработанное исключение в рендере роняло весь SPA в белый экран без возможности восстановиться без ручной перезагрузки страницы. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+9
-3
@@ -18,6 +18,8 @@ import { NetrunnerMatrix } from "matrix-engine/NetrunnerMatrix";
|
||||
import { useSettingsStore } from "./store/useSettingsStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TelegramWeb } from "./pages/TelegramWeb";
|
||||
import { UpdateChecker } from "./features/UpdateChecker";
|
||||
import { ErrorBoundary } from "./features/ErrorBoundary";
|
||||
|
||||
function AppContent() {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -102,6 +104,8 @@ function AppContent() {
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
|
||||
<UpdateChecker />
|
||||
|
||||
<main className="flex-1 relative flex flex-col items-center justify-center overflow-hidden">
|
||||
<Routes>
|
||||
<Route path="/" element={isLoggedIn ? <Home /> : <Login />} />
|
||||
@@ -118,8 +122,10 @@ function AppContent() {
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<AppContent />
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { withTranslation, type WithTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props extends WithTranslation {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ловит непойманные ошибки рендера где угодно в дереве ниже — без неё любой
|
||||
* баг в компоненте (например, неожиданный ответ бэкенда, на который никто не
|
||||
* рассчитывал) валит всё приложение в белый экран без единого сообщения.
|
||||
* React error boundary обязан быть классовым компонентом — хуки этого не умеют.
|
||||
*/
|
||||
class ErrorBoundaryImpl extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("[ErrorBoundary] Непойманная ошибка рендера:", error, info.componentStack);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
this.setState({ error: null });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
const { t } = this.props;
|
||||
|
||||
if (!error) return this.props.children;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-background p-6 text-center">
|
||||
<h1 className="text-lg font-semibold">{t("error_boundary_title")}</h1>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
{t("error_boundary_desc")}
|
||||
</p>
|
||||
<Button onClick={this.handleReload}>{t("error_boundary_reload")}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorBoundary = withTranslation()(ErrorBoundaryImpl);
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Update } from "@tauri-apps/plugin-updater";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Status = "idle" | "downloading" | "error";
|
||||
|
||||
/**
|
||||
* Проверка обновлений — только десктоп (Windows/macOS/Linux). На мобильных
|
||||
* сборках плагин не зарегистрирован (см. src-tauri/src/lib.rs, #[cfg(desktop)]),
|
||||
* поэтому check() там просто недоступен — ошибка тихо проглатывается ниже,
|
||||
* без падения остального приложения.
|
||||
*/
|
||||
export function UpdateChecker() {
|
||||
const { t } = useTranslation();
|
||||
const [update, setUpdate] = useState<Update | null>(null);
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [progress, setProgress] = useState(0);
|
||||
const installing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const { check } = await import("@tauri-apps/plugin-updater");
|
||||
const result = await check();
|
||||
if (!cancelled && result?.available) {
|
||||
setUpdate(result);
|
||||
}
|
||||
} catch {
|
||||
// Мобильная сборка или плагин недоступен в этом окружении — не критично.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!update) return null;
|
||||
|
||||
const installUpdate = async () => {
|
||||
if (installing.current) return;
|
||||
installing.current = true;
|
||||
setStatus("downloading");
|
||||
try {
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
await update.downloadAndInstall((event) => {
|
||||
if (event.event === "Started") {
|
||||
total = event.data.contentLength ?? 0;
|
||||
} else if (event.event === "Progress") {
|
||||
downloaded += event.data.chunkLength;
|
||||
setProgress(total > 0 ? Math.round((downloaded / total) * 100) : 0);
|
||||
}
|
||||
});
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||
await relaunch();
|
||||
} catch (e) {
|
||||
console.error("[UpdateChecker] Установка обновления не удалась:", e);
|
||||
setStatus("error");
|
||||
installing.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={() => {}}>
|
||||
<DialogContent showCloseButton={status !== "downloading"}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("update_available_title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("update_available_desc", { version: update.version })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={installUpdate} disabled={status === "downloading"}>
|
||||
{status === "downloading"
|
||||
? t("update_downloading", { progress })
|
||||
: status === "error"
|
||||
? t("update_retry")
|
||||
: t("update_install_now")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user