Skip to main content

sparrow/
update.rs

1// ─── Self-update — check, notify, and install updates ─────────────────────────
2//
3// Checks GitHub releases and crates.io for newer versions.
4// Exposes check_update() for background checks and self_update() for one-click.
5// Emits UpdateAvailable events for WebView and TUI consumption.
6
7use serde::{Deserialize, Serialize};
8
9// ─── Update info ───────────────────────────────────────────────────────────────
10
11/// Structured update information for all surfaces.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct UpdateInfo {
14    pub current: String,
15    pub latest: String,
16    pub is_newer: bool,
17    pub download_url: Option<String>,
18    pub crate_url: String,
19    pub release_url: String,
20    pub install_cmd: String,
21}
22
23impl std::fmt::Display for UpdateInfo {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        // Status lines just want: "v0.6.2 → v0.7.1".
26        write!(f, "v{} → v{}", self.current, self.latest)
27    }
28}
29
30impl UpdateInfo {
31    pub fn up_to_date(current: &str) -> Self {
32        UpdateInfo {
33            current: current.to_string(),
34            latest: current.to_string(),
35            is_newer: false,
36            download_url: None,
37            crate_url: format!("https://crates.io/crates/sparrow-cli"),
38            release_url: format!("https://github.com/ucav/Sparrow/releases"),
39            install_cmd: "cargo install sparrow-cli".to_string(),
40        }
41    }
42}
43
44const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
45const GITHUB_API: &str = "https://api.github.com/repos/ucav/Sparrow/releases/latest";
46const CRATES_API: &str = "https://crates.io/api/v1/crates/sparrow-cli";
47
48// ─── Public API ────────────────────────────────────────────────────────────────
49
50/// Check for updates (blocking — call in background for UI).
51/// Returns Some(UpdateInfo) if a newer version is available.
52pub fn check_update() -> Option<UpdateInfo> {
53    // Try GitHub first (fastest, includes binary download URLs)
54    if let Some(info) = check_github() {
55        if info.is_newer {
56            return Some(info);
57        }
58    }
59
60    // Fallback to crates.io
61    if let Some(info) = check_cratesio() {
62        if info.is_newer {
63            return Some(info);
64        }
65    }
66
67    None
68}
69
70/// Perform self-update by downloading and replacing the current binary.
71/// Only works when running from a release binary (not cargo run).
72pub fn self_update() -> anyhow::Result<String> {
73    let current = CURRENT_VERSION;
74
75    // Find latest version
76    let latest = match check_github() {
77        Some(info) if info.is_newer => info.latest,
78        _ => match check_cratesio() {
79            Some(info) if info.is_newer => info.latest,
80            _ => return Ok(format!("Already up to date (v{}). 🐦", current)),
81        },
82    };
83
84    let platform = if cfg!(target_os = "linux") {
85        "linux-x86_64"
86    } else if cfg!(target_os = "macos") {
87        "macos-arm64"
88    } else if cfg!(target_os = "windows") {
89        "windows-x86_64.exe"
90    } else {
91        anyhow::bail!("Unsupported platform for auto-update. Use: cargo install sparrow-cli");
92    };
93
94    let download_url = format!(
95        "https://github.com/ucav/Sparrow/releases/download/v{}/sparrow-{}",
96        latest, platform
97    );
98
99    let bin_path = std::env::current_exe()?;
100    let new_bin = bin_path.with_extension("new");
101
102    // Download
103    let client = reqwest::blocking::Client::builder()
104        .user_agent("sparrow-updater")
105        .timeout(std::time::Duration::from_secs(120))
106        .build()?;
107
108    let response = client.get(&download_url).send()?;
109    if !response.status().is_success() {
110        anyhow::bail!(
111            "Download failed ({}). Try: {}",
112            response.status(),
113            "cargo install sparrow-cli"
114        );
115    }
116
117    let bytes = response.bytes()?;
118    std::fs::write(&new_bin, &bytes)?;
119
120    // Replace current binary
121    #[cfg(windows)]
122    {
123        let old_bin = bin_path.with_extension("old");
124        std::fs::rename(&bin_path, &old_bin)?;
125        std::fs::rename(&new_bin, &bin_path)?;
126        let _ = std::fs::remove_file(&old_bin);
127    }
128    #[cfg(not(windows))]
129    {
130        std::fs::rename(&new_bin, &bin_path)?;
131        use std::os::unix::fs::PermissionsExt;
132        let mut perms = std::fs::metadata(&bin_path)?.permissions();
133        perms.set_mode(0o755);
134        std::fs::set_permissions(&bin_path, perms)?;
135    }
136
137    Ok(format!(
138        "Updated from v{} → v{}. Restart Sparrow to apply. 🐦",
139        current, latest
140    ))
141}
142
143// ─── Backends ──────────────────────────────────────────────────────────────────
144
145fn check_github() -> Option<UpdateInfo> {
146    let client = reqwest::blocking::Client::builder()
147        .user_agent("sparrow-update-check")
148        .timeout(std::time::Duration::from_secs(5))
149        .build()
150        .ok()?;
151
152    let resp: serde_json::Value = client.get(GITHUB_API).send().ok()?.json().ok()?;
153
154    let latest = resp["tag_name"]
155        .as_str()
156        .unwrap_or("v0.0.0")
157        .trim_start_matches('v');
158
159    let is_newer = is_newer_version(latest, CURRENT_VERSION);
160
161    // Build download URL for current platform
162    let platform = if cfg!(target_os = "linux") {
163        "linux-x86_64"
164    } else if cfg!(target_os = "macos") {
165        "macos-arm64"
166    } else if cfg!(target_os = "windows") {
167        "windows-x86_64.exe"
168    } else {
169        return Some(UpdateInfo {
170            current: CURRENT_VERSION.to_string(),
171            latest: latest.to_string(),
172            is_newer,
173            download_url: None,
174            crate_url: "https://crates.io/crates/sparrow-cli".to_string(),
175            release_url: format!("https://github.com/ucav/Sparrow/releases/tag/v{}", latest),
176            install_cmd: "cargo install sparrow-cli".to_string(),
177        });
178    };
179
180    Some(UpdateInfo {
181        current: CURRENT_VERSION.to_string(),
182        latest: latest.to_string(),
183        is_newer,
184        download_url: Some(format!(
185            "https://github.com/ucav/Sparrow/releases/download/v{}/sparrow-{}",
186            latest, platform
187        )),
188        crate_url: "https://crates.io/crates/sparrow-cli".to_string(),
189        release_url: format!("https://github.com/ucav/Sparrow/releases/tag/v{}", latest),
190        install_cmd: "cargo install sparrow-cli".to_string(),
191    })
192}
193
194fn check_cratesio() -> Option<UpdateInfo> {
195    let client = reqwest::blocking::Client::builder()
196        .user_agent("sparrow-update-check (crates.io)")
197        .timeout(std::time::Duration::from_secs(5))
198        .build()
199        .ok()?;
200
201    let resp: serde_json::Value = client.get(CRATES_API).send().ok()?.json().ok()?;
202
203    let latest = resp["crate"]["max_stable_version"]
204        .as_str()
205        .or_else(|| resp["crate"]["max_version"].as_str())
206        .unwrap_or("0.0.0");
207
208    let is_newer = is_newer_version(latest, CURRENT_VERSION);
209
210    Some(UpdateInfo {
211        current: CURRENT_VERSION.to_string(),
212        latest: latest.to_string(),
213        is_newer,
214        download_url: None,
215        crate_url: "https://crates.io/crates/sparrow-cli".to_string(),
216        release_url: "https://github.com/ucav/Sparrow/releases".to_string(),
217        install_cmd: "cargo install sparrow-cli".to_string(),
218    })
219}
220
221// ─── Version comparison ────────────────────────────────────────────────────────
222
223fn is_newer_version(latest: &str, current: &str) -> bool {
224    let parse = |v: &str| -> Vec<u32> {
225        v.split(|c: char| !c.is_ascii_digit())
226            .filter(|s| !s.is_empty())
227            .filter_map(|s| s.parse::<u32>().ok())
228            .collect()
229    };
230
231    let latest_parts = parse(latest);
232    let current_parts = parse(current);
233
234    for (l, c) in latest_parts.iter().zip(current_parts.iter()) {
235        if l > c {
236            return true;
237        }
238        if l < c {
239            return false;
240        }
241    }
242
243    // If all matching parts are equal, the one with more parts is newer
244    // e.g., 0.7.1 > 0.7.0, but 0.7.0-rc1 is NOT > 0.7.0
245    if latest_parts.len() > current_parts.len() {
246        return true;
247    }
248
249    false
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_version_comparison() {
258        assert!(is_newer_version("0.7.1", "0.7.0"));
259        assert!(is_newer_version("1.0.0", "0.9.9"));
260        assert!(is_newer_version("0.8.0", "0.7.9"));
261        assert!(!is_newer_version("0.7.0", "0.7.0"));
262        assert!(!is_newer_version("0.6.9", "0.7.0"));
263        assert!(!is_newer_version("0.7.0", "0.7.1"));
264    }
265
266    #[test]
267    fn test_version_with_prefix() {
268        assert!(is_newer_version("v0.7.1", "0.7.0"));
269        assert!(is_newer_version("v1.0.0", "v0.9.9"));
270    }
271
272    #[test]
273    fn test_update_info_up_to_date() {
274        let info = UpdateInfo::up_to_date("0.7.0");
275        assert!(!info.is_newer);
276        assert_eq!(info.current, info.latest);
277    }
278}