1use std::time::Duration;
2
3use serde::{Deserialize, Serialize};
4use tauri::AppHandle;
5
6const RELEASES_URL: &str = "https://api.github.com/repos/drmowinckels/entracte/releases/latest";
7
8#[derive(Debug, Clone, Serialize)]
14pub struct UpdateInfo {
15 pub current: String,
16 pub latest: String,
17 pub has_update: bool,
18 pub release_url: String,
19}
20
21#[derive(Deserialize)]
22struct GhRelease {
23 tag_name: String,
24 html_url: String,
25}
26
27type ParsedVersion = (Vec<u32>, Option<String>);
31
32fn parse_version(version: &str) -> ParsedVersion {
33 let body = version
35 .trim()
36 .trim_start_matches('v')
37 .split('+')
38 .next()
39 .unwrap_or("");
40 let (core, pre) = match body.split_once('-') {
41 Some((core, suffix)) if !suffix.is_empty() => (core, Some(suffix.to_string())),
42 _ => (body, None),
43 };
44 let nums: Vec<u32> = core
45 .split('.')
46 .filter(|p| !p.is_empty())
47 .filter_map(|p| p.parse().ok())
48 .collect();
49 (nums, pre)
50}
51
52fn is_newer(latest: &str, current: &str) -> bool {
53 let (lnum, lpre) = parse_version(latest);
54 let (cnum, cpre) = parse_version(current);
55 match lnum.cmp(&cnum) {
56 std::cmp::Ordering::Greater => true,
57 std::cmp::Ordering::Less => false,
58 std::cmp::Ordering::Equal => match (lpre, cpre) {
59 (None, None) => false,
60 (Some(_), None) => false,
62 (None, Some(_)) => true,
63 (Some(a), Some(b)) => a > b,
64 },
65 }
66}
67
68fn normalize(version: &str) -> String {
69 version.trim_start_matches('v').to_string()
70}
71
72#[tauri::command]
76pub async fn check_for_update(app: AppHandle) -> Result<UpdateInfo, String> {
77 let current = app.package_info().version.to_string();
78 check_for_update_at(RELEASES_URL, ¤t).await
79}
80
81pub(crate) async fn check_for_update_at(url: &str, current: &str) -> Result<UpdateInfo, String> {
84 let client = reqwest::Client::builder()
85 .user_agent(format!("entracte/{current}"))
86 .timeout(Duration::from_secs(10))
87 .build()
88 .map_err(|e| e.to_string())?;
89
90 let release: GhRelease = client
91 .get(url)
92 .header("Accept", "application/vnd.github+json")
93 .send()
94 .await
95 .map_err(|e| e.to_string())?
96 .error_for_status()
97 .map_err(|e| e.to_string())?
98 .json()
99 .await
100 .map_err(|e| e.to_string())?;
101
102 Ok(UpdateInfo {
103 has_update: is_newer(&release.tag_name, current),
104 current: current.to_string(),
105 latest: normalize(&release.tag_name),
106 release_url: release.html_url,
107 })
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn parse_version_strips_v_prefix() {
116 assert_eq!(parse_version("v0.1.0"), (vec![0, 1, 0], None));
117 assert_eq!(parse_version("0.1.0"), (vec![0, 1, 0], None));
118 }
119
120 #[test]
121 fn parse_version_separates_pre_release_tag() {
122 assert_eq!(
123 parse_version("v1.2.3-beta.4"),
124 (vec![1, 2, 3], Some("beta.4".to_string()))
125 );
126 assert_eq!(
127 parse_version("1.2.3-rc1"),
128 (vec![1, 2, 3], Some("rc1".to_string()))
129 );
130 }
131
132 #[test]
133 fn parse_version_strips_build_metadata() {
134 assert_eq!(parse_version("1.2.3+build.7"), (vec![1, 2, 3], None));
135 assert_eq!(
136 parse_version("1.2.3-rc1+build.7"),
137 (vec![1, 2, 3], Some("rc1".to_string()))
138 );
139 }
140
141 #[test]
142 fn is_newer_major_minor_patch() {
143 assert!(is_newer("v0.1.0", "0.0.1"));
144 assert!(is_newer("v1.0.0", "0.99.99"));
145 assert!(is_newer("v0.0.2", "0.0.1"));
146 assert!(!is_newer("v0.0.1", "0.0.1"));
147 assert!(!is_newer("v0.0.1", "0.0.2"));
148 }
149
150 #[test]
151 fn is_newer_handles_v_prefix_either_side() {
152 assert!(is_newer("v0.1.0", "v0.0.9"));
153 assert!(!is_newer("0.0.9", "v0.0.9"));
154 }
155
156 #[test]
157 fn is_newer_pre_release_is_older_than_same_stable() {
158 assert!(!is_newer("v1.2.3-beta.4", "1.2.3"));
161 assert!(!is_newer("v0.2.0-rc1", "0.2.0"));
162 }
163
164 #[test]
165 fn is_newer_stable_beats_same_core_pre_release() {
166 assert!(is_newer("v1.2.3", "1.2.3-beta.4"));
167 assert!(is_newer("v0.2.0", "0.2.0-rc1"));
168 }
169
170 #[test]
171 fn is_newer_compares_pre_release_tags_lexically() {
172 assert!(is_newer("v1.2.3-rc2", "1.2.3-rc1"));
173 assert!(!is_newer("v1.2.3-rc1", "1.2.3-rc2"));
174 assert!(!is_newer("v1.2.3-rc1", "1.2.3-rc1"));
175 }
176
177 #[test]
178 fn is_newer_higher_core_beats_lower_core_with_pre_release() {
179 assert!(is_newer("v1.3.0-rc1", "1.2.3"));
180 assert!(!is_newer("v1.2.3", "1.3.0-rc1"));
181 }
182
183 #[test]
184 fn normalize_strips_v() {
185 assert_eq!(normalize("v0.1.0"), "0.1.0");
186 assert_eq!(normalize("0.1.0"), "0.1.0");
187 }
188
189 fn body_for(tag: &str) -> String {
194 serde_json::json!({
195 "tag_name": tag,
196 "html_url": format!("https://example.test/release/{tag}"),
197 })
198 .to_string()
199 }
200
201 #[tokio::test]
202 async fn check_for_update_returns_has_update_when_tag_is_newer() {
203 let mut server = mockito::Server::new_async().await;
204 let _m = server
205 .mock("GET", "/")
206 .with_status(200)
207 .with_header("content-type", "application/json")
208 .with_body(body_for("v9.9.9"))
209 .create_async()
210 .await;
211 let url = server.url();
212 let info = check_for_update_at(&url, "0.1.0").await.unwrap();
213 assert!(info.has_update, "expected newer tag to report has_update");
214 assert_eq!(info.current, "0.1.0");
215 assert_eq!(info.latest, "9.9.9");
216 assert_eq!(info.release_url, "https://example.test/release/v9.9.9");
217 }
218
219 #[tokio::test]
220 async fn check_for_update_returns_no_update_when_tag_matches_current() {
221 let mut server = mockito::Server::new_async().await;
222 let _m = server
223 .mock("GET", "/")
224 .with_status(200)
225 .with_header("content-type", "application/json")
226 .with_body(body_for("v0.1.0"))
227 .create_async()
228 .await;
229 let url = server.url();
230 let info = check_for_update_at(&url, "0.1.0").await.unwrap();
231 assert!(!info.has_update);
232 assert_eq!(info.latest, "0.1.0");
233 }
234
235 #[tokio::test]
236 async fn check_for_update_returns_no_update_when_tag_is_older() {
237 let mut server = mockito::Server::new_async().await;
238 let _m = server
239 .mock("GET", "/")
240 .with_status(200)
241 .with_header("content-type", "application/json")
242 .with_body(body_for("v0.0.1"))
243 .create_async()
244 .await;
245 let url = server.url();
246 let info = check_for_update_at(&url, "0.1.0").await.unwrap();
247 assert!(!info.has_update);
248 }
249
250 #[tokio::test]
251 async fn check_for_update_returns_err_on_404() {
252 let mut server = mockito::Server::new_async().await;
253 let _m = server
254 .mock("GET", "/")
255 .with_status(404)
256 .with_body("not found")
257 .create_async()
258 .await;
259 let url = server.url();
260 let err = check_for_update_at(&url, "0.1.0")
261 .await
262 .expect_err("404 should surface as Err");
263 assert!(!err.is_empty(), "error must carry a message");
264 }
265
266 #[tokio::test]
267 async fn check_for_update_returns_err_on_500() {
268 let mut server = mockito::Server::new_async().await;
269 let _m = server
270 .mock("GET", "/")
271 .with_status(500)
272 .with_body("server error")
273 .create_async()
274 .await;
275 let url = server.url();
276 let err = check_for_update_at(&url, "0.1.0")
277 .await
278 .expect_err("500 should surface as Err");
279 assert!(!err.is_empty());
280 }
281
282 #[tokio::test]
283 async fn check_for_update_returns_err_on_malformed_json() {
284 let mut server = mockito::Server::new_async().await;
285 let _m = server
286 .mock("GET", "/")
287 .with_status(200)
288 .with_header("content-type", "application/json")
289 .with_body("{not valid json")
290 .create_async()
291 .await;
292 let url = server.url();
293 let err = check_for_update_at(&url, "0.1.0")
294 .await
295 .expect_err("malformed JSON should surface as Err");
296 assert!(!err.is_empty());
297 }
298
299 #[tokio::test]
300 async fn check_for_update_returns_err_when_url_is_unreachable() {
301 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
305 let port = listener.local_addr().unwrap().port();
306 drop(listener);
307 let url = format!("http://127.0.0.1:{port}/");
308 let err = check_for_update_at(&url, "0.1.0")
309 .await
310 .expect_err("connection refused must surface as Err");
311 assert!(!err.is_empty());
312 }
313}