Добавляет 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:
2026-07-09 16:36:39 +07:00
parent 24f3e5f454
commit b8e1e925b3
3 changed files with 156 additions and 3 deletions
+9 -3
View File
@@ -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>
);
}
+53
View File
@@ -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);
+94
View File
@@ -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>
);
}