Skip to main content

cranpose_services/
github_release_updater.rs

1//! A portable [`AppUpdater`] that discovers a newer release from a GitHub
2//! repository's release feed.
3//!
4//! Desktop has no framework-owned installer, and iOS is forbidden by App
5//! Store Review Guideline 3.3.2 from replacing its own binary — but both can
6//! still tell an application a newer version exists. [`GitHubAppUpdater`] is
7//! that check, written once against the framework's own [`HttpClient`] rather
8//! than per platform, so every platform reads the same release feed the same
9//! way and none of them link a second HTTP stack to do it.
10
11use 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
17/// Root of the GitHub REST API this checker reads.
18///
19/// Unauthenticated, so only public repositories and the API's anonymous rate
20/// limit — the same limit a browser hits reading the same page.
21const GITHUB_API_ROOT: &str = "https://api.github.com";
22
23/// Discovers releases through a repository's public GitHub release feed.
24///
25/// [`AppUpdater::check`] starts the request on a thread of its own and
26/// returns immediately; the outcome arrives later through
27/// [`set_app_update_status`] — the same fire-and-forget contract Android's
28/// JNI-backed updater uses, so an application observing update status cannot
29/// tell which platform answered it.
30pub struct GitHubAppUpdater {
31    client: HttpClientRef,
32    /// Whether the client behind this updater can actually make a request.
33    ///
34    /// The framework's own client needs the `http-native` feature, which is
35    /// the application's choice to enable. Without it every request fails
36    /// with [`crate::http::HttpError::UnsupportedFeature`], and an updater
37    /// that still claimed it could check would be claiming a capability the
38    /// build does not have — which is the exact thing
39    /// [`AppUpdateCapabilities`] exists to prevent.
40    can_reach_network: bool,
41}
42
43impl GitHubAppUpdater {
44    /// An updater over the framework's [`default_http_client`].
45    pub fn new() -> Self {
46        Self {
47            client: default_http_client(),
48            can_reach_network: cfg!(feature = "http-native"),
49        }
50    }
51
52    /// An updater over a specific client — the seam a test replaces with a
53    /// [`crate::http::StubHttpClient`], and the one an application uses to
54    /// supply a client of its own. A client the caller built is one the
55    /// caller can send with.
56    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            // Neither desktop nor iOS has a framework-owned way to replace
75            // its own binary, so this updater only ever discovers a release —
76            // it never claims it can install one.
77            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
95/// Fetches the latest release and turns it into the status this checker
96/// publishes, folding every failure into [`AppUpdateStatus::Error`] rather
97/// than letting the worker thread's result go nowhere.
98async 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
108/// The fields this checker reads out of a GitHub release feed. Not every
109/// field the API returns — only what a caller of [`GitHubAppUpdater`] needs
110/// to decide whether a package is newer and to fetch it.
111struct 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    /// The `algorithm:hex` digest GitHub publishes per asset, when it did —
122    /// older releases and third-party mirrors may carry none.
123    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
166/// Sends the one request this checker needs and reads its body as JSON.
167///
168/// Errors are flattened to a message rather than kept as [`crate::http::HttpError`]
169/// or [`serde_json::Error`]: the caller only ever turns them into
170/// [`AppUpdateStatus::Error`], and a status is text, not a typed error a
171/// caller branches on.
172async 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
201/// Turns a fetched release into the status [`GitHubAppUpdater::check`]
202/// publishes: current if nothing newer was found, available with a package
203/// when it was, or an error when the release is newer but carries none of
204/// the assets this application asked for.
205fn 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    // A digest the feed did not publish, or published in a form this
225    // framework cannot check, leaves the package unverifiable — which
226    // `install_app_update` refuses rather than installing blind. Inventing
227    // one here would hide that the feed never promised anything to check
228    // against.
229    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
238/// Strips a release tag's leading `v` (or `V`), the one prefix GitHub's own
239/// tagging convention adds and this application's own version string never
240/// carries.
241fn 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
249/// Parses a version string into numeric components, so they compare the way
250/// a version number means to rather than the way its digits happen to sort:
251/// `"10"` reads as `10`, not as a string that sorts before `"9"`.
252///
253/// A component that is not purely numeric (a pre-release suffix such as
254/// `10-beta`) contributes its leading digits and drops the rest — enough to
255/// order releases correctly without a general version grammar this framework
256/// has no other use for.
257fn 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
271/// Whether `candidate` is a newer release than `current`, comparing version
272/// components numerically component by component rather than as strings or
273/// as whole numbers.
274fn 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    /// The contract this checker relies on to decide whether a release is
288    /// worth surfacing: version numbers compare numerically, not as strings
289    /// or as whole dotted numbers, so `v0.1.10` — the release after nine
290    /// patches — is correctly newer than `v0.1.9` rather than sorting before
291    /// it the way `"10" < "9"` would as strings.
292    #[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}