From d7a34f4247d0092dbdf1833e8a250f513f5f8910 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 29 May 2026 08:47:54 +0800 Subject: [PATCH] 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. --- src-tauri/src/commands/misc.rs | 236 +++++++++++++++++++++-- src/components/settings/AboutSection.tsx | 17 +- src/lib/version.test.ts | 49 +++++ src/lib/version.ts | 85 ++++++++ 4 files changed, 357 insertions(+), 30 deletions(-) create mode 100644 src/lib/version.test.ts create mode 100644 src/lib/version.ts diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index a66c3a1a9..0d4d8ac6e 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -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 { - let url = format!("https://registry.npmjs.org/{package}"); - match client.get(&url).send().await { - Ok(resp) => { - if let Ok(json) = resp.json::().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)> { + // 忽略 `+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::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().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 { + 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::(), y.parse::()) { + (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, + prerelease_tags: &[&str], + local_version: Option<&str>, +) -> Option { + 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> { + let url = format!("https://registry.npmjs.org/{package}"); + let resp = client.get(&url).send().await.ok()?; + let json = resp.json::().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 { + 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) + ); + // 大 patch(codex 时间戳式)不溢出 + assert_eq!( + compare_semver("0.1.2505172116", "0.135.0"), + Some(Ordering::Less) + ); + // 无法解析返回 None(gemini 的 `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; + // 模拟 codex:beta 是低于 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 { diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index 4dab949fd..5d6820f9a 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -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 版本不达标):用它区分卡片文案与按钮,避免把 // "装了跑不起来"误判成"未安装"而给出无用的安装按钮(重装同一版本解决不了)。 diff --git a/src/lib/version.test.ts b/src/lib/version.test.ts new file mode 100644 index 000000000..17f3bb433 --- /dev/null +++ b/src/lib/version.test.ts @@ -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); + }); +}); diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 000000000..e80d72700 --- /dev/null +++ b/src/lib/version.ts @@ -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; +}