has/src/components/UpdaterBanner.tsx

799 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef } from "react";
import { playTvBeep } from "../utils/tvAudio";
export type DetectedPlatform = "windows" | "android_tv" | "android_mobile" | "linux" | "macos" | "web";
interface UpdateInfo {
version: string;
body: string | null;
downloadUrl?: string;
platform?: string;
windowsUrl?: string | null;
tvApkUrl?: string | null;
mobileApkUrl?: string | null;
linuxUrl?: string | null;
macosUrl?: string | null;
}
interface UpdaterBannerProps {
isTvEnvironment?: boolean;
}
// Helper for Smart TV / Android TV detection
const detectTvEnvironment = (): boolean => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem("has_tv_mode");
if (saved !== null) return saved === "true";
const ua = (navigator.userAgent || "").toLowerCase();
const isTvUa =
ua.includes("android tv") ||
ua.includes("smarttv") ||
ua.includes("googletv") ||
ua.includes("tizen") ||
ua.includes("webos") ||
ua.includes("appletv") ||
ua.includes("crkey") ||
ua.includes("bravia") ||
ua.includes("aftt") ||
ua.includes("aftm") ||
ua.includes("mibox") ||
ua.includes("mi box") ||
ua.includes("shield") ||
ua.includes("dtv") ||
ua.includes("smart-tv") ||
ua.includes("hbbtv") ||
ua.includes("netcast") ||
ua.includes("viera") ||
ua.includes("roku") ||
ua.includes("firetv");
if (isTvUa) return true;
const isAndroid = ua.includes("android");
const noTouch = !("ontouchstart" in window) && navigator.maxTouchPoints === 0;
if (isAndroid && noTouch) return true;
return false;
};
// Detect client platform
const detectClientPlatform = (isTv: boolean, backendPlatform?: string): DetectedPlatform => {
if (isTv) return "android_tv";
if (backendPlatform === "android") return "android_mobile";
if (backendPlatform === "windows") return "windows";
if (backendPlatform === "macos") return "macos";
if (backendPlatform === "linux") return "linux";
const ua = (typeof navigator !== "undefined" ? navigator.userAgent : "").toLowerCase();
const plat = (typeof navigator !== "undefined" ? navigator.platform : "").toLowerCase();
if (ua.includes("android")) return isTv ? "android_tv" : "android_mobile";
if (plat.includes("win") || ua.includes("windows")) return "windows";
if (plat.includes("mac") || ua.includes("macintosh")) return "macos";
if (plat.includes("linux") || ua.includes("linux")) return "linux";
return "web";
};
// Check if a remote version is strictly newer than the current version
const isNewerSemver = (remote: string, current: string): boolean => {
const cleanRemote = remote.replace(/^v/i, "").trim();
const cleanCurrent = current.replace(/^v/i, "").trim();
if (cleanRemote === cleanCurrent) return false;
const rParts = cleanRemote.split(".").map((n) => parseInt(n, 10) || 0);
const cParts = cleanCurrent.split(".").map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(rParts.length, cParts.length); i++) {
const r = rParts[i] ?? 0;
const c = cParts[i] ?? 0;
if (r > c) return true;
if (r < c) return false;
}
return false;
};
export function UpdaterBanner({ isTvEnvironment }: UpdaterBannerProps = {}) {
const [isTv, setIsTv] = useState<boolean>(() => isTvEnvironment ?? detectTvEnvironment());
const [currentAppVersion, setCurrentAppVersion] = useState<string>("0.0.15");
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [downloading, setDownloading] = useState(false);
const [progress, setProgress] = useState(0);
const [statusText, setStatusText] = useState<string>("Загрузка обновления...");
const [error, setError] = useState<string | null>(null);
const [dismissed, setDismissed] = useState(false);
// Auto-update preferences for Android TV
const [autoUpdateTv, setAutoUpdateTv] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("has_auto_update_tv") === "true";
});
const [autoCountdown, setAutoCountdown] = useState<number | null>(null);
// TV remote navigation: "update" | "skip" | "exit" | "autotoggle"
const [tvFocusedBtn, setTvFocusedBtn] = useState<"update" | "skip" | "exit" | "autotoggle">("update");
const updateBtnRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (typeof isTvEnvironment === "boolean") {
setIsTv(isTvEnvironment);
}
}, [isTvEnvironment]);
// Read current running version from Tauri on startup
useEffect(() => {
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
import("@tauri-apps/api/app")
.then(({ getVersion }) => getVersion())
.then((ver) => {
if (ver) setCurrentAppVersion(ver);
})
.catch(() => {});
}
}, []);
// Check for updates
useEffect(() => {
let cancelled = false;
const checkForUpdates = async () => {
// 1. Try Rust backend command first (Works on Android TV, Mobile & Desktop without CORS limits)
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
try {
const { invoke } = await import("@tauri-apps/api/core");
const res = await invoke<any>("check_app_update", {
currentVersion: currentAppVersion,
});
if (!cancelled && res && res.has_update) {
setUpdateAvailable(true);
setUpdateInfo({
version: res.version,
body: res.body || "Новое обновление с улучшенной стабильностью и функциями.",
downloadUrl: res.download_url,
platform: res.platform,
windowsUrl: res.windows_url,
tvApkUrl: res.tv_apk_url,
mobileApkUrl: res.mobile_apk_url,
linuxUrl: res.linux_url,
macosUrl: res.macos_url,
});
setDismissed(false);
return;
}
} catch (err) {
console.warn("[Updater] Rust backend check failed, trying fallback:", err);
}
}
// 2. Desktop Tauri updater plugin (macOS / Windows / Linux)
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window) {
try {
const { check } = await import("@tauri-apps/plugin-updater");
const update = await check();
if (!cancelled && update?.available) {
setUpdateAvailable(true);
setUpdateInfo({
version: update.version,
body: update.body ?? null,
});
setDismissed(false);
return;
}
} catch {
// Expected on mobile / TV webview
}
}
// 3. GitHub API check fallback
try {
const resp = await fetch("https://api.github.com/repos/autli5/has/releases/latest", {
headers: { Accept: "application/vnd.github.v3+json" },
});
if (resp.ok) {
const data = await resp.json();
const remoteTag = (data?.tag_name || "").replace(/^v/i, "").trim();
if (!cancelled && remoteTag && isNewerSemver(remoteTag, currentAppVersion)) {
let tvApk: string | null = null;
let mobileApk: string | null = null;
let winUrl: string | null = null;
let linUrl: string | null = null;
let macUrl: string | null = null;
if (Array.isArray(data?.assets)) {
for (const asset of data.assets) {
const name: string = (asset.name || "").toLowerCase();
const url: string = asset.browser_download_url || "";
if ((name.includes("_tv_") || name.includes("-tv-") || name.includes(".tv.")) && name.endsWith(".apk")) {
tvApk = url;
} else if (name.endsWith(".apk") && !mobileApk) {
mobileApk = url;
} else if (name.endsWith(".exe") || name.endsWith(".msi")) {
if (!winUrl || name.endsWith(".exe")) winUrl = url;
} else if (name.endsWith(".deb") || name.endsWith(".appimage") || name.endsWith(".rpm")) {
if (!linUrl || name.endsWith(".deb")) linUrl = url;
} else if (name.endsWith(".dmg")) {
macUrl = url;
}
}
}
if (!tvApk && mobileApk) tvApk = mobileApk;
if (!mobileApk && tvApk) mobileApk = tvApk;
setUpdateAvailable(true);
setUpdateInfo({
version: remoteTag,
body: data.body || "Новое обновление с улучшенной стабильностью и функциями.",
downloadUrl: data.html_url || "https://github.com/autli5/has/releases/latest",
windowsUrl: winUrl,
tvApkUrl: tvApk,
mobileApkUrl: mobileApk,
linuxUrl: linUrl,
macosUrl: macUrl,
});
setDismissed(false);
}
}
} catch (err) {
console.warn("[Updater] Fallback fetch error:", err);
}
};
const timer = setTimeout(checkForUpdates, 1200);
const interval = setInterval(checkForUpdates, 90 * 1000);
const handleFocus = () => checkForUpdates();
window.addEventListener("focus", handleFocus);
window.addEventListener("has-check-update", handleFocus);
const handleSimulateUpdate = (e: any) => {
setUpdateAvailable(true);
setUpdateInfo({
version: e.detail?.version || "1.0.0",
body: e.detail?.body || "Вышло масштабное обновление программы.",
downloadUrl: "https://github.com/autli5/has/releases/latest",
});
setDismissed(false);
};
window.addEventListener("has-simulate-update", handleSimulateUpdate);
return () => {
cancelled = true;
clearTimeout(timer);
clearInterval(interval);
window.removeEventListener("focus", handleFocus);
window.removeEventListener("has-check-update", handleFocus);
window.removeEventListener("has-simulate-update", handleSimulateUpdate);
};
}, [currentAppVersion]);
// Determine current active platform
const clientPlatform = detectClientPlatform(isTv, updateInfo?.platform);
// Platform-tailored texts and assets
const getPlatformDetails = () => {
const version = updateInfo?.version || "1.0.0";
switch (clientPlatform) {
case "windows":
return {
badge: "🪟 Windows",
title: `Доступно обновление для Windows (v${version})`,
desc: "Вышла новая версия приложения для Windows. Нажмите «Обновить», чтобы установить последнюю версию программы.",
btnLabel: "🚀 Обновить для Windows (.exe)",
icon: "🪟",
};
case "android_tv":
return {
badge: "📺 Android TV",
title: "Хотите ли вы обновить приложение HAS?",
desc: "Доступна новая оптимизированная версия для телевизоров и ТВ-приставок с поддержкой пульта ДУ, балансеров видео и повышенной стабильностью.",
btnLabel: "🚀 Да, обновить сейчас",
icon: "📺",
};
case "android_mobile":
return {
badge: "📱 Android",
title: "Хотите ли вы обновить приложение HAS?",
desc: "Новая версия приложения для мобильных устройств на Android.",
btnLabel: "🚀 Скачать APK для Android",
icon: "📱",
};
case "linux":
return {
badge: "🐧 Linux",
title: `Доступно обновление для Linux (v${version})`,
desc: "Пакет обновления для дистрибутивов Linux (.deb / .AppImage / .rpm).",
btnLabel: "🚀 Скачать для Linux (.deb)",
icon: "🐧",
};
case "macos":
return {
badge: "🍏 macOS",
title: `Доступно обновление для macOS (v${version})`,
desc: "Новая версия приложения для macOS (.dmg).",
btnLabel: "🚀 Скачать образ (.dmg)",
icon: "🍏",
};
default:
return {
badge: "🚀 Обновление",
title: `Доступно обновление v${version}`,
desc: "Вышла новая версия программы с улучшенной стабильностью.",
btnLabel: "🚀 Обновить сейчас",
icon: "🚀",
};
}
};
const platformInfo = getPlatformDetails();
// Smart download target selector
const resolveTargetUrl = useCallback((): string => {
if (!updateInfo) return "https://github.com/autli5/has/releases/latest";
switch (clientPlatform) {
case "windows":
return updateInfo.windowsUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
case "android_tv":
return updateInfo.tvApkUrl || updateInfo.mobileApkUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
case "android_mobile":
return updateInfo.mobileApkUrl || updateInfo.tvApkUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
case "linux":
return updateInfo.linuxUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
case "macos":
return updateInfo.macosUrl || updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
default:
return updateInfo.downloadUrl || "https://github.com/autli5/has/releases/latest";
}
}, [clientPlatform, updateInfo]);
const cancelAutoCountdown = useCallback(() => {
setAutoCountdown(null);
}, []);
const toggleAutoUpdate = useCallback(() => {
setAutoUpdateTv((prev) => {
const next = !prev;
try {
localStorage.setItem("has_auto_update_tv", next ? "true" : "false");
} catch {}
return next;
});
cancelAutoCountdown();
}, [cancelAutoCountdown]);
const handleInstall = useCallback(async () => {
cancelAutoCountdown();
setDownloading(true);
setError(null);
setProgress(0);
setStatusText("Подготовка к установке обновления...");
// Desktop Windows / macOS / Linux Tauri updater plugin
if (typeof window !== "undefined" && "__TAURI_INTERNALS__" in window && !isTv) {
try {
const { check } = await import("@tauri-apps/plugin-updater");
const { relaunch } = await import("@tauri-apps/plugin-process");
const update = await check();
if (update?.available) {
let downloaded = 0;
let total = 0;
setStatusText("Загрузка обновления...");
await update.downloadAndInstall((event: any) => {
if (event.event === "Started") {
total = event.data?.contentLength ?? 0;
} else if (event.event === "Progress") {
downloaded += event.data?.chunkLength ?? 0;
} else if (event.event === "Finished") {
setProgress(100);
setStatusText("Перезапуск программы...");
}
if (total > 0) {
const p = Math.round((downloaded / total) * 100);
setProgress(p);
setStatusText(`Загрузка: ${p}%`);
}
});
await relaunch();
return;
}
} catch (e: unknown) {
console.warn("[Updater] Desktop plugin install skipped/fallback:", e);
}
}
// Android TV / Mobile / Direct APK Download & Installation
const targetUrl = resolveTargetUrl();
setStatusText("Подключение к серверу загрузки APK...");
let streamSucceeded = false;
try {
const response = await fetch(targetUrl, { mode: "cors" });
if (response.ok && response.body) {
const contentLength = +(response.headers.get("Content-Length") || 0);
const reader = response.body.getReader();
let received = 0;
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
received += value.length;
if (contentLength > 0) {
const pct = Math.min(99, Math.round((received / contentLength) * 100));
setProgress(pct);
setStatusText(`Скачивание APK: ${pct}% (${(received / 1024 / 1024).toFixed(1)} МБ)`);
} else {
setStatusText(`Скачивание APK: ${(received / 1024 / 1024).toFixed(1)} МБ`);
}
}
}
setProgress(100);
setStatusText("APK успешно скачан! Запуск установки...");
streamSucceeded = true;
const blob = new Blob(chunks as BlobPart[], { type: "application/vnd.android.package-archive" });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = `has-tv-v${updateInfo?.version || "update"}.apk`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
} catch {
// Fetch streaming can be restricted by CORS on certain hosts; fallback below
}
if (!streamSucceeded) {
let p = 0;
await new Promise<void>((resolve) => {
const progressTimer = setInterval(() => {
p += 20;
if (p >= 100) {
clearInterval(progressTimer);
setProgress(100);
setStatusText("Запуск установщика приложения...");
resolve();
} else {
setProgress(p);
setStatusText(`Загрузка: ${p}%`);
}
}, 120);
});
}
try {
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(targetUrl);
} catch {
window.open(targetUrl, "_blank");
}
setTimeout(() => {
setDismissed(true);
setDownloading(false);
}, 1800);
}, [cancelAutoCountdown, isTv, updateInfo, resolveTargetUrl]);
// Clean Exit from TV application
const handleExitApp = useCallback(async () => {
try {
const { exit } = await import("@tauri-apps/plugin-process");
await exit(0);
} catch {
window.close();
}
}, []);
// Auto-update countdown for Android TV
useEffect(() => {
if (!updateAvailable || dismissed || !isTv || downloading) return;
if (!autoUpdateTv) {
setAutoCountdown(null);
return;
}
setAutoCountdown(5);
const interval = setInterval(() => {
setAutoCountdown((prev) => {
if (prev === null) return null;
if (prev <= 1) {
clearInterval(interval);
handleInstall();
return null;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [updateAvailable, dismissed, isTv, downloading, autoUpdateTv, handleInstall]);
// Strict Remote Control D-Pad Navigation for TV Update Modal
useEffect(() => {
if (!updateAvailable || dismissed || !isTv || downloading) return;
// Auto-focus the "Update" button when TV modal appears
setTimeout(() => {
if (updateBtnRef.current) {
updateBtnRef.current.focus();
}
}, 50);
const handleTvKeyDown = (e: KeyboardEvent) => {
cancelAutoCountdown();
// Down: switch to auto-update toggle
if (e.key === "ArrowDown" || e.keyCode === 20) {
e.preventDefault();
playTvBeep(520, 0.025);
if (tvFocusedBtn !== "autotoggle") {
setTvFocusedBtn("autotoggle");
}
return;
}
// Up: switch back from auto-update toggle to update button
if (e.key === "ArrowUp" || e.keyCode === 19) {
e.preventDefault();
playTvBeep(520, 0.025);
if (tvFocusedBtn === "autotoggle") {
setTvFocusedBtn("update");
}
return;
}
// Left: cycle within top button row
if (e.key === "ArrowLeft" || e.keyCode === 21) {
e.preventDefault();
playTvBeep(520, 0.025);
setTvFocusedBtn((prev) => {
if (prev === "exit") return "skip";
if (prev === "skip") return "update";
return "exit";
});
return;
}
// Right / Tab: cycle within top button row
if (e.key === "ArrowRight" || e.keyCode === 22 || e.key === "Tab") {
e.preventDefault();
playTvBeep(520, 0.025);
setTvFocusedBtn((prev) => {
if (prev === "update") return "skip";
if (prev === "skip") return "exit";
return "update";
});
return;
}
// Enter / OK (KeyCode 13, 23, 66)
if (e.key === "Enter" || e.key === " " || e.keyCode === 13 || e.keyCode === 23 || e.keyCode === 66) {
e.preventDefault();
if (tvFocusedBtn === "update") {
playTvBeep(680, 0.05);
handleInstall();
} else if (tvFocusedBtn === "exit") {
playTvBeep(320, 0.06);
handleExitApp();
} else if (tvFocusedBtn === "autotoggle") {
playTvBeep(560, 0.04);
toggleAutoUpdate();
} else {
playTvBeep(420, 0.04);
setDismissed(true);
}
return;
}
// Back / Escape key triggers "skip"
if (e.key === "Escape" || e.keyCode === 27 || e.keyCode === 4) {
e.preventDefault();
playTvBeep(420, 0.04);
setDismissed(true);
}
};
window.addEventListener("keydown", handleTvKeyDown);
return () => window.removeEventListener("keydown", handleTvKeyDown);
}, [updateAvailable, dismissed, isTv, downloading, tvFocusedBtn, handleInstall, handleExitApp, cancelAutoCountdown, toggleAutoUpdate]);
if (!updateAvailable || dismissed) return null;
// =========================================================================
// 1. ANDROID TV: Full-screen Centered Modal with D-Pad TV Navigation & Exit
// =========================================================================
if (isTv) {
return (
<div className="has-tv-updater-overlay" role="dialog" aria-modal="true">
<div className="has-tv-updater-card">
<div className="has-tv-updater-icon-ring">{platformInfo.icon}</div>
<h2 className="has-tv-updater-title">Хотите ли вы обновить приложение HAS?</h2>
<div className="has-tv-updater-version-tag">
<span>📺 Android TV • v{currentAppVersion} ➔ v{updateInfo?.version || "новейшая"}</span>
</div>
<p className="has-tv-updater-desc">
{updateInfo?.body || platformInfo.desc}
</p>
{autoCountdown !== null && !downloading && (
<div className="has-tv-updater-countdown">
⚡ Автообновление активно: начнется через {autoCountdown} сек... (нажмите любую кнопку для отмены)
</div>
)}
{downloading ? (
<div className="has-tv-updater-progress-container">
<div className="has-tv-updater-progress-track">
<div
className="has-tv-updater-progress-bar"
style={{ width: `${progress}%` }}
/>
</div>
<span className="has-tv-updater-progress-text">
{statusText}
</span>
</div>
) : (
<>
<div className="has-tv-updater-buttons">
{/* Button 1: Да, обновить сейчас */}
<button
ref={updateBtnRef}
type="button"
className={`has-tv-updater-btn btn-update ${
tvFocusedBtn === "update" ? "focused" : ""
}`}
onClick={() => {
playTvBeep(680, 0.05);
cancelAutoCountdown();
handleInstall();
}}
onMouseEnter={() => {
cancelAutoCountdown();
setTvFocusedBtn("update");
}}
tabIndex={0}
data-tv-focusable="true"
>
<span>{platformInfo.btnLabel}</span>
</button>
{/* Button 2: Напомнить позже */}
<button
type="button"
className={`has-tv-updater-btn btn-skip ${
tvFocusedBtn === "skip" ? "focused" : ""
}`}
onClick={() => {
playTvBeep(420, 0.04);
cancelAutoCountdown();
setDismissed(true);
}}
onMouseEnter={() => {
cancelAutoCountdown();
setTvFocusedBtn("skip");
}}
tabIndex={0}
data-tv-focusable="true"
>
<span>Напомнить позже</span>
</button>
{/* Button 3: Выход из приложения (Exit) */}
<button
type="button"
className={`has-tv-updater-btn btn-exit ${
tvFocusedBtn === "exit" ? "focused" : ""
}`}
onClick={() => {
playTvBeep(320, 0.06);
cancelAutoCountdown();
handleExitApp();
}}
onMouseEnter={() => {
cancelAutoCountdown();
setTvFocusedBtn("exit");
}}
tabIndex={0}
data-tv-focusable="true"
title="Выйти из приложения"
>
<span>✕ Выход</span>
</button>
</div>
{/* Auto-update Toggle Chip for Android TV */}
<button
type="button"
className={`has-tv-updater-autotoggle ${autoUpdateTv ? "active" : ""} ${
tvFocusedBtn === "autotoggle" ? "focused" : ""
}`}
onClick={toggleAutoUpdate}
onMouseEnter={() => {
cancelAutoCountdown();
setTvFocusedBtn("autotoggle");
}}
tabIndex={0}
data-tv-focusable="true"
title="Переключить режим автоматического скачивания обновлений для ТВ"
>
{autoUpdateTv
? "🟢 Автообновление для TV: ВКЛЮЧЕНО (скачивать без запроса)"
: "⚪ Автообновление для TV: ВЫКЛЮЧЕНО (нажмите OK чтобы включить)"}
</button>
</>
)}
{error && <div className="updater-error" style={{ marginTop: 16 }}>{error}</div>}
<div className="has-tv-updater-remote-hint">
◄ ► Стрелки на пульте: выбор • ▲ ▼ Вверх/Вниз: переключение опций • OK: подтвердить • Back: позже
</div>
</div>
</div>
);
}
// =========================================================================
// 2. DESKTOP (WINDOWS / MAC / LINUX) & MOBILE: Smart Platform Banner
// =========================================================================
return (
<div className="updater-banner">
<div className="updater-banner-content">
<div className="updater-icon">{platformInfo.icon}</div>
<div className="updater-text">
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 2 }}>
<strong>{platformInfo.title}</strong>
<span
style={{
fontSize: 11,
padding: "2px 8px",
borderRadius: 6,
background: "rgba(99, 102, 241, 0.2)",
color: "#c7d2fe",
fontWeight: 700,
}}
>
{platformInfo.badge}
</span>
</div>
<span className="updater-notes">
{updateInfo?.body || platformInfo.desc}
</span>
</div>
<div className="updater-actions">
{downloading ? (
<div className="updater-progress-wrap">
<div className="updater-progress-bar">
<div
className="updater-progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<span className="updater-progress-label">{progress}%</span>
</div>
) : (
<>
<button
type="button"
className="updater-btn-install"
onClick={handleInstall}
>
{platformInfo.btnLabel}
</button>
<button
type="button"
className="updater-btn-dismiss"
onClick={() => setDismissed(true)}
>
Позже
</button>
</>
)}
</div>
</div>
{error && <div className="updater-error">{error}</div>}
</div>
);
}