Skip to main content

modde_core/
update_check.rs

1use std::env;
2use std::path::PathBuf;
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use anyhow::{Context, Result};
6use semver::Version;
7use serde::{Deserialize, Serialize};
8use tokio::fs;
9
10use crate::settings::AppSettings;
11
12const DEFAULT_RELEASE_URL: &str =
13    "https://codeberg.org/api/v1/repos/caniko/rs-modde/releases/latest";
14const CACHE_TTL: Duration = Duration::from_hours(24);
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct UpdateInfo {
18    pub current_version: String,
19    pub latest_version: String,
20    pub release_url: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24struct ReleaseResponse {
25    tag_name: String,
26    #[serde(default)]
27    html_url: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31struct UpdateCheckCache {
32    checked_at: u64,
33    latest_version: String,
34    release_url: String,
35}
36
37#[must_use]
38pub fn update_checks_enabled(settings: &AppSettings) -> bool {
39    if env::var("MODDE_NO_UPDATE_CHECK").is_ok_and(|value| {
40        matches!(
41            value.trim().to_ascii_lowercase().as_str(),
42            "1" | "true" | "yes" | "on"
43        )
44    }) {
45        return false;
46    }
47    settings.update_check.enabled
48}
49
50pub async fn check_latest() -> Result<Option<UpdateInfo>> {
51    check_latest_with_settings(&AppSettings::load()).await
52}
53
54pub async fn check_latest_with_settings(settings: &AppSettings) -> Result<Option<UpdateInfo>> {
55    if !update_checks_enabled(settings) {
56        return Ok(None);
57    }
58
59    if let Some(cache) = read_fresh_cache().await? {
60        return compare_cached(cache);
61    }
62
63    let latest = fetch_latest_release().await?;
64    write_cache(&latest).await?;
65    compare_cached(latest)
66}
67
68pub async fn check_latest_uncached(settings: &AppSettings) -> Result<Option<UpdateInfo>> {
69    if !update_checks_enabled(settings) {
70        return Ok(None);
71    }
72    let latest = fetch_latest_release().await?;
73    write_cache(&latest).await?;
74    compare_cached(latest)
75}
76
77fn compare_cached(cache: UpdateCheckCache) -> Result<Option<UpdateInfo>> {
78    let current = Version::parse(env!("CARGO_PKG_VERSION")).context("invalid compiled version")?;
79    let latest = Version::parse(cache.latest_version.trim_start_matches('v'))
80        .with_context(|| format!("invalid latest release version '{}'", cache.latest_version))?;
81
82    if latest > current {
83        Ok(Some(UpdateInfo {
84            current_version: current.to_string(),
85            latest_version: latest.to_string(),
86            release_url: cache.release_url,
87        }))
88    } else {
89        Ok(None)
90    }
91}
92
93async fn fetch_latest_release() -> Result<UpdateCheckCache> {
94    let endpoint =
95        env::var("MODDE_UPDATE_CHECK_URL").unwrap_or_else(|_| DEFAULT_RELEASE_URL.into());
96    let client = reqwest::Client::builder()
97        .user_agent(concat!("modde/", env!("CARGO_PKG_VERSION")))
98        .timeout(Duration::from_secs(5))
99        .build()
100        .context("failed to build update-check HTTP client")?;
101
102    let release = client
103        .get(endpoint)
104        .send()
105        .await
106        .context("failed to query latest modde release")?
107        .error_for_status()
108        .context("latest modde release endpoint returned an error")?
109        .json::<ReleaseResponse>()
110        .await
111        .context("failed to parse latest modde release response")?;
112
113    let fallback_url = format!(
114        "https://codeberg.org/caniko/rs-modde/releases/tag/{}",
115        release.tag_name
116    );
117
118    Ok(UpdateCheckCache {
119        checked_at: now_unix_secs()?,
120        latest_version: release.tag_name,
121        release_url: release.html_url.unwrap_or(fallback_url),
122    })
123}
124
125async fn read_fresh_cache() -> Result<Option<UpdateCheckCache>> {
126    let path = cache_path();
127    let Ok(bytes) = fs::read(&path).await else {
128        return Ok(None);
129    };
130    let cache: UpdateCheckCache = serde_json::from_slice(&bytes)
131        .with_context(|| format!("failed to parse update-check cache at {}", path.display()))?;
132    let now = now_unix_secs()?;
133    if now.saturating_sub(cache.checked_at) <= CACHE_TTL.as_secs() {
134        Ok(Some(cache))
135    } else {
136        Ok(None)
137    }
138}
139
140async fn write_cache(cache: &UpdateCheckCache) -> Result<()> {
141    let path = cache_path();
142    let parent = path
143        .parent()
144        .ok_or_else(|| anyhow::anyhow!("update-check cache path has no parent"))?;
145    fs::create_dir_all(parent)
146        .await
147        .with_context(|| format!("failed to create cache directory {}", parent.display()))?;
148
149    let tmp = path.with_extension(format!("json.tmp.{}", std::process::id()));
150    let bytes = serde_json::to_vec_pretty(cache).context("failed to serialize update cache")?;
151    fs::write(&tmp, bytes)
152        .await
153        .with_context(|| format!("failed to write temporary cache {}", tmp.display()))?;
154    fs::rename(&tmp, &path)
155        .await
156        .with_context(|| format!("failed to replace update cache {}", path.display()))?;
157    Ok(())
158}
159
160fn cache_path() -> PathBuf {
161    crate::paths::modde_cache_dir().join("update-check.json")
162}
163
164fn now_unix_secs() -> Result<u64> {
165    Ok(SystemTime::now()
166        .duration_since(UNIX_EPOCH)
167        .context("system clock is before Unix epoch")?
168        .as_secs())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::sync::Mutex;
175
176    static ENV_LOCK: Mutex<()> = Mutex::new(());
177
178    #[test]
179    fn env_opt_out_accepts_truthy_values() {
180        let _guard = ENV_LOCK.lock().unwrap();
181        let mut settings = AppSettings::default();
182        settings.update_check.enabled = true;
183        unsafe {
184            env::set_var("MODDE_NO_UPDATE_CHECK", "1");
185        }
186        assert!(!update_checks_enabled(&settings));
187        unsafe {
188            env::remove_var("MODDE_NO_UPDATE_CHECK");
189        }
190    }
191
192    #[test]
193    fn config_opt_out_disables_checks() {
194        let _guard = ENV_LOCK.lock().unwrap();
195        unsafe {
196            env::remove_var("MODDE_NO_UPDATE_CHECK");
197        }
198        let mut settings = AppSettings::default();
199        settings.update_check.enabled = false;
200        assert!(!update_checks_enabled(&settings));
201    }
202}