Files
openless/openless-all/app/src/components/AutoUpdate.tsx
T
TRIPandbaiqing 859fd5370f feat(updater): Beta 渠道接通 auto-update(Builder.endpoints runtime 切换) (#465)
* 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>
2026-05-17 11:15:06 +08:00

239 lines
8.7 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),改为 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`;
}