| import React, { useEffect, useState, useRef } from 'react'; |
| import { X, Sparkles, Loader2, CheckCircle, RotateCcw } from 'lucide-react'; |
| import { request as invoke } from '../utils/request'; |
| import { useTranslation } from 'react-i18next'; |
| import { check as tauriCheck } from '@tauri-apps/plugin-updater'; |
| import { relaunch as tauriRelaunch } from '@tauri-apps/plugin-process'; |
| import { isTauri } from '../utils/env'; |
| import { showToast } from './common/ToastContainer'; |
|
|
| interface UpdateInfo { |
| has_update: boolean; |
| latest_version: string; |
| current_version: string; |
| download_url: string; |
| source?: string; |
| } |
|
|
| type UpdateState = 'checking' | 'downloading' | 'ready' | 'error' | 'none'; |
|
|
| interface UpdateNotificationProps { |
| onClose: () => void; |
| } |
|
|
| export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onClose }) => { |
| const { t } = useTranslation(); |
| const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null); |
| const [isVisible, setIsVisible] = useState(false); |
| const [isClosing, setIsClosing] = useState(false); |
| const [updateState, setUpdateState] = useState<UpdateState>('checking'); |
| const [downloadProgress, setDownloadProgress] = useState(0); |
| const downloadStarted = useRef(false); |
|
|
| useEffect(() => { |
| checkAndDownload(); |
| }, []); |
|
|
| const checkAndDownload = async () => { |
| try { |
| |
| const info = await invoke<UpdateInfo>('check_for_updates'); |
| if (!info.has_update) { |
| onClose(); |
| return; |
| } |
|
|
| setUpdateInfo(info); |
|
|
| |
| if (!isTauri()) { |
| console.warn('Auto update is only available in Tauri environment'); |
| onClose(); |
| return; |
| } |
|
|
| |
| if (downloadStarted.current) return; |
| downloadStarted.current = true; |
|
|
| setUpdateState('downloading'); |
| setTimeout(() => setIsVisible(true), 100); |
|
|
| const update = await tauriCheck(); |
| if (!update) { |
| |
| console.warn('Native updater returned null'); |
| showToast(t('update_notification.toast.not_ready'), 'info'); |
| handleClose(); |
| return; |
| } |
|
|
| let downloaded = 0; |
| let contentLength = 0; |
|
|
| await update.downloadAndInstall((event) => { |
| switch (event.event) { |
| case 'Started': |
| contentLength = event.data.contentLength || 0; |
| break; |
| case 'Progress': |
| downloaded += event.data.chunkLength; |
| if (contentLength > 0) { |
| setDownloadProgress(Math.round((downloaded / contentLength) * 100)); |
| } |
| break; |
| case 'Finished': |
| break; |
| } |
| }); |
|
|
| |
| setUpdateState('ready'); |
| setDownloadProgress(100); |
| } catch (error) { |
| const errorMsg = error instanceof Error ? error.message : String(error); |
| console.error('Auto update failed:', errorMsg); |
| setUpdateState('error'); |
| showToast(`${t('update_notification.toast.failed')}: ${errorMsg}`, 'error'); |
| } |
| }; |
|
|
| const handleRestart = async () => { |
| try { |
| await tauriRelaunch(); |
| } catch (error) { |
| console.error('Relaunch failed:', error); |
| } |
| }; |
|
|
| const handleClose = () => { |
| setIsClosing(true); |
| setIsVisible(false); |
| setTimeout(onClose, 400); |
| }; |
|
|
| if (updateState === 'none') { |
| return null; |
| } |
|
|
| return ( |
| <div |
| className={` |
| fixed top-6 right-6 z-[100] |
| transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] |
| ${isVisible && !isClosing ? 'translate-y-0 opacity-100 scale-100' : '-translate-y-4 opacity-0 scale-95'} |
| `} |
| > |
| <div className=" |
| relative overflow-hidden |
| w-80 p-5 |
| rounded-2xl |
| border border-white/20 dark:border-white/10 |
| shadow-[0_8px_32px_0_rgba(31,38,135,0.15)] |
| backdrop-blur-xl |
| bg-white/70 dark:bg-slate-900/60 |
| group |
| "> |
| <div className="absolute -top-10 -right-10 w-32 h-32 bg-blue-500/20 rounded-full blur-3xl pointer-events-none group-hover:bg-blue-500/30 transition-colors duration-500" /> |
| <div className="absolute -bottom-10 -left-10 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl pointer-events-none group-hover:bg-purple-500/30 transition-colors duration-500" /> |
| |
| <div className="relative z-10"> |
| <div className="flex items-start justify-between mb-3"> |
| <div className="flex items-center gap-2"> |
| <div className="p-1.5 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 shadow-sm"> |
| {updateState === 'ready' ? ( |
| <CheckCircle className="w-4 h-4 text-white" /> |
| ) : ( |
| <Sparkles className="w-4 h-4 text-white" /> |
| )} |
| </div> |
| <div> |
| <h3 className="font-bold text-gray-800 dark:text-white leading-tight"> |
| {updateState === 'ready' |
| ? t('update_notification.ready') |
| : t('update_notification.title')} |
| </h3> |
| {updateInfo && ( |
| <p className="text-xs font-medium text-blue-600 dark:text-blue-400"> |
| v{updateInfo.latest_version} |
| </p> |
| )} |
| </div> |
| </div> |
| |
| {(updateState === 'error' || updateState === 'ready') && ( |
| <button |
| onClick={handleClose} |
| className=" |
| p-1 rounded-full |
| text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 |
| hover:bg-black/5 dark:hover:bg-white/10 |
| transition-all duration-200 |
| " |
| aria-label={t('common.cancel')} |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| )} |
| </div> |
| |
| {/* Status message */} |
| <div className="mb-4"> |
| <p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed"> |
| {updateState === 'downloading' && t('update_notification.downloading')} |
| {updateState === 'ready' && t('update_notification.restart_prompt')} |
| {updateState === 'error' && `${t('update_notification.toast.failed')}`} |
| </p> |
| </div> |
| |
| {/* Progress bar during download */} |
| {updateState === 'downloading' && ( |
| <div className="mb-4"> |
| <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"> |
| <div |
| className="bg-gradient-to-r from-blue-500 to-purple-600 h-2 rounded-full transition-all duration-300" |
| style={{ width: `${downloadProgress}%` }} |
| /> |
| </div> |
| <div className="flex items-center justify-between mt-1"> |
| <p className="text-xs text-gray-500">{downloadProgress}%</p> |
| <Loader2 className="w-3 h-3 animate-spin text-blue-500" /> |
| </div> |
| </div> |
| )} |
| |
| {/* Restart button when ready */} |
| {updateState === 'ready' && ( |
| <div className="flex gap-2"> |
| <button |
| onClick={handleRestart} |
| className=" |
| flex-1 group/btn |
| relative overflow-hidden |
| bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-500 hover:to-emerald-500 |
| text-white font-medium |
| py-2.5 px-4 rounded-xl |
| shadow-lg shadow-green-500/25 |
| transition-all duration-300 |
| flex items-center justify-center gap-2 |
| active:scale-[0.98] |
| " |
| > |
| <RotateCcw className="w-4 h-4" /> |
| <span>{t('update_notification.btn_restart')}</span> |
| <div className="absolute inset-0 -translate-x-full group-hover/btn:animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent z-20 pointer-events-none" /> |
| </button> |
| <button |
| onClick={handleClose} |
| className=" |
| px-3 py-2.5 rounded-xl |
| text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 |
| hover:bg-black/5 dark:hover:bg-white/10 |
| transition-all duration-200 |
| text-sm font-medium |
| " |
| > |
| {t('update_notification.btn_later')} |
| </button> |
| </div> |
| )} |
| |
| {/* Error state — retry button */} |
| {updateState === 'error' && ( |
| <button |
| onClick={() => { |
| downloadStarted.current = false; |
| setUpdateState('checking'); |
| setDownloadProgress(0); |
| checkAndDownload(); |
| }} |
| className=" |
| w-full |
| bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 |
| text-white font-medium |
| py-2.5 px-4 rounded-xl |
| shadow-lg shadow-blue-500/25 |
| transition-all duration-300 |
| flex items-center justify-center gap-2 |
| active:scale-[0.98] |
| " |
| > |
| <RotateCcw className="w-4 h-4" /> |
| <span>{t('common.retry')}</span> |
| </button> |
| )} |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|