use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::Result;
use serde::{Deserialize, Serialize};
const CACHE_FOR: Duration = Duration::from_hours(24);
const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
const ENDPOINT: &str = "https://crates.io/api/v1/crates/mirador";
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Cache {
latest: Option<String>,
checked_at: Option<i64>,
}
pub type Found = Arc<Mutex<Option<String>>>;
pub fn spawn(enabled: bool, path: Option<PathBuf>) -> Found {
let found: Found = Arc::new(Mutex::new(None));
if !enabled || opted_out() {
return found;
}
let Some(path) = path else {
return found;
};
let cache = read_cache(&path);
if let Some(latest) = cache.latest.as_deref()
&& is_newer(latest, current())
{
set(&found, Some(latest.to_string()));
}
if fresh(&cache) {
return found;
}
let shared = Arc::clone(&found);
let _ = std::thread::Builder::new()
.name("mirador-update".into())
.spawn(move || {
let Ok(latest) = fetch(ENDPOINT) else {
return;
};
let _ = write_cache(
&path,
&Cache {
latest: Some(latest.clone()),
checked_at: Some(now_seconds()),
},
);
if is_newer(&latest, current()) {
set(&shared, Some(latest));
}
});
found
}
fn opted_out() -> bool {
opted_out_given(|key| std::env::var(key).ok())
}
fn opted_out_given(read: impl Fn(&str) -> Option<String>) -> bool {
["NO_UPDATE_CHECK", "DO_NOT_TRACK"]
.iter()
.filter_map(|key| read(key))
.any(|value| !value.is_empty() && value != "0")
}
fn set(slot: &Found, value: Option<String>) {
match slot.lock() {
Ok(mut guard) => *guard = value,
Err(poisoned) => *poisoned.into_inner() = value,
}
}
fn current() -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn now_seconds() -> i64 {
jiff::Timestamp::now().as_second()
}
fn fresh(cache: &Cache) -> bool {
let Some(checked_at) = cache.checked_at else {
return false;
};
let age = now_seconds().saturating_sub(checked_at);
(0..CACHE_FOR.as_secs().cast_signed()).contains(&age)
}
fn read_cache(path: &Path) -> Cache {
std::fs::read_to_string(path)
.ok()
.and_then(|text| toml::from_str(&text).ok())
.unwrap_or_default()
}
fn write_cache(path: &Path, cache: &Cache) -> Result<()> {
let body = toml::to_string_pretty(cache)?;
crate::store::write_atomic(
path,
&format!(
"# Written by mirador's update check, which is off unless\n\
# `[general].check_for_updates` turns it on. Safe to delete.\n\n{body}"
),
)
}
fn fetch(url: &str) -> Result<String> {
#[derive(Deserialize)]
struct Body {
#[serde(rename = "crate")]
krate: Krate,
}
#[derive(Deserialize)]
struct Krate {
max_stable_version: Option<String>,
max_version: Option<String>,
}
let agent = ureq::Agent::config_builder()
.timeout_global(Some(HTTP_TIMEOUT))
.user_agent(concat!(
"mirador/",
env!("CARGO_PKG_VERSION"),
" (update check; https://github.com/jchultarsky/mirador)"
))
.build()
.new_agent();
let text = agent.get(url).call()?.body_mut().read_to_string()?;
let body: Body = serde_json::from_str(&text)?;
body.krate
.max_stable_version
.or(body.krate.max_version)
.ok_or_else(|| anyhow::anyhow!("no version in the response"))
}
fn is_newer(candidate: &str, installed: &str) -> bool {
let (Some(candidate), Some(installed)) = (triple(candidate), triple(installed)) else {
return false;
};
candidate > installed
}
fn triple(version: &str) -> Option<(u64, u64, u64)> {
let version = version.trim();
if version.contains(['-', '+']) {
return None;
}
let mut parts = version.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
pub fn default_path(data_dir: &Path) -> PathBuf {
data_dir.join("update-check.toml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_higher_version_in_any_position_is_newer() {
assert!(is_newer("0.7.2", "0.7.1"));
assert!(is_newer("0.8.0", "0.7.9"));
assert!(is_newer("1.0.0", "0.99.99"));
assert!(is_newer("0.10.0", "0.9.0"), "10 is not less than 9");
}
#[test]
fn the_same_or_an_older_version_is_not_newer() {
assert!(!is_newer("0.7.1", "0.7.1"));
assert!(!is_newer("0.7.0", "0.7.1"));
assert!(!is_newer("0.6.9", "0.7.0"));
}
#[test]
fn a_prerelease_never_counts_as_newer() {
assert!(!is_newer("0.8.0-rc.1", "0.7.0"));
assert!(!is_newer("0.8.0-rc.1", "0.8.0"));
assert!(!is_newer("0.8.0+build.5", "0.7.0"));
}
#[test]
fn an_unparseable_version_is_never_newer() {
for junk in ["", "latest", "0.7", "0.7.1.2", "v0.7.2", "not a version"] {
assert!(!is_newer(junk, "0.7.1"), "{junk:?} was treated as newer");
}
assert!(!is_newer("0.7.2", "garbage"));
}
#[test]
fn a_cache_is_fresh_for_a_day_and_then_is_not() {
let at = |seconds_ago: i64| Cache {
latest: Some("0.7.1".into()),
checked_at: Some(now_seconds() - seconds_ago),
};
assert!(fresh(&at(0)));
assert!(fresh(&at(60 * 60 * 23)));
assert!(!fresh(&at(60 * 60 * 25)));
assert!(!fresh(&Cache::default()), "never checked is not fresh");
}
#[test]
fn a_clock_that_moved_backwards_makes_the_cache_stale_rather_than_eternal() {
let future = Cache {
latest: Some("0.7.1".into()),
checked_at: Some(now_seconds() + 60 * 60 * 24 * 365),
};
assert!(!fresh(&future));
}
#[test]
fn the_environment_can_veto_the_config() {
let env = |set: &'static str, value: &'static str| {
move |key: &str| (key == set).then(|| value.to_string())
};
for key in ["NO_UPDATE_CHECK", "DO_NOT_TRACK"] {
assert!(opted_out_given(env(key, "1")), "{key} was ignored");
assert!(opted_out_given(env(key, "true")), "{key}=true was ignored");
assert!(
!opted_out_given(env(key, "0")),
"{key}=0 should not opt out"
);
assert!(!opted_out_given(env(key, "")), "{key}= should not opt out");
}
assert!(!opted_out_given(|_| None), "nothing set is not opted out");
}
#[test]
fn nothing_is_spawned_when_the_check_is_off() {
let found = spawn(false, Some(PathBuf::from("/nonexistent/update-check.toml")));
assert!(found.lock().unwrap().is_none());
}
#[test]
fn an_unreadable_cache_is_treated_as_no_cache() {
assert!(
read_cache(Path::new("/nonexistent/nope.toml"))
.checked_at
.is_none()
);
}
#[test]
fn a_cache_survives_a_round_trip() {
let dir = std::env::temp_dir().join(format!("mirador-update-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = default_path(&dir);
let written = Cache {
latest: Some("9.9.9".into()),
checked_at: Some(now_seconds()),
};
write_cache(&path, &written).unwrap();
let read = read_cache(&path);
assert_eq!(read.latest.as_deref(), Some("9.9.9"));
assert!(fresh(&read));
let _ = std::fs::remove_dir_all(&dir);
}
}