// 自动更新共用模块 — Settings 的"关于"section 和 footer 按钮共用同一套 // 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。 // // 渠道感知:check 不再走 plugin-updater 的 JS check()(它只看 tauri.conf 配的 // Stable manifest URL),改为 appCheckUpdateWithChannel()(ipc 层按 // supportsAutoUpdate 在 Android 上 no-op)。 // Rust 那边按 prefs.update_channel 决定 manifest URL;返回的 metadata 直接 // `new Update(metadata)` 复用 plugin 的 download / install / close 实现, // 我们不重复造下载和签名校验。 import { useEffect, useRef, useState } from 'react'; import type { DownloadEvent } from '@tauri-apps/plugin-updater'; import { Update } from '@tauri-apps/plugin-updater'; import { useTranslation } from 'react-i18next'; import { appCheckUpdateWithChannel, isTauri, restartApp, type AppUpdateMetadata, type UpdateChannel } from '../lib/ipc'; import { Btn } from '../pages/_atoms'; const UPDATE_CHECK_TIMEOUT_MS = 15_000; export type UpdateStatus = | 'idle' | 'checking' | 'available' | 'none' | 'downloading' | 'installing' | 'downloaded' | 'error'; export interface UseAutoUpdate { status: UpdateStatus; version: string; progress: number | null; downloaded: number; contentLength: number | null; checking: boolean; busy: boolean; errorMessage: string | null; /** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 * `channel` 显式指定查哪个渠道;省略时由 Rust 端回落到 prefs.update_channel。 */ checkForUpdates: (channel?: UpdateChannel) => Promise; /** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */ installUpdate: () => Promise; /** 关闭对话框(仅在非 busy 状态可用)。 */ dismissDialog: () => Promise; } export function useAutoUpdate(): UseAutoUpdate { const updateRef = useRef(null); const [status, setStatus] = useState('idle'); const [version, setVersion] = useState(''); const [downloaded, setDownloaded] = useState(0); const [contentLength, setContentLength] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const checking = status === 'checking'; const busy = status === 'downloading' || status === 'installing'; const progress = contentLength && contentLength > 0 ? Math.min(100, Math.round((downloaded / contentLength) * 100)) : null; const closeUpdate = async () => { const current = updateRef.current; updateRef.current = null; if (current) { try { await current.close(); } catch (error) { console.warn('[updater] failed to close update resource', error); } } }; useEffect(() => { return () => { void closeUpdate(); }; }, []); const resetProgress = () => { setDownloaded(0); setContentLength(null); }; const checkForUpdates = async (channel?: UpdateChannel) => { setStatus('checking'); setVersion(''); setErrorMessage(null); resetProgress(); await closeUpdate(); try { if (!isTauri) { setStatus('none'); return; } // Rust 侧按 update_channel 拼 manifest URL:Stable → tauri.conf 默认; // Beta → fetch_latest_beta_release 拼出 -beta manifest URL 后再 check。 const metadata = await appCheckUpdateWithChannel( UPDATE_CHECK_TIMEOUT_MS, channel ?? null, ); if (!metadata) { setStatus('none'); return; } // metadata 形状跟 plugin 自己 check 返回的 UpdateMetadata 完全一致; // new Update(metadata) 直接复用 plugin 的 download/install/close 实现。 const next = new Update({ rid: metadata.rid, currentVersion: metadata.currentVersion, version: metadata.version, date: metadata.date ?? undefined, body: metadata.body ?? undefined, rawJson: metadata.rawJson, }); updateRef.current = next; setVersion(next.version); setStatus('available'); } catch (error) { console.error('[updater] failed to check update', error); const msg = error instanceof Error ? error.message : String(error); setErrorMessage(msg); setStatus('error'); } }; const installUpdate = async () => { const update = updateRef.current; if (!update) return; resetProgress(); setStatus('downloading'); try { await update.download((event: DownloadEvent) => { if (event.event === 'Started') { resetProgress(); setContentLength(event.data.contentLength ?? null); } else if (event.event === 'Progress') { setDownloaded(value => value + event.data.chunkLength); } else if (event.event === 'Finished') { setStatus('installing'); } }); setStatus('installing'); await update.install(); await closeUpdate(); setStatus('downloaded'); } catch (error) { console.error('[updater] failed to install update', error); const msg = error instanceof Error ? error.message : String(error); setErrorMessage(msg); await closeUpdate(); setStatus('error'); } }; const dismissDialog = async () => { if (busy) return; await closeUpdate(); setStatus('idle'); setVersion(''); resetProgress(); }; return { status, version, progress, downloaded, contentLength, checking, busy, errorMessage, checkForUpdates, installUpdate, dismissDialog, }; } export function isDialogStatus(status: UpdateStatus): status is 'available' | 'downloading' | 'installing' | 'downloaded' { return status === 'available' || status === 'downloading' || status === 'installing' || status === 'downloaded'; } export function UpdateDialog({ status, version, progress, downloaded, contentLength, onInstall, onClose, }: { status: 'available' | 'downloading' | 'installing' | 'downloaded'; version: string; progress: number | null; downloaded: number; contentLength: number | null; onInstall: () => void; onClose: () => void; }) { const { t } = useTranslation(); const downloading = status === 'downloading'; const installing = status === 'installing'; return (
{t(`settings.about.updateDialog.${status}.title`)}
{t(`settings.about.updateDialog.${status}.desc`, { version })}
{(downloading || installing || status === 'downloaded') && (
{installing ? t('settings.about.updateDialog.installingLabel') : progress === null ? t('settings.about.updateDialog.progressUnknown', { downloaded: formatBytes(downloaded) }) : t('settings.about.updateDialog.progress', { progress, downloaded: formatBytes(downloaded), total: formatBytes(contentLength ?? 0) })}
)}
{status === 'available' && {t('common.cancel')}} {status === 'available' && {t('settings.about.updateDialog.install')}} {(downloading || installing) && {installing ? t('settings.about.updateDialog.installingLabel') : t('settings.about.updateDialog.downloadingLabel')}} {status === 'downloaded' && {t('settings.about.updateDialog.later')}} {status === 'downloaded' && {t('settings.about.updateDialog.restartNow')}}
); } function formatBytes(value: number) { if (!Number.isFinite(value) || value <= 0) return '0 B'; if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; return `${(value / 1024 / 1024).toFixed(1)} MB`; }