/** * App auto-update prompt. * * Globally-mounted banner that surfaces the Tauri shell updater to the user. * The state machine, listeners, and auto-download orchestration all live in * `useAppUpdate`; this component is a thin presentational layer on top. * * UX contract: the banner is **silent during background download**. The user * only sees a prompt once bytes are staged (`ready_to_install`) — at which * point they can choose "Restart now" or "Later". Errors and the active * install/restart flow also surface visually. * * Visual conventions mirror `LocalAIDownloadSnackbar` — bottom-right portal, * stone-900 panel, primary gradient progress bar. */ import { useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useAppUpdate } from '../hooks/useAppUpdate'; import { useT } from '../lib/i18n/I18nContext'; import { formatBytes } from '../utils/localAiHelpers'; import Button from './ui/Button'; interface AppUpdatePromptProps { /** Override auto-check defaults (mostly for tests). */ autoCheck?: boolean; initialCheckDelayMs?: number; recheckIntervalMs?: number; autoDownload?: boolean; } /** * Phases that should surface a visible banner. Background-only phases * (`checking`, `available`, `downloading`) stay silent so the user isn't * pestered while we're working — the prompt only appears once the user * has a meaningful decision to make. */ function shouldShow(phase: ReturnType['phase']): boolean { return ( phase === 'ready_to_install' || phase === 'installing' || phase === 'restarting' || phase === 'error' ); } const AppUpdatePrompt = (props: AppUpdatePromptProps) => { const { t } = useT(); const { phase, info, bytesDownloaded, totalBytes, error, install, download, reset } = useAppUpdate(props); const [dismissed, setDismissed] = useState(false); const [prevPhase, setPrevPhase] = useState(phase); const dismissedErrorRef = useRef(null); const currentErrorKey = error ?? 'Update failed. See logs for details.'; // Re-show on every transition INTO a visible non-error phase, or when a new // error differs from the one the user already dismissed this session. if (phase !== prevPhase) { setPrevPhase(phase); if (shouldShow(phase) && !shouldShow(prevPhase)) { setDismissed(phase === 'error' && dismissedErrorRef.current === currentErrorKey); } } const handleInstall = useCallback(() => { void install(); }, [install]); const handleLater = useCallback(() => { setDismissed(true); }, []); const handleRetryDownload = useCallback(() => { dismissedErrorRef.current = null; setDismissed(false); reset(); void download(); }, [reset, download]); const handleDismissError = useCallback(() => { dismissedErrorRef.current = currentErrorKey; reset(); setDismissed(true); }, [currentErrorKey, reset]); if (!shouldShow(phase) || dismissed) return null; const newVersion = info?.available_version ?? null; const currentVersion = info?.current_version ?? null; const percent = totalBytes != null && totalBytes > 0 ? Math.min(100, Math.round((bytesDownloaded / totalBytes) * 100)) : null; return createPortal(
{/* Header */}
{headerLabel(phase, t)}
{(phase === 'ready_to_install' || phase === 'error') && ( )}
{/* Body */}
{phase === 'ready_to_install' && ( <>

{newVersion ? t('app.update.versionReady').replace('{version}', newVersion) : t('app.update.newVersionReady')} {currentVersion && ( {' '} {t('app.update.currentlyOn').replace('{version}', currentVersion)} )}

{info?.body && }

{t('app.update.restartNote')}

)} {(phase === 'installing' || phase === 'restarting') && ( <>
{progressDetail(phase, bytesDownloaded, totalBytes, percent, t)} {newVersion && v{newVersion}}
)} {phase === 'error' && ( <>

{error ?? t('app.update.errorFallback')}

)}
, document.body ); }; function headerLabel( phase: ReturnType['phase'], t: (k: string) => string ): string { switch (phase) { case 'ready_to_install': return t('app.update.header.readyToInstall'); case 'installing': return t('app.update.header.installing'); case 'restarting': return t('app.update.header.restarting'); case 'error': return t('app.update.header.error'); default: return t('app.update.header.default'); } } function progressDetail( phase: ReturnType['phase'], downloaded: number, total: number | null, percent: number | null, t: (k: string) => string ): string { if (phase === 'installing') return t('app.update.progress.installing'); if (phase === 'restarting') return t('app.update.progress.restarting'); if (total != null && total > 0) { return `${formatBytes(downloaded)} / ${formatBytes(total)}`; } if (downloaded > 0) return t('app.update.progress.downloaded').replace('{amount}', formatBytes(downloaded)); return percent != null ? `${percent}%` : t('app.update.progress.working'); } const ProgressBar = ({ indeterminate, percent, }: { indeterminate?: boolean; percent?: number | null; }) => { const indet = indeterminate || percent == null; return (
); }; const ReleaseNotes = ({ body }: { body: string }) => { const [expanded, setExpanded] = useState(false); const trimmed = body.trim(); if (!trimmed) return null; const isLong = trimmed.length > 160; const display = expanded || !isLong ? trimmed : `${trimmed.slice(0, 160).trimEnd()}…`; return (

{display}

{isLong && ( setExpanded(prev => !prev)} /> )}
); }; const ReleaseNotesToggle = ({ expanded, onToggle, }: { expanded: boolean; onToggle: () => void; }) => { const { t } = useT(); return ( ); }; const UpdateIcon = ({ className }: { className?: string }) => ( ); const CloseIcon = ({ className }: { className?: string }) => ( ); export default AppUpdatePrompt;