cranpose_services/
github_release_updater.rs1use crate::{
12 app_update::{
13 AppUpdateCapabilities, AppUpdateError, AppUpdateStatus, AppUpdater, GitHubReleaseUpdate,
14 PackageDigest, UpdatePackage, set_app_update_status,
15 },
16 http::{HttpClient, HttpClientRef, HttpControl, HttpRequest, default_http_client},
17};
18
19const GITHUB_API_ROOT: &str = "https://api.github.com";
20
21pub struct GitHubAppUpdater {
29 client: HttpClientRef,
30 can_reach_network: bool,
31}
32
33impl GitHubAppUpdater {
34 pub fn new() -> Self {
36 Self {
37 client: default_http_client(),
38 can_reach_network: cfg!(feature = "http-native"),
39 }
40 }
41
42 pub fn with_client(client: HttpClientRef) -> Self {
47 Self {
48 client,
49 can_reach_network: true,
50 }
51 }
52}
53
54impl Default for GitHubAppUpdater {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl AppUpdater for GitHubAppUpdater {
61 fn capabilities(&self) -> AppUpdateCapabilities {
62 AppUpdateCapabilities {
63 check: self.can_reach_network,
64 install: false,
65 }
66 }
67
68 fn check(&self, source: &GitHubReleaseUpdate) -> Result<(), AppUpdateError> {
69 let client = self.client.clone();
70 let source = source.clone();
71 std::thread::Builder::new()
72 .name("cranpose-app-update-check".to_string())
73 .spawn(move || {
74 let status = pollster::block_on(latest_release_status(client.as_ref(), &source));
75 set_app_update_status(status);
76 })
77 .map_err(|error| AppUpdateError::Request(error.to_string()))?;
78 Ok(())
79 }
80}
81
82async fn latest_release_status(
83 client: &dyn HttpClient,
84 source: &GitHubReleaseUpdate,
85) -> AppUpdateStatus {
86 match fetch_latest_release(client, source).await {
87 Ok(release) => release_status(&release, source),
88 Err(message) => AppUpdateStatus::Error(message),
89 }
90}
91
92struct GitHubRelease {
93 tag_name: String,
94 notes: Option<String>,
95 assets: Vec<GitHubReleaseAsset>,
96}
97
98struct GitHubReleaseAsset {
99 name: String,
100 download_url: String,
101 size: Option<u64>,
102 digest: Option<String>,
103}
104
105impl GitHubRelease {
106 fn from_json(value: &serde_json::Value) -> Option<Self> {
107 let tag_name = value.get("tag_name")?.as_str()?.to_string();
108 let notes = value
109 .get("body")
110 .and_then(serde_json::Value::as_str)
111 .filter(|body| !body.trim().is_empty())
112 .map(str::to_string);
113 let assets = value
114 .get("assets")
115 .and_then(serde_json::Value::as_array)
116 .map(|assets| {
117 assets
118 .iter()
119 .filter_map(GitHubReleaseAsset::from_json)
120 .collect()
121 })
122 .unwrap_or_default();
123 Some(Self {
124 tag_name,
125 notes,
126 assets,
127 })
128 }
129}
130
131impl GitHubReleaseAsset {
132 fn from_json(value: &serde_json::Value) -> Option<Self> {
133 Some(Self {
134 name: value.get("name")?.as_str()?.to_string(),
135 download_url: value.get("browser_download_url")?.as_str()?.to_string(),
136 size: value.get("size").and_then(serde_json::Value::as_u64),
137 digest: value
138 .get("digest")
139 .and_then(serde_json::Value::as_str)
140 .map(str::to_string),
141 })
142 }
143}
144
145async fn fetch_latest_release(
146 client: &dyn HttpClient,
147 source: &GitHubReleaseUpdate,
148) -> Result<GitHubRelease, String> {
149 let url = format!(
150 "{GITHUB_API_ROOT}/repos/{}/releases/latest",
151 source.repository
152 );
153 let request = HttpRequest::get(url).header("Accept", "application/vnd.github+json");
154 let response = client
155 .send(&request, HttpControl::new())
156 .await
157 .map_err(|error| error.to_string())?
158 .error_for_status()
159 .map_err(|error| error.to_string())?;
160 let body = response
161 .read_text()
162 .await
163 .map_err(|error| error.to_string())?;
164 let value: serde_json::Value = serde_json::from_str(&body)
165 .map_err(|error| format!("the release feed did not answer with JSON: {error}"))?;
166 GitHubRelease::from_json(&value).ok_or_else(|| {
167 format!(
168 "the release feed for {} is missing tag_name or assets",
169 source.repository
170 )
171 })
172}
173
174fn release_status(release: &GitHubRelease, source: &GitHubReleaseUpdate) -> AppUpdateStatus {
175 let latest_version = version_from_tag(&release.tag_name);
176 if !is_newer_version(latest_version, &source.current_version) {
177 return AppUpdateStatus::UpToDate;
178 }
179 let Some(asset) = release
180 .assets
181 .iter()
182 .find(|asset| asset.name.ends_with(source.asset_suffix.as_str()))
183 else {
184 return AppUpdateStatus::Error(format!(
185 "the latest release of {} ({}) has no asset ending in {}",
186 source.repository, release.tag_name, source.asset_suffix
187 ));
188 };
189 let mut package = UpdatePackage::new(latest_version, asset.download_url.clone());
190 if let Some(size) = asset.size {
191 package = package.with_size(size);
192 }
193 if let Some(digest) = asset.digest.as_deref().and_then(PackageDigest::parse) {
194 package = package.with_digest(digest);
195 }
196 if let Some(notes) = &release.notes {
197 package = package.with_notes(notes.clone());
198 }
199 AppUpdateStatus::Available { package }
200}
201
202fn version_from_tag(tag: &str) -> &str {
203 let trimmed = tag.trim();
204 trimmed
205 .strip_prefix('v')
206 .or_else(|| trimmed.strip_prefix('V'))
207 .unwrap_or(trimmed)
208}
209
210fn version_components(version: &str) -> Vec<u64> {
211 version_from_tag(version)
212 .split('.')
213 .map(|component| {
214 component
215 .chars()
216 .take_while(char::is_ascii_digit)
217 .collect::<String>()
218 .parse()
219 .unwrap_or(0)
220 })
221 .collect()
222}
223
224fn is_newer_version(candidate: &str, current: &str) -> bool {
225 let mut candidate = version_components(candidate);
226 let mut current = version_components(current);
227 let len = candidate.len().max(current.len());
228 candidate.resize(len, 0);
229 current.resize(len, 0);
230 candidate > current
231}
232
233#[cfg(test)]
234#[path = "tests/github_release_updater_tests.rs"]
235mod tests;