intelli_shell/service/
version.rs1use chrono::Utc;
2use color_eyre::eyre::Context;
3use reqwest::header;
4use semver::Version;
5use serde::Deserialize;
6use tracing::{Instrument, instrument};
7
8use super::IntelliShellService;
9use crate::{
10 errors::{Result, UserFacingError},
11 storage::SqliteStorage,
12};
13
14#[derive(Debug)]
16pub(super) enum VersionCheckState {
17 NotStarted,
19 InProgress,
21 Finished(Option<Version>),
23}
24
25impl IntelliShellService {
26 #[instrument(skip_all)]
32 pub fn poll_new_version(&self) -> Option<Version> {
33 let mut state = self.version_check_state.lock().expect("poisoned lock");
35
36 match &*state {
37 VersionCheckState::Finished(version) => version.clone(),
39
40 VersionCheckState::InProgress => None,
42
43 VersionCheckState::NotStarted => {
45 if !self.check_updates {
47 tracing::debug!("Skipping version check as it's disabled in the configuration");
48 *state = VersionCheckState::Finished(None);
49 return None;
50 }
51
52 *state = VersionCheckState::InProgress;
54 tracing::trace!("Spawning background task for version check");
55 drop(state);
56
57 let storage = self.storage.clone();
58 let state_clone = self.version_check_state.clone();
59 tokio::spawn(
60 async move {
61 let result = fetch_latest_version(&storage).await;
62
63 let mut state = state_clone.lock().expect("poisoned lock");
65 match result {
66 Ok(version) => {
67 if let Some(ref v) = version {
68 tracing::info!("New version available: v{v}");
69 } else {
70 tracing::debug!("No new version available");
71 }
72 *state = VersionCheckState::Finished(version);
73 }
74 Err(err) => {
75 tracing::error!("Failed to check for new version: {err:#?}");
76 *state = VersionCheckState::Finished(None);
77 }
78 }
79 }
80 .instrument(tracing::info_span!("bg")),
81 );
82
83 None
84 }
85 }
86 }
87}
88
89async fn fetch_latest_version(storage: &SqliteStorage) -> Result<Option<Version>> {
91 let now = Utc::now();
93 let current = &super::CURRENT_VERSION;
94
95 let (latest, checked_at) = storage.get_version_info().await?;
96
97 if (now - checked_at) < super::FETCH_INTERVAL {
99 tracing::debug!("Skipping version retrieval as it was checked recently, latest: v{latest}");
100 return Ok(Some(latest).filter(|v| v > current));
101 }
102
103 #[derive(Deserialize, Debug)]
105 struct Release {
106 tag_name: String,
107 }
108
109 let res = reqwest::Client::new()
111 .get("https://api.github.com/repos/lasantosr/intelli-shell/releases/latest")
112 .header(header::USER_AGENT, "intelli-shell")
113 .timeout(super::REQUEST_TIMEOUT)
114 .send()
115 .await
116 .map_err(|err| {
117 tracing::error!("{err:?}");
118 UserFacingError::LatestVersionRequestFailed(err.to_string())
119 })?;
120
121 if !res.status().is_success() {
122 let status = res.status();
123 let status_str = status.as_str();
124 let body = res.text().await.unwrap_or_default();
125 let message = serde_json::from_str::<serde_json::Value>(&body)
126 .ok()
127 .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(|s| s.to_string()))
128 .unwrap_or_else(|| format!("received {status_str} response"));
129 if let Some(reason) = status.canonical_reason() {
130 tracing::error!("Got response [{status_str}] {reason}:\n{body}");
131 return Err(UserFacingError::LatestVersionRequestFailed(message).into());
132 } else {
133 tracing::error!("Got response [{status_str}]:\n{body}");
134 return Err(UserFacingError::LatestVersionRequestFailed(message).into());
135 }
136 }
137
138 let release: Release = res.json().await.wrap_err("Failed to parse latest release response")?;
139
140 let tag_version = release.tag_name.trim_start_matches('v');
142 let latest = Version::parse(tag_version)
143 .wrap_err_with(|| format!("Failed to parse latest version from tag: {tag_version}"))?;
144
145 tracing::debug!("Latest version fetched: v{latest}");
146
147 storage.update_version_info(latest.clone(), now).await?;
149
150 Ok(Some(latest).filter(|v| v > current))
152}