mod apply;
mod channel;
mod github;
mod platform;
mod provider;
mod state;
pub use channel::InstallChannel;
pub use provider::{LatestRelease, ReleaseProvider};
pub use state::UpdateState;
fn provider() -> impl ReleaseProvider {
github::GitHubProvider
}
pub fn releases_url() -> &'static str {
provider().releases_url()
}
use chrono::{Duration, Utc};
use std::path::Path;
pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
pub(crate) const USER_AGENT: &str = concat!("kimun/", env!("CARGO_PKG_VERSION"));
const CHECK_INTERVAL_HOURS: i64 = 24;
pub(crate) fn http_get(url: &str) -> Result<ureq::Response, UpdateError> {
Ok(ureq::get(url)
.set("User-Agent", USER_AGENT)
.set("Accept", "application/vnd.github+json")
.call()?)
}
#[derive(Debug, Clone)]
pub struct UpdateStatus {
pub current: String,
pub latest: String,
pub channel: InstallChannel,
pub update_available: bool,
pub dismissed: bool,
}
impl UpdateStatus {
pub fn should_notify(&self) -> bool {
self.update_available && !self.dismissed
}
}
pub fn check(config_dir: &Path, force: bool) -> Result<Option<UpdateStatus>, UpdateError> {
if force {
let release = provider().latest_stable()?;
return Ok(Some(status_for(config_dir, &release)));
}
let st = UpdateState::load(config_dir);
if st.is_stale(Utc::now(), Duration::hours(CHECK_INTERVAL_HOURS)) {
let release = provider().latest_stable()?;
Ok(Some(status_for(config_dir, &release)))
} else {
Ok(st
.latest_version
.as_deref()
.map(|v| build_status(config_dir, &st, v)))
}
}
fn build_status(config_dir: &Path, st: &UpdateState, version: &str) -> UpdateStatus {
UpdateStatus {
current: CURRENT_VERSION.to_string(),
update_available: is_newer(version, CURRENT_VERSION),
dismissed: st.dismissed_version.as_deref() == Some(version),
channel: channel::detect(config_dir),
latest: version.to_string(),
}
}
pub fn status_for(config_dir: &Path, latest: &LatestRelease) -> UpdateStatus {
let mut st = UpdateState::load(config_dir);
st.last_check = Some(Utc::now());
st.latest_version = Some(latest.version.clone());
if let Err(e) = st.save(config_dir) {
tracing::warn!("could not save update state: {e}");
}
build_status(config_dir, &st, &latest.version)
}
pub fn fetch_latest() -> Result<LatestRelease, UpdateError> {
provider().latest_stable()
}
pub fn apply(latest: &LatestRelease) -> Result<(), UpdateError> {
apply::self_update(latest)
}
async fn run_blocking<T, F>(f: F) -> Result<T, UpdateError>
where
F: FnOnce() -> Result<T, UpdateError> + Send + 'static,
T: Send + 'static,
{
match tokio::task::spawn_blocking(f).await {
Ok(result) => result,
Err(e) => Err(UpdateError::Task(e.to_string())),
}
}
pub async fn check_now(
config_dir: std::path::PathBuf,
force: bool,
) -> Result<Option<UpdateStatus>, UpdateError> {
run_blocking(move || check(&config_dir, force)).await
}
pub async fn latest_release() -> Result<LatestRelease, UpdateError> {
run_blocking(fetch_latest).await
}
pub async fn install(latest: LatestRelease) -> Result<(), UpdateError> {
run_blocking(move || apply(&latest)).await
}
pub fn dismiss(config_dir: &Path, version: &str) -> std::io::Result<()> {
let mut st = UpdateState::load(config_dir);
st.dismissed_version = Some(version.to_string());
st.save(config_dir)
}
fn is_newer(candidate: &str, current: &str) -> bool {
match (parse_version(candidate), parse_version(current)) {
(Some(c), Some(cur)) => c > cur,
_ => false,
}
}
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let mut parts = v.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))
}
#[derive(Debug)]
pub enum UpdateError {
Http(Box<ureq::Error>),
Io(std::io::Error),
Parse(serde_json::Error),
NoRelease,
UnsupportedPlatform,
MissingAsset(String),
NoChecksum(String),
ChecksumMismatch { expected: String, actual: String },
Replace(std::io::Error),
Task(String),
}
impl std::fmt::Display for UpdateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Http(e) => write!(f, "network error: {e}"),
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Parse(e) => write!(f, "could not parse GitHub response: {e}"),
Self::NoRelease => write!(f, "no stable release found"),
Self::UnsupportedPlatform => {
write!(f, "no self-update binary is published for this platform")
}
Self::MissingAsset(name) => write!(f, "release is missing asset: {name}"),
Self::NoChecksum(name) => write!(f, "no checksum published for {name}"),
Self::ChecksumMismatch { expected, actual } => {
write!(f, "checksum mismatch (expected {expected}, got {actual})")
}
Self::Replace(e) => write!(f, "could not replace the running binary: {e}"),
Self::Task(e) => write!(f, "update task failed: {e}"),
}
}
}
impl std::error::Error for UpdateError {}
impl From<ureq::Error> for UpdateError {
fn from(e: ureq::Error) -> Self {
Self::Http(Box::new(e))
}
}
impl From<std::io::Error> for UpdateError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<serde_json::Error> for UpdateError {
fn from(e: serde_json::Error) -> Self {
Self::Parse(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn newer_versions_compare_correctly() {
assert!(is_newer("0.18.0", "0.17.0"));
assert!(is_newer("1.0.0", "0.99.99"));
assert!(is_newer("0.17.1", "0.17.0"));
assert!(!is_newer("0.17.0", "0.17.0"));
assert!(!is_newer("0.16.0", "0.17.0"));
}
#[test]
fn unparseable_versions_never_nudge() {
assert!(!is_newer("garbage", "0.17.0"));
assert!(!is_newer("0.18.0-beta.1", "0.17.0"));
assert!(!is_newer("0.18", "0.17.0"));
}
}