mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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:
+217
-19
@@ -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)
|
||||
);
|
||||
// 大 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 {
|
||||
|
||||
@@ -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 版本不达标):用它区分卡片文案与按钮,避免把
|
||||
// "装了跑不起来"误判成"未安装"而给出无用的安装按钮(重装同一版本解决不了)。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user