use std::time::Duration;
const CACHE_TTL: Duration = Duration::from_secs(86400); const CACHE_KEY: &str = "latest-version";
#[derive(Clone, Debug)]
pub struct UpdateInfo {
pub current: String,
pub latest: String,
pub url: String,
}
impl UpdateInfo {
pub fn message(&self) -> String {
format!(
"Update available: {} -> {} ({})",
self.current, self.latest, self.url
)
}
}
#[derive(Debug)]
pub struct UpdateChecker {
app_name: String,
current_version: String,
repo: String,
env_suppress: String,
}
impl UpdateChecker {
pub fn new(app_name: &str, current_version: &str, repo: &str) -> Self {
let prefix = app_name.to_uppercase().replace('-', "_");
Self {
app_name: app_name.to_string(),
current_version: current_version.to_string(),
repo: repo.to_string(),
env_suppress: format!("{prefix}_NO_UPDATE_CHECK"),
}
}
pub fn is_suppressed(&self) -> bool {
std::env::var(&self.env_suppress)
.ok()
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
pub fn app_name(&self) -> &str {
&self.app_name
}
pub fn current_version(&self) -> &str {
&self.current_version
}
#[tracing::instrument(skip(self), fields(app = %self.app_name, current = %self.current_version))]
pub async fn check(&self) -> Option<UpdateInfo> {
if self.is_suppressed() {
tracing::debug!("update check suppressed by env");
return None;
}
if let Some(cache) = crate::cache::Cache::default_for(&self.app_name)
&& let Ok(Some(cached)) = cache.get(CACHE_KEY)
&& let Ok(version) = String::from_utf8(cached)
{
tracing::debug!(cached_version = %version, "using cached version check");
return self.compare_versions(&version);
}
let url = format!("https://api.github.com/repos/{}/releases/latest", self.repo);
let client =
crate::http::HttpClient::from_app(&self.app_name, &self.current_version).ok()?;
let resp = match client.get(&url).await {
Ok(r) => r,
Err(e) => {
tracing::debug!(error = %e, "update check failed");
return None;
}
};
if !resp.is_success() {
tracing::debug!(status = resp.status, "GitHub API returned non-200");
return None;
}
let json: serde_json::Value = match resp.json() {
Ok(v) => v,
Err(e) => {
tracing::debug!(error = %e, "failed to parse release response");
return None;
}
};
let tag = json.get("tag_name")?.as_str()?;
let latest = tag.strip_prefix('v').unwrap_or(tag);
let html_url = json.get("html_url")?.as_str().unwrap_or("");
if let Some(cache) = crate::cache::Cache::default_for(&self.app_name) {
let _ = cache.set(CACHE_KEY, latest.as_bytes(), CACHE_TTL);
}
self.compare_versions_with_url(latest, html_url)
}
fn compare_versions(&self, latest: &str) -> Option<UpdateInfo> {
let url = format!("https://github.com/{}/releases/tag/v{}", self.repo, latest);
self.compare_versions_with_url(latest, &url)
}
fn compare_versions_with_url(&self, latest: &str, url: &str) -> Option<UpdateInfo> {
if is_newer(&self.current_version, latest) {
Some(UpdateInfo {
current: self.current_version.clone(),
latest: latest.to_string(),
url: url.to_string(),
})
} else {
None
}
}
}
pub fn is_newer(current: &str, latest: &str) -> bool {
let parse = |v: &str| -> Vec<u64> { v.split('.').map(|s| s.parse().unwrap_or(0)).collect() };
let curr = parse(current);
let lat = parse(latest);
for i in 0..curr.len().max(lat.len()) {
let c = curr.get(i).copied().unwrap_or(0);
let l = lat.get(i).copied().unwrap_or(0);
if l > c {
return true;
}
if l < c {
return false;
}
}
false
}