modde_core/
update_check.rs1use 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}