#[cfg(test)]
mod tests;
use std::{
io::Write,
path::Path,
time::{Duration, SystemTime},
};
use semver::Version;
use serde::{Deserialize, Serialize};
const LATEST_URL: &str = "https://api.github.com/repos/lhj6102/artifactize/releases/latest";
const SITE: &str = "https://github.com/lhj6102/artifactize";
const TEST_LATEST_PATH: &str = "/api/releases/latest";
pub const OFF_VARIABLE: &str = "ARTIFACTIZE_NO_UPDATE_CHECK";
pub const TEST_ENDPOINT_VARIABLE: &str = "ARTIFACTIZE_RELEASES_URL";
const INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const RECHECK: Duration = Duration::from_secs(60 * 60);
const TEST_RECHECK: Duration = Duration::from_secs(1);
const TIMEOUT: Duration = Duration::from_secs(10);
const CACHE: &str = "release-check.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Channel {
Store,
Winget,
InstallSh,
InstallPs1,
Homebrew,
HomebrewCask,
Cargo,
Dmg,
Deb,
AppImage,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Update {
Command { command: String },
Download {
url: String,
#[serde(skip_serializing_if = "Option::is_none")]
command: Option<String>,
},
Page { url: String },
}
impl Channel {
pub fn update(self, latest: &Version) -> Option<Update> {
crate::platform::install::update(self, latest, SITE)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Unchecked {
Store,
Off,
Development,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Unchecked(Unchecked),
Latest(Version),
Newer { latest: Version, update: Update },
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Status {
pub running: Version,
pub channel: Channel,
pub outcome: Outcome,
}
struct Source {
url: String,
site: String,
}
pub async fn check(state: &Path) -> Status {
let running = running();
let channel = crate::platform::install::channel();
let outcome = match source(&running, channel) {
Ok(source) => look_up(&running, channel, &source, state, SystemTime::now()).await,
Err(outcome) => outcome,
};
Status {
running,
channel,
outcome,
}
}
pub fn recheck_interval() -> Duration {
if crate::platform::environment::var(TEST_ENDPOINT_VARIABLE)
.is_some_and(|value| !value.is_empty())
{
TEST_RECHECK
} else {
RECHECK
}
}
pub fn running() -> Version {
Version::parse(env!("CARGO_PKG_VERSION")).expect("the crate version is semantic")
}
fn source(running: &Version, channel: Channel) -> Result<Source, Outcome> {
if crate::platform::environment::var(OFF_VARIABLE).is_some_and(|value| !value.is_empty()) {
return Err(Outcome::Unchecked(Unchecked::Off));
}
if channel == Channel::Store {
return Err(Outcome::Unchecked(Unchecked::Store));
}
match crate::llm::variable_endpoint(TEST_ENDPOINT_VARIABLE) {
Ok(Some(site)) => Ok(Source {
url: format!("{site}{TEST_LATEST_PATH}"),
site: site.root().to_owned(),
}),
Ok(None) if running.pre.as_str() == "dev" => {
Err(Outcome::Unchecked(Unchecked::Development))
}
Ok(None) => Ok(Source {
url: LATEST_URL.into(),
site: SITE.into(),
}),
Err(error) => Err(Outcome::Failed(error)),
}
}
async fn look_up(
running: &Version,
channel: Channel,
source: &Source,
state: &Path,
now: SystemTime,
) -> Outcome {
match latest(source, state, now).await {
Ok(latest) => decide(running, channel, latest, &source.site),
Err(error) => Outcome::Failed(error),
}
}
fn decide(running: &Version, channel: Channel, latest: Version, site: &str) -> Outcome {
if latest <= *running {
return Outcome::Latest(latest);
}
match crate::platform::install::update(channel, &latest, site) {
Some(update) => Outcome::Newer { latest, update },
None => Outcome::Unchecked(Unchecked::Store),
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Cached {
#[serde(with = "epoch_seconds")]
checked_at: SystemTime,
site: String,
latest: Version,
}
async fn latest(source: &Source, state: &Path, now: SystemTime) -> Result<Version, String> {
let cache = state.join(CACHE);
if let Some(cached) = read_cache(&cache)
.filter(|cached| cached.site == source.site && fresh(cached.checked_at, now))
{
return Ok(cached.latest);
}
let latest = fetch(&source.url).await?;
let _ = write_cache(
state,
&Cached {
checked_at: now,
site: source.site.clone(),
latest: latest.clone(),
},
);
Ok(latest)
}
fn fresh(checked_at: SystemTime, now: SystemTime) -> bool {
now.duration_since(checked_at)
.is_ok_and(|age| age < INTERVAL)
}
mod epoch_seconds {
use serde::{Deserialize, Deserializer, Serializer, de::Error};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub fn serialize<S: Serializer>(time: &SystemTime, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_u64(
time.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_secs()),
)
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<SystemTime, D::Error> {
let seconds = u64::deserialize(deserializer)?;
UNIX_EPOCH
.checked_add(Duration::from_secs(seconds))
.ok_or_else(|| D::Error::custom("checkedAt is past any clock"))
}
}
fn read_cache(path: &Path) -> Option<Cached> {
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
}
fn write_cache(state: &Path, cached: &Cached) -> Result<(), String> {
let mut file = tempfile::NamedTempFile::new_in(state).map_err(|e| e.to_string())?;
serde_json::to_writer(&mut file, cached).map_err(|e| e.to_string())?;
file.flush().map_err(|e| e.to_string())?;
file.persist(state.join(CACHE))
.map_err(|e| e.error.to_string())?;
Ok(())
}
async fn fetch(url: &str) -> Result<Version, String> {
#[derive(Deserialize)]
struct Latest {
tag_name: String,
}
let client = reqwest::Client::builder()
.timeout(TIMEOUT)
.user_agent(concat!("artifactize/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| e.to_string())?;
let latest: Latest = client
.get(url)
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|e| e.without_url().to_string())?
.json()
.await
.map_err(|e| e.without_url().to_string())?;
let tag = &latest.tag_name;
Version::parse(tag.strip_prefix('v').unwrap_or(tag))
.map_err(|e| format!("the latest release's tag {tag:?} is not a version: {e}"))
}