Добавляет 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 { useSettingsStore } from "./store/useSettingsStore";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { TelegramWeb } from "./pages/TelegramWeb";
|
import { TelegramWeb } from "./pages/TelegramWeb";
|
||||||
|
import { UpdateChecker } from "./features/UpdateChecker";
|
||||||
|
import { ErrorBoundary } from "./features/ErrorBoundary";
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
@@ -102,6 +104,8 @@ function AppContent() {
|
|||||||
onClose={() => setIsMenuOpen(false)}
|
onClose={() => setIsMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UpdateChecker />
|
||||||
|
|
||||||
<main className="flex-1 relative flex flex-col items-center justify-center overflow-hidden">
|
<main className="flex-1 relative flex flex-col items-center justify-center overflow-hidden">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={isLoggedIn ? <Home /> : <Login />} />
|
<Route path="/" element={isLoggedIn ? <Home /> : <Login />} />
|
||||||
@@ -118,8 +122,10 @@ function AppContent() {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ErrorBoundary>
|
||||||
<AppContent />
|
<ThemeProvider>
|
||||||
</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