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