fix(about): handle prerelease tools in version check

Use semver comparison instead of string inequality so locally-ahead prerelease builds aren't misreported as outdated; backend now fetches the full npm dist-tags and, when the local version leads latest, surfaces the tool's prerelease channel (claude=next) as latest.
This commit is contained in:
Jason
2026-05-29 08:47:54 +08:00
parent 8302f1e36c
commit d7a34f4247
4 changed files with 357 additions and 30 deletions
+217 -19
View File
@@ -678,19 +678,25 @@ async fn get_single_tool_version_impl(
ShellProbe::NotFound(e) => (None, Some(e), false),
};
// 2. 获取远程最新版本
// 2. 获取远程最新版本npm 工具在本地领先 latest 时会按预发布通道补查,见
// fetch_npm_latest_for_tool / npm_prerelease_tags
let local = local_version.as_deref();
let latest_version = match tool {
"claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await,
"codex" => fetch_npm_latest_version(&client, "@openai/codex").await,
"gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await,
"claude" => {
fetch_npm_latest_for_tool(&client, "@anthropic-ai/claude-code", tool, local).await
}
"codex" => fetch_npm_latest_for_tool(&client, "@openai/codex", tool, local).await,
"gemini" => fetch_npm_latest_for_tool(&client, "@google/gemini-cli", tool, local).await,
"opencode" => {
if let Some(version) = fetch_npm_latest_version(&client, "opencode-ai").await {
if let Some(version) =
fetch_npm_latest_for_tool(&client, "opencode-ai", tool, local).await
{
Some(version)
} else {
fetch_github_latest_version(&client, "anomalyco/opencode").await
}
}
"openclaw" => fetch_npm_latest_version(&client, "openclaw").await,
"openclaw" => fetch_npm_latest_for_tool(&client, "openclaw", tool, local).await,
"hermes" => fetch_pypi_latest_version(&client, "hermes-agent").await,
_ => None,
};
@@ -706,22 +712,133 @@ async fn get_single_tool_version_impl(
}
}
/// Helper function to fetch latest version from npm registry
async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Option<String> {
let url = format!("https://registry.npmjs.org/{package}");
match client.get(&url).send().await {
Ok(resp) => {
if let Ok(json) = resp.json::<serde_json::Value>().await {
json.get("dist-tags")
.and_then(|tags| tags.get("latest"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
/// 该工具在 npm 上的预发布通道 tag(靠前者优先)。仅当本地版本已**严格领先**
/// `latest` 时才会被补查 —— 让主动在抢先通道的用户(如走 Claude Code 的 `next`)
/// 看到与所在通道对齐的"最新版本",同时绝不把稳定通道用户暴露给预发布版。
/// 返回空切片表示该工具只看 `latest`、不补查。
///
/// 为何不通用覆盖所有工具:各家预发布 tag 命名互不统一(codex=alpha/beta/native、
/// gemini=nightly/preview、openclaw=alpha/beta),且 codex 的 beta/native 是
/// `0.1.x` 时间戳式版本、gemini 有误发的 `false` tag —— 这些脏值虽会被
/// `pick_latest_version` 的版本比较挡掉,但维护成本与误报风险不值当,故暂只为
/// Claude Code 启用。
fn npm_prerelease_tags(tool: &str) -> &'static [&'static str] {
match tool {
"claude" => &["next"],
_ => &[],
}
}
/// 解析 "2.1.156" / "2.1.156-beta.1" → (主版本三段, 预发布段)。无法解析返回 None。
/// 与前端 `src/lib/version.ts` 的 parseVersion 语义对称(跨语言各实现一份)。
/// patch 用 u64 以容纳 codex 的 `0.1.2505172116` 时间戳式版本而不溢出。
fn parse_semver(v: &str) -> Option<([u64; 3], Vec<String>)> {
// 忽略 `+build` 元数据,再以首个 `-` 切出预发布段。
let core_and_pre = v.trim().split('+').next().unwrap_or("");
let (core, pre) = match core_and_pre.split_once('-') {
Some((c, p)) => (c, Some(p)),
None => (core_and_pre, None),
};
let mut parts = core.split('.');
let major = parts.next()?.parse::<u64>().ok()?;
let minor = parts.next()?.parse::<u64>().ok()?;
let patch = parts.next()?.parse::<u64>().ok()?;
if parts.next().is_some() {
return None; // 多于三段,非法
}
let pre_segments = pre
.map(|p| p.split('.').map(|s| s.to_string()).collect())
.unwrap_or_default();
Some(([major, minor, patch], pre_segments))
}
/// 比较两个版本号(遵循 semver:主版本三段优先;core 相等时有预发布 < 无预发布;
/// 预发布段逐段比 —— 数字段按数值、数字段 < 非数字段、非数字段按 ASCII、前缀相同
/// 则段更多者更大)。任一无法解析返回 None,调用方据此保守处理。
fn compare_semver(a: &str, b: &str) -> Option<std::cmp::Ordering> {
use std::cmp::Ordering;
let (ac, ap) = parse_semver(a)?;
let (bc, bp) = parse_semver(b)?;
for i in 0..3 {
match ac[i].cmp(&bc[i]) {
Ordering::Equal => continue,
other => return Some(other),
}
}
match (ap.is_empty(), bp.is_empty()) {
(true, true) => return Some(Ordering::Equal),
(true, false) => return Some(Ordering::Greater),
(false, true) => return Some(Ordering::Less),
(false, false) => {}
}
for (x, y) in ap.iter().zip(bp.iter()) {
let ord = match (x.parse::<u64>(), y.parse::<u64>()) {
(Ok(xv), Ok(yv)) => xv.cmp(&yv),
(Ok(_), Err(_)) => Ordering::Less, // 数字段 < 非数字段
(Err(_), Ok(_)) => Ordering::Greater,
(Err(_), Err(_)) => x.as_str().cmp(y.as_str()),
};
if ord != Ordering::Equal {
return Some(ord);
}
}
Some(ap.len().cmp(&bp.len()))
}
/// 从一次 registry 请求得到的完整 dist-tags 出发,挑选要展示的"最新版本"。
///
/// 规则:默认就是 `latest`;仅当本地版本已**严格领先** `latest`(说明用户主动在
/// 抢先通道)时,才把 `prerelease_tags` 指向的版本纳入比较,取其中能被解析、且
/// 高于 `latest` 的最高者。无法解析或不高于 latest 的脏 tag 一律落选。
fn pick_latest_version(
dist_tags: &serde_json::Map<String, serde_json::Value>,
prerelease_tags: &[&str],
local_version: Option<&str>,
) -> Option<String> {
use std::cmp::Ordering;
let latest = dist_tags.get("latest").and_then(|v| v.as_str())?;
// 本地是否严格领先 latest;任一无法解析则按"未领先"保守处理(只看 latest)。
let local_ahead = local_version
.and_then(|local| compare_semver(local, latest))
.map(|ord| ord == Ordering::Greater)
.unwrap_or(false);
if prerelease_tags.is_empty() || !local_ahead {
return Some(latest.to_string());
}
let mut best = latest.to_string();
for tag in prerelease_tags {
if let Some(candidate) = dist_tags.get(*tag).and_then(|v| v.as_str()) {
if compare_semver(candidate, &best) == Some(Ordering::Greater) {
best = candidate.to_string();
}
}
Err(_) => None,
}
Some(best)
}
/// 拉取 npm 包的完整 dist-tags(单次请求即含 latest/next/beta/...)。
async fn fetch_npm_dist_tags(
client: &reqwest::Client,
package: &str,
) -> Option<serde_json::Map<String, serde_json::Value>> {
let url = format!("https://registry.npmjs.org/{package}");
let resp = client.get(&url).send().await.ok()?;
let json = resp.json::<serde_json::Value>().await.ok()?;
json.get("dist-tags")?.as_object().cloned()
}
/// 查询某 npm 工具要展示的"最新版本":取 `latest`,并在本地版本领先时按工具的
/// 预发布通道(见 `npm_prerelease_tags`)补查 —— 复用同一次 registry 响应,无额外请求。
async fn fetch_npm_latest_for_tool(
client: &reqwest::Client,
package: &str,
tool: &str,
local_version: Option<&str>,
) -> Option<String> {
let dist_tags = fetch_npm_dist_tags(client, package).await?;
pick_latest_version(&dist_tags, npm_prerelease_tags(tool), local_version)
}
/// Helper function to fetch latest version from GitHub releases
@@ -3068,6 +3185,87 @@ mod tests {
assert_eq!(extract_version("no version here"), "no version here");
}
#[test]
fn test_compare_semver() {
use std::cmp::Ordering;
assert_eq!(
compare_semver("2.1.156", "2.1.154"),
Some(Ordering::Greater)
);
assert_eq!(compare_semver("2.1.154", "2.1.156"), Some(Ordering::Less));
assert_eq!(compare_semver("2.1.156", "2.1.156"), Some(Ordering::Equal));
// 预发布 < 同核心正式版
assert_eq!(
compare_semver("2.1.156-beta.1", "2.1.156"),
Some(Ordering::Less)
);
// core 更高的预发布仍高于较低的正式版(gemini nightly 场景)
assert_eq!(
compare_semver("0.45.0-nightly.1", "0.44.1"),
Some(Ordering::Greater)
);
// 大 patchcodex 时间戳式)不溢出
assert_eq!(
compare_semver("0.1.2505172116", "0.135.0"),
Some(Ordering::Less)
);
// 无法解析返回 Nonegemini 的 `false` 脏 tag
assert_eq!(compare_semver("false", "1.0.0"), None);
}
#[test]
fn test_pick_latest_version() {
use serde_json::json;
let tags = json!({
"latest": "2.1.154",
"next": "2.1.156",
"stable": "2.1.145"
});
let map = tags.as_object().unwrap();
// 本地领先 latest(在 next 通道)→ 补查到 next,数字对齐
assert_eq!(
pick_latest_version(map, &["next"], Some("2.1.156")),
Some("2.1.156".to_string())
);
// 本地等于 latest → 不补查,仍显示 latest
assert_eq!(
pick_latest_version(map, &["next"], Some("2.1.154")),
Some("2.1.154".to_string())
);
// 本地落后 latest(稳定通道用户)→ 不补查,不被推向预发布版
assert_eq!(
pick_latest_version(map, &["next"], Some("2.1.145")),
Some("2.1.154".to_string())
);
// 无预发布白名单 → 永远只看 latest(不解析 local,避免脏 local 触发)
assert_eq!(
pick_latest_version(map, &[], Some("2.1.156")),
Some("2.1.154".to_string())
);
// 本地版本未知 → 保守只看 latest
assert_eq!(
pick_latest_version(map, &["next"], None),
Some("2.1.154".to_string())
);
}
#[test]
fn test_pick_latest_version_filters_dirty_prerelease() {
use serde_json::json;
// 模拟 codexbeta 是低于 latest 的时间戳式脏版本
let tags = json!({
"latest": "0.135.0",
"beta": "0.1.2505172116"
});
let map = tags.as_object().unwrap();
// 即便本地领先 latest,低于 latest 的脏 beta 也不会被选
assert_eq!(
pick_latest_version(map, &["beta"], Some("0.200.0")),
Some("0.135.0".to_string())
);
}
/// `parent_dir` 是锚定层"由 bin 路径推导同目录绝对路径"的基石,跨平台共用——
/// 这里固化 `\`/`/`/混合分隔符/根边界四种情况,避免未来重构悄悄改语义。
mod parent_dir_cases {
+6 -11
View File
@@ -40,6 +40,7 @@ import { APP_ICON_MAP } from "@/config/appConfig";
import type { AppId } from "@/lib/api/types";
import { extractErrorMessage } from "@/utils/errorUtils";
import { isWindows } from "@/lib/platform";
import { isUpdateAvailable } from "@/lib/version";
import { ToolUpgradeConfirmDialog } from "./ToolUpgradeConfirmDialog";
import { ToolInstallRow } from "./ToolInstallRow";
@@ -238,11 +239,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
() =>
TOOL_NAMES.filter((toolName) => {
const tool = toolVersionByName.get(toolName);
return Boolean(
tool?.version &&
tool.latest_version &&
tool.version !== tool.latest_version,
);
return isUpdateAvailable(tool?.version, tool?.latest_version);
}),
[toolVersionByName],
);
@@ -545,8 +542,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
action === "update" &&
Boolean(previousVersion) &&
tool.version === previousVersion &&
Boolean(latestVersion) &&
tool.version !== latestVersion;
isUpdateAvailable(tool.version, latestVersion);
if (versionUnchangedAfterUpdate) {
// 有些上游 updater 会在未实际改动版本时仍返回 0。这里用刷新后的
@@ -949,10 +945,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
const displayName = TOOL_DISPLAY_NAMES[toolName];
const isToolVersionLoading =
isLoadingTools || Boolean(loadingTools[toolName]);
const isOutdated = Boolean(
tool?.version &&
tool.latest_version &&
tool.version !== tool.latest_version,
const isOutdated = isUpdateAvailable(
tool?.version,
tool?.latest_version,
);
// 已安装却跑不起来(如 Node 版本不达标):用它区分卡片文案与按钮,避免把
// "装了跑不起来"误判成"未安装"而给出无用的安装按钮(重装同一版本解决不了)。
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { compareVersions, isUpdateAvailable } from "./version";
describe("compareVersions", () => {
it("按主版本三段比较大小", () => {
expect(compareVersions("2.1.156", "2.1.154")).toBeGreaterThan(0);
expect(compareVersions("2.1.154", "2.1.156")).toBeLessThan(0);
expect(compareVersions("2.2.0", "2.1.999")).toBeGreaterThan(0);
expect(compareVersions("3.0.0", "2.9.9")).toBeGreaterThan(0);
expect(compareVersions("2.1.156", "2.1.156")).toBe(0);
});
it("预发布版低于同核心的正式版", () => {
expect(compareVersions("2.1.156-beta.1", "2.1.156")).toBeLessThan(0);
expect(compareVersions("2.1.156", "2.1.156-rc.1")).toBeGreaterThan(0);
});
it("预发布段之间:数字按数值、数字<非数字、段多者更大", () => {
expect(compareVersions("1.0.0-beta.2", "1.0.0-beta.11")).toBeLessThan(0);
expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBeLessThan(0);
expect(compareVersions("1.0.0-beta", "1.0.0-beta.1")).toBeLessThan(0);
});
it("无法解析时保守返回 0", () => {
expect(compareVersions("", "2.1.154")).toBe(0);
expect(compareVersions("unknown", "2.1.154")).toBe(0);
});
});
describe("isUpdateAvailable", () => {
it("仅当 latest 严格高于 current 才提示更新", () => {
expect(isUpdateAvailable("2.1.154", "2.1.156")).toBe(true);
});
it("抢先版反超 latest 时不提示更新(本地 156 > latest 154)", () => {
// 本场景的核心:Claude Code next 通道 156 高于 npm latest 154
expect(isUpdateAvailable("2.1.156", "2.1.154")).toBe(false);
});
it("版本相等不提示更新", () => {
expect(isUpdateAvailable("2.1.156", "2.1.156")).toBe(false);
});
it("缺少当前版本或最新版本时不提示更新", () => {
expect(isUpdateAvailable(undefined, "2.1.156")).toBe(false);
expect(isUpdateAvailable("2.1.156", null)).toBe(false);
expect(isUpdateAvailable("", "")).toBe(false);
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* 轻量 semver 比较 —— 用于工具版本"是否有可用更新"的判断。
*
* 为什么不直接用字符串 `!==` 比较:版本号是偏序关系,"不相等"不等于"落后"。
* 当本地装的是抢先/预发布通道(如 Claude Code 的 npm `next` tag,版本号反而
* 高于 `latest`)时,字符串 `!==` 会把"领先"误判成"需要更新",造成永久误报
* (永远提示可更新,点了升级又触发"版本未变"警告,提示消不掉)。
*/
interface ParsedVersion {
/** 主版本三段 [major, minor, patch] */
core: [number, number, number];
/** 预发布标识符段,如 "2.1.156-beta.1" → ["beta", "1"];正式版为空数组 */
pre: string[];
}
/** 解析 "2.1.156" / "2.1.156-beta.1";无法解析返回 null。 */
function parseVersion(v: string): ParsedVersion | null {
const m = v.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
if (!m) return null;
return {
core: [Number(m[1]), Number(m[2]), Number(m[3])],
pre: m[4] ? m[4].split(".") : [],
};
}
/**
* 比较预发布段(遵循 semver):
* - 双方都无预发布 → 相等
* - 有预发布的版本 < 没有的(正式版更高)
* - 都有则逐段比较:数字段按数值、数字段 < 非数字段、非数字段按 ASCII,
* 前缀相同则段更多的更大
*/
function comparePre(a: string[], b: string[]): number {
if (a.length === 0 && b.length === 0) return 0;
if (a.length === 0) return 1;
if (b.length === 0) return -1;
const len = Math.min(a.length, b.length);
for (let i = 0; i < len; i++) {
const ai = a[i];
const bi = b[i];
const aNum = /^\d+$/.test(ai);
const bNum = /^\d+$/.test(bi);
if (aNum && bNum) {
const d = Number(ai) - Number(bi);
if (d !== 0) return d < 0 ? -1 : 1;
} else if (aNum) {
return -1;
} else if (bNum) {
return 1;
} else if (ai !== bi) {
return ai < bi ? -1 : 1;
}
}
if (a.length === b.length) return 0;
return a.length < b.length ? -1 : 1;
}
/**
* 比较两个版本号。
* @returns >0 表示 a 比 b 新;<0 表示 a 比 b 旧;0 表示相等或无法判定。
* 任一无法解析时返回 0(保守:视为"无法判定差异",不触发更新提示)。
*/
export function compareVersions(a: string, b: string): number {
const pa = parseVersion(a);
const pb = parseVersion(b);
if (!pa || !pb) return 0;
for (let i = 0; i < 3; i++) {
const d = pa.core[i] - pb.core[i];
if (d !== 0) return d < 0 ? -1 : 1;
}
return comparePre(pa.pre, pb.pre);
}
/**
* 是否有可用更新:仅当 `latest` 严格高于 `current` 时为 true。
* 本地版本 ≥ latest(含抢先/预发布版反超 latest 的情况)一律返回 false。
*/
export function isUpdateAvailable(
current: string | null | undefined,
latest: string | null | undefined,
): boolean {
if (!current || !latest) return false;
return compareVersions(latest, current) > 0;
}