cranpose_services/
github_release_updater.rs1use crate::app_update::{
12 set_app_update_status, AppUpdateCapabilities, AppUpdateError, AppUpdateStatus, AppUpdater,
13 GitHubReleaseUpdate, PackageDigest, UpdatePackage,
14};
15use crate::http::{default_http_client, HttpClient, HttpClientRef, HttpControl, HttpRequest};
16
17const GITHUB_API_ROOT: &str = "https://api.github.com";
22
23pub struct GitHubAppUpdater {
31 client: HttpClientRef,
32 can_reach_network: bool,
41}
42
43impl GitHubAppUpdater {
44 pub fn new() -> Self {
46 Self {
47 client: default_http_client(),
48 can_reach_network: cfg!(feature = "http-native"),
49 }
50 }
51
52 pub fn with_client(client: HttpClientRef) -> Self {
57 Self {
58 client,
59 can_reach_network: true,
60 }
61 }
62}
63
64impl Default for GitHubAppUpdater {
65 fn default() -> Self {
66 Self::new()
67 }
68}
69
70impl AppUpdater for GitHubAppUpdater {
71 fn capabilities(&self) -> AppUpdateCapabilities {
72 AppUpdateCapabilities {
73 check: self.can_reach_network,
74 install: false,
78 }
79 }
80
81 fn check(&self, source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
82 let client = self.client.clone();
83 let source = source.clone();
84 std::thread::Builder::new()
85 .name("cranpose-app-update-check".to_string())
86 .spawn(move || {
87 let status = pollster::block_on(latest_release_status(client.as_ref(), &source));
88 set_app_update_status(status);
89 })
90 .map_err(|error| AppUpdateError::Request(error.to_string()))?;
91 Ok(())
92 }
93}
94
95async fn latest_release_status(
99 client: &dyn HttpClient,
100 source: &GitHubReleaseUpdate,
101) -> AppUpdateStatus {
102 match fetch_latest_release(client, source).await {
103 Ok(release) => release_status(&release, source),
104 Err(message) => AppUpdateStatus::Error(message),
105 }
106}
107
108struct GitHubRelease {
112 tag_name: String,
113 notes: Option<String>,
114 assets: Vec<GitHubReleaseAsset>,
115}
116
117struct GitHubReleaseAsset {
118 name: String,
119 download_url: String,
120 size: Option<u64>,
121 digest: Option<String>,
124}
125
126impl GitHubRelease {
127 fn from_json(value: &serde_json::Value) -> Option<Self> {
128 let tag_name = value.get("tag_name")?.as_str()?.to_string();
129 let notes = value
130 .get("body")
131 .and_then(serde_json::Value::as_str)
132 .filter(|body| !body.trim().is_empty())
133 .map(str::to_string);
134 let assets = value
135 .get("assets")
136 .and_then(serde_json::Value::as_array)
137 .map(|assets| {
138 assets
139 .iter()
140 .filter_map(GitHubReleaseAsset::from_json)
141 .collect()
142 })
143 .unwrap_or_default();
144 Some(Self {
145 tag_name,
146 notes,
147 assets,
148 })
149 }
150}
151
152impl GitHubReleaseAsset {
153 fn from_json(value: &serde_json::Value) -> Option<Self> {
154 Some(Self {
155 name: value.get("name")?.as_str()?.to_string(),
156 download_url: value.get("browser_download_url")?.as_str()?.to_string(),
157 size: value.get("size").and_then(serde_json::Value::as_u64),
158 digest: value
159 .get("digest")
160 .and_then(serde_json::Value::as_str)
161 .map(str::to_string),
162 })
163 }
164}
165
166async fn fetch_latest_release(
173 client: &dyn HttpClient,
174 source: &GitHubReleaseUpdate,
175) -> Result<GitHubRelease, String> {
176 let url = format!(
177 "{GITHUB_API_ROOT}/repos/{}/releases/latest",
178 source.repository
179 );
180 let request = HttpRequest::get(url).header("Accept", "application/vnd.github+json");
181 let response = client
182 .send(&request, HttpControl::new())
183 .await
184 .map_err(|error| error.to_string())?
185 .error_for_status()
186 .map_err(|error| error.to_string())?;
187 let body = response
188 .read_text()
189 .await
190 .map_err(|error| error.to_string())?;
191 let value: serde_json::Value = serde_json::from_str(&body)
192 .map_err(|error| format!("the release feed did not answer with JSON: {error}"))?;
193 GitHubRelease::from_json(&value).ok_or_else(|| {
194 format!(
195 "the release feed for {} is missing tag_name or assets",
196 source.repository
197 )
198 })
199}
200
201fn release_status(release: &GitHubRelease, source: &GitHubReleaseUpdate) -> AppUpdateStatus {
206 let latest_version = version_from_tag(&release.tag_name);
207 if !is_newer_version(latest_version, &source.current_version) {
208 return AppUpdateStatus::UpToDate;
209 }
210 let Some(asset) = release
211 .assets
212 .iter()
213 .find(|asset| asset.name.ends_with(source.asset_suffix.as_str()))
214 else {
215 return AppUpdateStatus::Error(format!(
216 "the latest release of {} ({}) has no asset ending in {}",
217 source.repository, release.tag_name, source.asset_suffix
218 ));
219 };
220 let mut package = UpdatePackage::new(latest_version, asset.download_url.clone());
221 if let Some(size) = asset.size {
222 package = package.with_size(size);
223 }
224 if let Some(digest) = asset.digest.as_deref().and_then(PackageDigest::parse) {
230 package = package.with_digest(digest);
231 }
232 if let Some(notes) = &release.notes {
233 package = package.with_notes(notes.clone());
234 }
235 AppUpdateStatus::Available { package }
236}
237
238fn version_from_tag(tag: &str) -> &str {
242 let trimmed = tag.trim();
243 trimmed
244 .strip_prefix('v')
245 .or_else(|| trimmed.strip_prefix('V'))
246 .unwrap_or(trimmed)
247}
248
249fn version_components(version: &str) -> Vec<u64> {
258 version_from_tag(version)
259 .split('.')
260 .map(|component| {
261 component
262 .chars()
263 .take_while(char::is_ascii_digit)
264 .collect::<String>()
265 .parse()
266 .unwrap_or(0)
267 })
268 .collect()
269}
270
271fn is_newer_version(candidate: &str, current: &str) -> bool {
275 let mut candidate = version_components(candidate);
276 let mut current = version_components(current);
277 let len = candidate.len().max(current.len());
278 candidate.resize(len, 0);
279 current.resize(len, 0);
280 candidate > current
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
293 fn version_comparison_is_numeric_per_component() {
294 assert!(
295 is_newer_version("v0.1.10", "v0.1.9"),
296 "10 must sort after 9 numerically, not before it as strings would"
297 );
298 assert!(
299 is_newer_version("0.1.10", "0.1.9"),
300 "the comparison works the same without a leading v"
301 );
302 assert!(!is_newer_version("v0.1.9", "v0.1.10"));
303 assert!(
304 !is_newer_version("v1.2.3", "v1.2.3"),
305 "identical versions are not newer than themselves"
306 );
307 assert!(is_newer_version("v1.3.0", "v1.2.9"));
308 assert!(
309 !is_newer_version("v1.2", "v1.2.0"),
310 "a missing trailing component reads as zero, not as older"
311 );
312 }
313}