Files
openless/openless-all/app/src/components/AutoUpdate.tsx
T
HKLHaoBin e330edef14 重构样式与组件以提高一致性并优化设计
• 更新了 Card 组件样式,采用新的变量来实现玻璃拟态效果和阴影。
• 修改了 Btn 组件中的按钮样式,以符合新的颜色令牌(tokens)。
• 调整了 Collapsible 组件样式,以获得更好的边框和背景一致性。
• 统一了各设置组件的边框圆角和背景颜色。
• 增强了 AboutSection、CheckUpdateButton 及其他板块,以利用新的设计令牌。
• 优化了应用外壳(app shell)和 aura 表面的全局样式,确保视觉效果的一致性。
• 引入了新的颜色令牌,以实现更好的主题管理和无障碍性。
• 更新了多个组件的 CSS,以反映设计系统的变更,包括按钮状态和背景。
2026-06-12 21:14:16 +08:00

240 lines
9.0 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.
// 自动更新共用模块 — 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<void>;
/** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */
installUpdate: () => Promise<void>;
/** 关闭对话框(仅在非 busy 状态可用)。 */
dismissDialog: () => Promise<void>;
}
export function useAutoUpdate(): UseAutoUpdate {
const updateRef = useRef<Update | null>(null);
const [status, setStatus] = useState<UpdateStatus>('idle');
const [version, setVersion] = useState('');
const [downloaded, setDownloaded] = useState(0);
const [contentLength, setContentLength] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.18)', display: 'grid', placeItems: 'center', zIndex: 40 }}>
<div style={{ width: 360, borderRadius: 'var(--ol-modal-radius)', background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: '0 18px 42px rgba(0,0,0,0.18)', padding: 18 }}>
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>{t(`settings.about.updateDialog.${status}.title`)}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14 }}>
{t(`settings.about.updateDialog.${status}.desc`, { version })}
</div>
{(downloading || installing || status === 'downloaded') && (
<div style={{ marginBottom: 14 }}>
<div style={{ height: 8, borderRadius: 'var(--ol-pill-radius)', background: 'var(--ol-surface-2)', overflow: 'hidden', border: '0.5px solid var(--ol-line)' }}>
<div style={{ height: '100%', width: `${status === 'downloaded' || installing ? 100 : progress ?? 8}%`, background: 'var(--ol-blue)', transition: 'width 0.18s var(--ol-motion-soft)' }} />
</div>
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--ol-ink-4)' }}>
{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) })}
</div>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
{status === 'available' && <Btn size="sm" onClick={onClose}>{t('common.cancel')}</Btn>}
{status === 'available' && <Btn variant="blue" size="sm" onClick={onInstall}>{t('settings.about.updateDialog.install')}</Btn>}
{(downloading || installing) && <Btn size="sm" disabled>{installing ? t('settings.about.updateDialog.installingLabel') : t('settings.about.updateDialog.downloadingLabel')}</Btn>}
{status === 'downloaded' && <Btn size="sm" onClick={onClose}>{t('settings.about.updateDialog.later')}</Btn>}
{status === 'downloaded' && <Btn variant="blue" size="sm" onClick={restartApp}>{t('settings.about.updateDialog.restartNow')}</Btn>}
</div>
</div>
</div>
);
}
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`;
}