Skip to main content

romm_cli/update/
mod.rs

1use anyhow::{anyhow, Context, Result};
2use self_update::cargo_crate_version;
3use serde::Deserialize;
4use std::process::Command;
5
6use crate::core::interrupt::{cancelled_error, InterruptContext};
7
8const REPO_OWNER: &str = "patricksmill";
9const REPO_NAME: &str = "romm-cli";
10const BIN_NAME: &str = "romm-cli";
11const GITHUB_LATEST_RELEASE_API: &str =
12    "https://api.github.com/repos/patricksmill/romm-cli/releases/latest";
13const CHANGELOG_URL: &str = "https://github.com/patricksmill/romm-cli/blob/main/CHANGELOG.md";
14
15#[derive(Debug, Clone)]
16pub struct UpdateStatus {
17    pub current_version: String,
18    pub latest_version: String,
19    pub should_update: bool,
20    pub release_url: String,
21    pub changelog_url: String,
22}
23
24#[derive(Debug, Deserialize)]
25struct GithubLatestRelease {
26    tag_name: String,
27    html_url: String,
28}
29
30fn github_release_asset_key() -> &'static str {
31    match (std::env::consts::OS, std::env::consts::ARCH) {
32        ("macos", "x86_64") => "macos-x86_64",
33        ("macos", "aarch64") => "macos-aarch64",
34        ("linux", "x86_64") => "linux-x86_64",
35        ("linux", "aarch64") => "linux-aarch64",
36        ("windows", "x86_64") => "windows-x86_64",
37        _ => self_update::get_target(),
38    }
39}
40
41fn parse_numeric_version_parts(input: &str) -> Vec<u64> {
42    let trimmed = input.trim().trim_start_matches('v');
43    trimmed
44        .split(['.', '-'])
45        .take(3)
46        .map(|p| p.parse::<u64>().unwrap_or(0))
47        .collect()
48}
49
50fn is_latest_newer(latest: &str, current: &str) -> bool {
51    let mut latest_parts = parse_numeric_version_parts(latest);
52    let mut current_parts = parse_numeric_version_parts(current);
53    let max_len = latest_parts.len().max(current_parts.len()).max(3);
54    latest_parts.resize(max_len, 0);
55    current_parts.resize(max_len, 0);
56    latest_parts > current_parts
57}
58
59pub fn changelog_url() -> &'static str {
60    CHANGELOG_URL
61}
62
63pub fn open_url_in_browser(url: &str) -> Result<()> {
64    #[cfg(target_os = "windows")]
65    {
66        Command::new("cmd")
67            .args(["/C", "start", "", url])
68            .spawn()
69            .context("failed to launch browser via start")?;
70        return Ok(());
71    }
72
73    #[cfg(target_os = "macos")]
74    {
75        Command::new("open")
76            .arg(url)
77            .spawn()
78            .context("failed to launch browser via open")?;
79        return Ok(());
80    }
81
82    #[cfg(all(unix, not(target_os = "macos")))]
83    {
84        Command::new("xdg-open")
85            .arg(url)
86            .spawn()
87            .context("failed to launch browser via xdg-open")?;
88        return Ok(());
89    }
90
91    #[allow(unreachable_code)]
92    Err(anyhow!("unsupported OS for opening browser"))
93}
94
95pub fn open_changelog_in_browser() -> Result<()> {
96    open_url_in_browser(changelog_url())
97}
98
99pub async fn check_for_update() -> Result<UpdateStatus> {
100    let current_version = cargo_crate_version!().to_string();
101    let response = reqwest::Client::new()
102        .get(GITHUB_LATEST_RELEASE_API)
103        .header(
104            reqwest::header::USER_AGENT,
105            format!("romm-cli/{current_version}"),
106        )
107        .send()
108        .await
109        .context("failed to query latest release")?
110        .error_for_status()
111        .context("latest release endpoint returned an error status")?;
112
113    let latest_release: GithubLatestRelease = response
114        .json()
115        .await
116        .context("failed to parse latest release response")?;
117
118    let latest_version = latest_release.tag_name.trim_start_matches('v').to_string();
119    Ok(UpdateStatus {
120        should_update: is_latest_newer(&latest_version, &current_version),
121        current_version,
122        latest_version,
123        release_url: latest_release.html_url,
124        changelog_url: changelog_url().to_string(),
125    })
126}
127
128pub async fn apply_update(interrupt: Option<InterruptContext>) -> Result<String> {
129    let interrupt = interrupt.unwrap_or_default();
130    let update_task = tokio::task::spawn_blocking(|| -> Result<String> {
131        let status = self_update::backends::github::Update::configure()
132            .repo_owner(REPO_OWNER)
133            .repo_name(REPO_NAME)
134            .bin_name(BIN_NAME)
135            .target(github_release_asset_key())
136            .show_download_progress(true)
137            .current_version(cargo_crate_version!())
138            .build()?
139            .update()?;
140        Ok(status.version().to_string())
141    });
142
143    let version = tokio::select! {
144        out = update_task => out
145            .map_err(|e| anyhow::anyhow!("update task failed: {e}"))??,
146        _ = interrupt.cancelled() => return Err(cancelled_error()),
147    };
148    Ok(version)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn version_compare_handles_patch_and_minor() {
157        assert!(is_latest_newer("0.25.1", "0.25.0"));
158        assert!(is_latest_newer("0.26.0", "0.25.9"));
159        assert!(!is_latest_newer("0.25.0", "0.25.0"));
160        assert!(!is_latest_newer("0.24.9", "0.25.0"));
161    }
162
163    #[test]
164    fn version_compare_handles_v_prefix() {
165        assert!(is_latest_newer("v1.2.4", "1.2.3"));
166    }
167}