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::{
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
21/// Discovers releases through a repository's public GitHub release feed.
22///
23/// [`AppUpdater::check`] starts the request on a thread of its own and
24/// returns immediately; the outcome arrives later through
25/// [`set_app_update_status`] — the same fire-and-forget contract Android's
26/// JNI-backed updater uses, so an application observing update status cannot
27/// tell which platform answered it.
28pub struct GitHubAppUpdater {
29    client: HttpClientRef,
30    can_reach_network: bool,
31}
32
33impl GitHubAppUpdater {
34    /// An updater over the framework's [`default_http_client`].
35    pub fn new() -> Self {
36        Self {
37            client: default_http_client(),
38            can_reach_network: cfg!(feature = "http-native"),
39        }
40    }
41
42    /// An updater over a specific client — the seam a test replaces with a
43    /// [`crate::http::StubHttpClient`], and the one an application uses to
44    /// supply a client of its own. A client the caller built is one the
45    /// caller can send with.
46    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)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn version_comparison_is_numeric_per_component() {
239        assert!(
240            is_newer_version("v0.1.10", "v0.1.9"),
241            "10 must sort after 9 numerically, not before it as strings would"
242        );
243        assert!(
244            is_newer_version("0.1.10", "0.1.9"),
245            "the comparison works the same without a leading v"
246        );
247        assert!(!is_newer_version("v0.1.9", "v0.1.10"));
248        assert!(
249            !is_newer_version("v1.2.3", "v1.2.3"),
250            "identical versions are not newer than themselves"
251        );
252        assert!(is_newer_version("v1.3.0", "v1.2.9"));
253        assert!(
254            !is_newer_version("v1.2", "v1.2.0"),
255            "a missing trailing component reads as zero, not as older"
256        );
257    }
258}