use chrono::Utc;
use color_eyre::eyre::Context;
use reqwest::header;
use semver::Version;
use serde::Deserialize;
use tracing::{Instrument, instrument};
use super::IntelliShellService;
use crate::{
errors::{Result, UserFacingError},
storage::SqliteStorage,
};
#[derive(Debug)]
pub(super) enum VersionCheckState {
NotStarted,
InProgress,
Finished(Option<Version>),
}
impl IntelliShellService {
#[instrument(skip_all)]
pub fn poll_new_version(&self) -> Option<Version> {
let mut state = self.version_check_state.lock().expect("poisoned lock");
match &*state {
VersionCheckState::Finished(version) => version.clone(),
VersionCheckState::InProgress => None,
VersionCheckState::NotStarted => {
if !self.check_updates {
tracing::debug!("Skipping version check as it's disabled in the configuration");
*state = VersionCheckState::Finished(None);
return None;
}
*state = VersionCheckState::InProgress;
tracing::trace!("Spawning background task for version check");
drop(state);
let storage = self.storage.clone();
let state_clone = self.version_check_state.clone();
tokio::spawn(
async move {
let result = fetch_latest_version(&storage).await;
let mut state = state_clone.lock().expect("poisoned lock");
match result {
Ok(version) => {
if let Some(ref v) = version {
tracing::info!("New version available: v{v}");
} else {
tracing::debug!("No new version available");
}
*state = VersionCheckState::Finished(version);
}
Err(err) => {
tracing::error!("Failed to check for new version: {err:#?}");
*state = VersionCheckState::Finished(None);
}
}
}
.instrument(tracing::info_span!("bg")),
);
None
}
}
}
}
async fn fetch_latest_version(storage: &SqliteStorage) -> Result<Option<Version>> {
let now = Utc::now();
let current = &super::CURRENT_VERSION;
let (latest, checked_at) = storage.get_version_info().await?;
if (now - checked_at) < super::FETCH_INTERVAL {
tracing::debug!("Skipping version retrieval as it was checked recently, latest: v{latest}");
return Ok(Some(latest).filter(|v| v > current));
}
#[derive(Deserialize, Debug)]
struct Release {
tag_name: String,
}
let res = reqwest::Client::new()
.get("https://api.github.com/repos/lasantosr/intelli-shell/releases/latest")
.header(header::USER_AGENT, "intelli-shell")
.timeout(super::REQUEST_TIMEOUT)
.send()
.await
.map_err(|err| {
tracing::error!("{err:?}");
UserFacingError::LatestVersionRequestFailed(err.to_string())
})?;
if !res.status().is_success() {
let status = res.status();
let status_str = status.as_str();
let body = res.text().await.unwrap_or_default();
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(|s| s.to_string()))
.unwrap_or_else(|| format!("received {status_str} response"));
if let Some(reason) = status.canonical_reason() {
tracing::error!("Got response [{status_str}] {reason}:\n{body}");
return Err(UserFacingError::LatestVersionRequestFailed(message).into());
} else {
tracing::error!("Got response [{status_str}]:\n{body}");
return Err(UserFacingError::LatestVersionRequestFailed(message).into());
}
}
let release: Release = res.json().await.wrap_err("Failed to parse latest release response")?;
let tag_version = release.tag_name.trim_start_matches('v');
let latest = Version::parse(tag_version)
.wrap_err_with(|| format!("Failed to parse latest version from tag: {tag_version}"))?;
tracing::debug!("Latest version fetched: v{latest}");
storage.update_version_info(latest.clone(), now).await?;
Ok(Some(latest).filter(|v| v > current))
}