Skip to main content

smriti/services/
update_checker.rs

1//! GitHub Releases API client — "is a newer Smriti out?"
2//!
3//! This module owns the HTTP call, JSON parsing, semver comparison,
4//! and all the error cases that come with talking to a remote API.
5//! The Tauri command handler just turns the result into a DTO.
6//!
7//! Everything here is opt-in — the call only runs when the user has
8//! enabled "Automatically check for updates" in Settings or explicitly
9//! clicks "Check for updates now". See PRIVACY.md.
10
11use semver::Version;
12use serde::Deserialize;
13use std::time::Duration;
14
15/// Where GitHub's API lives. Overridable via `SMRITI_GITHUB_API_URL`
16/// (legacy `PHOTOVAULT_GITHUB_API_URL` still honoured) for integration
17/// tests without hitting the real API.
18const DEFAULT_API_URL: &str =
19    "https://api.github.com/repos/ChivukulaVirinchi/photovault/releases/latest";
20
21/// Timeout for the single API call. Short enough that a hung network
22/// doesn't block app startup, long enough to survive normal jitter.
23const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
24
25/// A release asset attached to a GitHub release (MSI, AppImage,
26/// .dmg, SHA256SUMS, etc.). `self_replace` consumes these.
27#[derive(Debug, Clone, Deserialize)]
28pub struct ReleaseAsset {
29    pub name: String,
30    pub browser_download_url: String,
31    #[serde(rename = "size")]
32    pub size_bytes: u64,
33}
34
35/// Raw shape of the GitHub `/releases/latest` JSON payload — just
36/// the fields we actually use.
37#[derive(Debug, Deserialize)]
38struct GithubRelease {
39    tag_name: String,
40    html_url: String,
41    #[serde(default)]
42    body: String,
43    #[serde(default)]
44    prerelease: bool,
45    #[serde(default)]
46    assets: Vec<ReleaseAsset>,
47}
48
49/// Normalized latest-release descriptor the rest of the app talks to.
50#[derive(Debug, Clone)]
51#[allow(dead_code)] // `version` and `is_prerelease` aren't displayed
52                    // yet but are part of the public shape for
53                    // release-channel selection (Phase 3+) and tests.
54pub struct LatestRelease {
55    pub version: Version,
56    pub tag_name: String,
57    pub html_url: String,
58    pub body: String,
59    pub assets: Vec<ReleaseAsset>,
60    pub is_prerelease: bool,
61}
62
63/// Result of a single update check: the running version, the latest
64/// published one, and whether an upgrade is available.
65#[derive(Debug, Clone)]
66pub struct UpdateStatus {
67    pub current: Version,
68    pub latest: LatestRelease,
69    pub newer_available: bool,
70}
71
72pub fn current_version() -> Version {
73    Version::parse(env!("CARGO_PKG_VERSION"))
74        .expect("CARGO_PKG_VERSION must be a valid semantic version")
75}
76
77/// Typed errors the UI/handler needs to distinguish so the user sees
78/// a useful message instead of a raw `reqwest` string.
79#[derive(Debug, thiserror::Error)]
80pub enum UpdateCheckError {
81    #[error("failed to reach GitHub's release API: {0}")]
82    Network(String),
83
84    #[error("GitHub returned HTTP {status} from {url}")]
85    HttpStatus { status: u16, url: String },
86
87    #[error("failed to parse GitHub's release JSON: {0}")]
88    Parse(String),
89
90    #[error("couldn't parse the release's tag as a semver: {0}")]
91    InvalidTag(String),
92
93    #[error("couldn't parse Smriti's own version as a semver: {0}")]
94    InvalidSelfVersion(String),
95}
96
97/// Check whether a newer release is available.
98///
99/// Hits `api.github.com/repos/ChivukulaVirinchi/photovault/releases/latest`
100/// by default. Set `SMRITI_GITHUB_API_URL` (or the legacy
101/// `PHOTOVAULT_GITHUB_API_URL`) to override — used by integration
102/// tests to point at a local mock.
103pub async fn check_for_updates() -> Result<UpdateStatus, UpdateCheckError> {
104    let url = std::env::var("SMRITI_GITHUB_API_URL")
105        .or_else(|_| std::env::var("PHOTOVAULT_GITHUB_API_URL"))
106        .unwrap_or_else(|_| DEFAULT_API_URL.to_string());
107
108    let client = reqwest::Client::builder()
109        .timeout(REQUEST_TIMEOUT)
110        .user_agent(format!("smriti/{}", env!("CARGO_PKG_VERSION")))
111        .build()
112        .map_err(|e| UpdateCheckError::Network(e.to_string()))?;
113
114    let response = client
115        .get(&url)
116        .header("Accept", "application/vnd.github+json")
117        .header("X-GitHub-Api-Version", "2022-11-28")
118        .send()
119        .await
120        .map_err(|e| UpdateCheckError::Network(e.to_string()))?;
121
122    let status = response.status();
123    if !status.is_success() {
124        return Err(UpdateCheckError::HttpStatus {
125            status: status.as_u16(),
126            url,
127        });
128    }
129
130    let payload: GithubRelease = response
131        .json()
132        .await
133        .map_err(|e| UpdateCheckError::Parse(e.to_string()))?;
134
135    let latest_version = parse_tag_as_version(&payload.tag_name)?;
136    let current_version = current_version();
137
138    let newer_available = latest_version > current_version;
139
140    Ok(UpdateStatus {
141        current: current_version,
142        newer_available,
143        latest: LatestRelease {
144            version: latest_version,
145            tag_name: payload.tag_name,
146            html_url: payload.html_url,
147            body: payload.body,
148            assets: payload.assets,
149            is_prerelease: payload.prerelease,
150        },
151    })
152}
153
154/// Parse a GitHub tag name into a `semver::Version`. Handles the
155/// common `v1.2.3` form by stripping the leading `v` before parsing.
156fn parse_tag_as_version(tag: &str) -> Result<Version, UpdateCheckError> {
157    let trimmed = tag.trim().trim_start_matches('v').trim_start_matches('V');
158    Version::parse(trimmed).map_err(|e| UpdateCheckError::InvalidTag(e.to_string()))
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn tag_parses_with_v_prefix() {
167        assert_eq!(
168            parse_tag_as_version("v1.2.3").unwrap(),
169            Version::parse("1.2.3").unwrap()
170        );
171    }
172
173    #[test]
174    fn tag_parses_without_v_prefix() {
175        assert_eq!(
176            parse_tag_as_version("1.2.3").unwrap(),
177            Version::parse("1.2.3").unwrap()
178        );
179    }
180
181    #[test]
182    fn tag_parses_prerelease() {
183        let v = parse_tag_as_version("v1.0.0-beta.1").unwrap();
184        assert_eq!(v, Version::parse("1.0.0-beta.1").unwrap());
185        assert!(!v.pre.is_empty(), "prerelease segment should be present");
186    }
187
188    #[test]
189    fn tag_parses_uppercase_v() {
190        assert_eq!(
191            parse_tag_as_version("V2.0.0").unwrap(),
192            Version::parse("2.0.0").unwrap()
193        );
194    }
195
196    #[test]
197    fn tag_rejects_nonsemver() {
198        assert!(matches!(
199            parse_tag_as_version("banana"),
200            Err(UpdateCheckError::InvalidTag(_))
201        ));
202    }
203
204    #[test]
205    fn tag_rejects_empty() {
206        assert!(matches!(
207            parse_tag_as_version(""),
208            Err(UpdateCheckError::InvalidTag(_))
209        ));
210    }
211
212    #[test]
213    fn current_version_matches_package_version() {
214        assert_eq!(current_version().to_string(), env!("CARGO_PKG_VERSION"));
215    }
216}