• 更新了 Card 组件样式,采用新的变量来实现玻璃拟态效果和阴影。 • 修改了 Btn 组件中的按钮样式,以符合新的颜色令牌(tokens)。 • 调整了 Collapsible 组件样式,以获得更好的边框和背景一致性。 • 统一了各设置组件的边框圆角和背景颜色。 • 增强了 AboutSection、CheckUpdateButton 及其他板块,以利用新的设计令牌。 • 优化了应用外壳(app shell)和 aura 表面的全局样式,确保视觉效果的一致性。 • 引入了新的颜色令牌,以实现更好的主题管理和无障碍性。 • 更新了多个组件的 CSS,以反映设计系统的变更,包括按钮状态和背景。
240 lines
9.0 KiB
TypeScript
240 lines
9.0 KiB
TypeScript
// 自动更新共用模块 — 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`;
|
||
}
|