* feat(updater): Beta 渠道接通 auto-update(Builder.endpoints runtime 切换)
CLAUDE.md 之前的论断「tauri-plugin-updater 2.10 Builder 不暴露 endpoints()」在
2.10.1 已不成立 —— Builder 现在有 .endpoints(Vec<Url>) -> Result<Self>,build()
里 self.endpoints.unwrap_or_else(|| self.config.endpoints.clone()) 走 runtime 覆盖。
实现 channel-aware 自动更新(不 fork plugin、不写自定义下载/签名校验):
Rust(~100 行新增)
- commands.rs::app_check_update_with_channel:
Stable → webview.updater_builder().build()?.check()(默认 endpoints 从 tauri.conf)
Beta → fetch_latest_beta_release() → 拼镜像 + 直连两份 -beta manifest URL
→ builder.endpoints(urls)?.build()?.check()
- AppUpdateMetadata 字段形状对齐 plugin 的 UpdateMetadata(rid + currentVersion
+ version + body + rawJson),前端 new Update(metadata) 即可复用 plugin 的
download / install / close。
- lib.rs 注册新 command。
前端(~30 行改动)
- AutoUpdate.tsx::checkForUpdates 从 import('@tauri-apps/plugin-updater').check
改为 invoke('app_check_update_with_channel');其余 download / install / close
全走 plugin 原生 JS API(new Update(metadata) 后链式调用)。
- SettingsModal::BetaChannelControl 删掉「打开 GitHub 下载」按钮 ——
auto-update 接管了,手动下载路径冗余;保留「最新 Beta 版本号 + 刷新」做信息透明。
- i18n 5 个 locale 改 betaChannelDesc 文案,把「需要手动下载安装」改成
「自动接收最新 Beta 版本」。betaChannelDownloadBtn 等 i18n key 暂留(无用但无害)。
物理隔离仍生效:Beta tag 的 manifest 文件名带 -beta 后缀,跟 Stable 的
latest-{tgt}-{arch}.json 在 GitHub Release assets 里是分开两份;即使代码逻辑
错把 Beta URL 给 Stable 用户也是 HTTP 404,不会拿到错档。
验证:cargo test --lib serial 270 / 0 / 0;tsc clean;vite build clean。
* feat(settings): BetaChannelControl 加版本比对 + 「立即更新」按钮
之前 BetaChannelControl 在 toggle 打开后,只机械显示「最新 Beta: vX-beta-tauri」,
不管本机版本是不是已经等于那个 tag。用户报告:「显示有最新 Beta,但应该比对版本号」。
修复:
- parseVersionFromBetaTag("v1.3.4-1-beta-tauri") → "1.3.4-1"
- semverGreater(remote, local) 做 SemVer 风格比对(支持 X.Y.Z 和 X.Y.Z-N,覆盖
本仓库实际版本格式;prerelease 按 SemVer 规则 lower than no-prerelease)
- 本机 >= 最新 → 「最新 Beta: vX」 + 蓝色「已是最新」chip
- 本机 < 最新 → 「最新 Beta: vX」 + 「立即更新」按钮(蓝色高亮)
- 「立即更新」点击 → useAutoUpdate.checkForUpdates → 走和全局「检查更新」按钮
完全相同的 download/install 流程,UpdateDialog 直接挂在 BetaChannelControl 内部
- 用户的第二个 feature 诉求:「在 Beta 渠道旁边一键检查 + 自动更新」 = 这个按钮
i18n 5 个 locale 加 4 个 key:betaChannelUpToDate / betaChannelUpdateNow /
betaChannelUpdateNowTitle / betaChannelChecking。
* chore(release): bump 1.3.4-1 → 1.3.4-2 for Beta channel
`-2` 后缀让 appVersion.ts 的 IS_BETA_BUILD 仍判为 Beta,UI BETA 标签照常显示。
配本 PR 的 Beta auto-update + BetaChannelControl「立即更新」按钮,1.3.4-1 用户
切到 Beta 渠道后点检查会发现 1.3.4-2 并走 auto-update 流程一键升上来。
发布 tag:v1.3.4-2-beta-tauri。
---------
Co-authored-by: baiqing <lbx12309@icloud.com>
239 lines
8.7 KiB
TypeScript
239 lines
8.7 KiB
TypeScript
// 自动更新共用模块 — Settings 的"关于"section 和 footer 按钮共用同一套
|
||
// 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。
|
||
//
|
||
// 渠道感知:check 不再走 plugin-updater 的 JS check()(它只看 tauri.conf 配的
|
||
// Stable manifest URL),改为 invoke('app_check_update_with_channel')。
|
||
// Rust 那边按 prefs.update_channel 决定 manifest URL;返回的 metadata 直接
|
||
// `new Update(metadata)` 复用 plugin 的 download / install / close 实现,
|
||
// 我们不重复造下载和签名校验。
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { invoke } from '@tauri-apps/api/core';
|
||
import type { DownloadEvent } from '@tauri-apps/plugin-updater';
|
||
import { Update } from '@tauri-apps/plugin-updater';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { isTauri, restartApp } from '../lib/ipc';
|
||
import { Btn } from '../pages/_atoms';
|
||
|
||
const UPDATE_CHECK_TIMEOUT_MS = 8_000; // 缩短超时,让镜像站慢的情况能更快 fallback
|
||
|
||
interface AppUpdateMetadata {
|
||
rid: number;
|
||
currentVersion: string;
|
||
version: string;
|
||
date?: string | null;
|
||
body?: string | null;
|
||
rawJson: Record<string, unknown>;
|
||
}
|
||
|
||
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;
|
||
/** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。 */
|
||
checkForUpdates: () => 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 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 () => {
|
||
setStatus('checking');
|
||
setVersion('');
|
||
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 invoke<AppUpdateMetadata | null>('app_check_update_with_channel', {
|
||
timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
|
||
});
|
||
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);
|
||
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);
|
||
await closeUpdate();
|
||
setStatus('error');
|
||
}
|
||
};
|
||
|
||
const dismissDialog = async () => {
|
||
if (busy) return;
|
||
await closeUpdate();
|
||
setStatus('idle');
|
||
setVersion('');
|
||
resetProgress();
|
||
};
|
||
|
||
return {
|
||
status,
|
||
version,
|
||
progress,
|
||
downloaded,
|
||
contentLength,
|
||
checking,
|
||
busy,
|
||
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: 16, 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: 999, 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`;
|
||
}
|