use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const CRATE_NAME: &str = "trackWork";
const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
const HTTP_TIMEOUT: Duration = Duration::from_secs(4);
pub type UpdateSlot = Arc<Mutex<Option<String>>>;
pub fn spawn_check() -> UpdateSlot {
let slot: UpdateSlot = Arc::new(Mutex::new(None));
let slot_for_thread = slot.clone();
std::thread::spawn(move || {
if let Some(latest) = check_blocking() {
if is_newer(&latest, env!("CARGO_PKG_VERSION")) {
if let Ok(mut g) = slot_for_thread.lock() {
*g = Some(latest);
}
}
}
});
slot
}
fn check_blocking() -> Option<String> {
if let Some(cached) = read_cache_if_fresh() {
return Some(cached);
}
let fetched = fetch_latest_from_crates_io()?;
let _ = write_cache(&fetched);
Some(fetched)
}
fn fetch_latest_from_crates_io() -> Option<String> {
let url = format!("https://crates.io/api/v1/crates/{}", CRATE_NAME);
let ua = format!("trackWork/{} (https://crates.io/crates/trackWork)", env!("CARGO_PKG_VERSION"));
let client = reqwest::blocking::Client::builder()
.timeout(HTTP_TIMEOUT)
.user_agent(ua)
.build()
.ok()?;
let resp = client.get(url).send().ok()?;
if !resp.status().is_success() {
return None;
}
let json: serde_json::Value = resp.json().ok()?;
json.get("crate")
.and_then(|c| {
c.get("max_stable_version")
.or_else(|| c.get("max_version"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn cache_path() -> Option<PathBuf> {
dirs::home_dir().map(|p| p.join(".timetrack.update_check"))
}
fn read_cache_if_fresh() -> Option<String> {
let path = cache_path()?;
let text = std::fs::read_to_string(&path).ok()?;
let mut checked_at: Option<u64> = None;
let mut latest: Option<String> = None;
for line in text.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("checked_at = ") {
checked_at = rest.trim().parse().ok();
} else if let Some(rest) = line.strip_prefix("latest = ") {
latest = Some(rest.trim().trim_matches('"').to_string());
}
}
let checked = checked_at?;
let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
if now.saturating_sub(checked) > CACHE_TTL_SECS {
return None;
}
latest.filter(|s| !s.is_empty())
}
fn write_cache(latest: &str) -> std::io::Result<()> {
let Some(path) = cache_path() else {
return Ok(());
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let body = format!("checked_at = {}\nlatest = \"{}\"\n", now, latest);
std::fs::write(path, body)
}
fn is_newer(remote: &str, local: &str) -> bool {
if remote.contains('-') {
return false;
}
let parse = |s: &str| -> Option<(u32, u32, u32)> {
let core = s.split(['-', '+']).next()?;
let mut it = core.split('.');
let a = it.next()?.parse().ok()?;
let b = it.next()?.parse().ok()?;
let c = it.next().unwrap_or("0").parse().ok()?;
Some((a, b, c))
};
match (parse(remote), parse(local)) {
(Some(r), Some(l)) => r > l,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::is_newer;
#[test]
fn newer_versions_detected() {
assert!(is_newer("0.14.3", "0.14.2"));
assert!(is_newer("0.15.0", "0.14.99"));
assert!(is_newer("1.0.0", "0.99.99"));
}
#[test]
fn same_or_older_not_newer() {
assert!(!is_newer("0.14.2", "0.14.2"));
assert!(!is_newer("0.14.1", "0.14.2"));
assert!(!is_newer("0.13.99", "0.14.0"));
}
#[test]
fn prereleases_ignored() {
assert!(!is_newer("0.14.3-rc.1", "0.14.2"));
}
}