Skip to main content

j_cli/util/
version_check.rs

1use crate::constants;
2use serde::Deserialize;
3use std::fs;
4use std::path::PathBuf;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7/// 版本检查缓存结构
8#[derive(Debug, Deserialize)]
9struct VersionCache {
10    /// 最后检查时间(Unix 时间戳,秒)
11    last_check: u64,
12    /// 最新版本号
13    latest_version: String,
14    /// 当前版本号(用于判断是否需要重新检查)
15    current_version: String,
16}
17
18/// 获取版本缓存文件路径
19fn cache_file_path() -> PathBuf {
20    crate::config::YamlConfig::data_dir().join(constants::VERSION_CHECK_CACHE_FILE)
21}
22
23/// 获取当前 Unix 时间戳(秒)
24fn current_timestamp() -> u64 {
25    SystemTime::now()
26        .duration_since(UNIX_EPOCH)
27        .unwrap_or_default()
28        .as_secs()
29}
30
31/// 比较语义化版本号,返回 true 表示 latest > current
32fn is_newer_version(current: &str, latest: &str) -> bool {
33    let parse_version = |v: &str| -> Vec<u32> {
34        let v = v.trim_start_matches('v');
35        v.split('.')
36            .filter_map(|s| s.parse().ok())
37            .collect()
38    };
39
40    let current_parts = parse_version(current);
41    let latest_parts = parse_version(latest);
42
43    for i in 0..std::cmp::max(current_parts.len(), latest_parts.len()) {
44        let c = current_parts.get(i).unwrap_or(&0);
45        let l = latest_parts.get(i).unwrap_or(&0);
46        if l > c {
47            return true;
48        }
49        if l < c {
50            return false;
51        }
52    }
53    false
54}
55
56/// 读取缓存
57fn read_cache() -> Option<VersionCache> {
58    let path = cache_file_path();
59    if !path.exists() {
60        return None;
61    }
62
63    let content = fs::read_to_string(&path).ok()?;
64    serde_json::from_str(&content).ok()
65}
66
67/// 判断缓存是否需要刷新
68fn cache_needs_refresh() -> bool {
69    let current_version = constants::VERSION;
70    let now = current_timestamp();
71
72    match read_cache() {
73        Some(c) => {
74            c.current_version != current_version
75                || now.saturating_sub(c.last_check) >= constants::VERSION_CHECK_INTERVAL_SECS
76        }
77        None => true,
78    }
79}
80
81/// 【阶段1:即时检查】从缓存中读取是否有新版本,不涉及网络,立即返回
82/// 返回 Some(latest_version) 表示有更新可用
83pub fn check_cached() -> Option<String> {
84    let current_version = constants::VERSION;
85    let cache = read_cache()?;
86
87    if is_newer_version(current_version, &cache.latest_version) {
88        Some(cache.latest_version)
89    } else {
90        None
91    }
92}
93
94/// 【阶段2:后台刷新】生成临时脚本并 fork 独立子进程静默刷新缓存
95/// 子进程完全独立于主进程,主进程退出后子进程仍能完成网络请求
96/// 下次运行 check_cached() 时就能读到新的版本信息
97pub fn refresh_cache_in_background() {
98    if !cache_needs_refresh() {
99        return;
100    }
101
102    let cache_path = cache_file_path();
103    let current_version = constants::VERSION;
104    let url = constants::GITHUB_RELEASES_API;
105
106    // 确保缓存文件的父目录存在
107    if let Some(parent) = cache_path.parent() {
108        let _ = fs::create_dir_all(parent);
109    }
110
111    // 生成临时脚本文件
112    let script_path = cache_path.with_extension("sh");
113    let script_content = build_check_script(url, current_version, &cache_path.to_string_lossy());
114
115    if fs::write(&script_path, &script_content).is_err() {
116        return;
117    }
118
119    // 用 nohup 在后台 fork 独立子进程执行脚本,主进程退出不影响
120    let _ = std::process::Command::new("/bin/sh")
121        .arg("-c")
122        .arg(format!(
123            "nohup /bin/sh \"{}\" >/dev/null 2>&1 &",
124            script_path.to_string_lossy()
125        ))
126        .stdout(std::process::Stdio::null())
127        .stderr(std::process::Stdio::null())
128        .spawn();
129}
130
131/// 构建版本检查 shell 脚本内容
132fn build_check_script(url: &str, current_version: &str, cache_path: &str) -> String {
133    format!(
134        r##"
135            #!/bin/sh
136            # 版本检查脚本(由 j-cli 自动生成,执行后自动删除)
137            RESPONSE=$(curl -s -S -L --connect-timeout 3 --max-time 8 \
138              -H "Accept: application/vnd.github.v3+json" \
139              -H "User-Agent: j-cli" \
140              "{url}")
141            
142            if [ $? -ne 0 ]; then
143              rm -f "$0"
144              exit 0
145            fi
146            
147            TAG=$(echo "$RESPONSE" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' | sed 's/^v//')
148            
149            if [ -n "$TAG" ]; then
150              NOW=$(date +%s)
151              cat > "{cache_path}" << CACHE_EOF
152            {{
153              "last_check": $NOW,
154              "latest_version": "$TAG",
155              "current_version": "{current_version}"
156            }}
157            CACHE_EOF
158            fi
159            
160            # 清理临时脚本自身
161            rm -f "$0"
162        "##,
163        url = url,
164        current_version = current_version,
165        cache_path = cache_path,
166    )
167}
168
169/// 【一步完成】检查缓存 + 后台刷新 + 如果有更新则打印提示
170#[allow(dead_code)]
171pub fn check_and_hint() {
172    if let Some(latest_version) = check_cached() {
173        print_update_hint(&latest_version);
174    }
175    refresh_cache_in_background();
176}
177
178/// 打印新版本提示
179pub fn print_update_hint(latest_version: &str) {
180    eprintln!();
181    eprintln!("┌─────────────────────────────────────────────────────────┐");
182    eprintln!("│  🎉 有新版本可用!                                        │");
183    eprintln!("│                                                         │");
184    eprintln!("│  当前版本: {:<43}│", constants::VERSION);
185    eprintln!("│  最新版本: {:<43}│", latest_version);
186    eprintln!("│                                                         │");
187    eprintln!("│  更新方式:                                               │");
188    eprintln!("│    cargo install j-cli                                  │");
189    eprintln!("│    或访问: https://github.com/{}/releases │", constants::GITHUB_REPO);
190    eprintln!("└─────────────────────────────────────────────────────────┘");
191    eprintln!();
192}