1mod apply;
15mod channel;
16mod github;
17mod platform;
18mod provider;
19mod state;
20
21pub use channel::InstallChannel;
22pub use provider::{LatestRelease, ReleaseProvider};
23pub use state::UpdateState;
24
25fn provider() -> impl ReleaseProvider {
35 github::GitHubProvider
36}
37
38pub fn releases_url() -> &'static str {
41 provider().releases_url()
42}
43
44use chrono::{Duration, Utc};
45use std::path::Path;
46
47pub const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
49
50pub(crate) const USER_AGENT: &str = concat!("kimun/", env!("CARGO_PKG_VERSION"));
53
54const CHECK_INTERVAL_HOURS: i64 = 24;
56
57pub(crate) fn http_get(url: &str) -> Result<ureq::Response, UpdateError> {
59 Ok(ureq::get(url)
60 .set("User-Agent", USER_AGENT)
61 .set("Accept", "application/vnd.github+json")
62 .call()?)
63}
64
65#[derive(Debug, Clone)]
67pub struct UpdateStatus {
68 pub current: String,
70 pub latest: String,
72 pub channel: InstallChannel,
74 pub update_available: bool,
76 pub dismissed: bool,
78}
79
80impl UpdateStatus {
81 pub fn should_notify(&self) -> bool {
84 self.update_available && !self.dismissed
85 }
86}
87
88pub fn check(config_dir: &Path, force: bool) -> Result<Option<UpdateStatus>, UpdateError> {
96 if force {
99 let release = provider().latest_stable()?;
100 return Ok(Some(status_for(config_dir, &release)));
101 }
102 let st = UpdateState::load(config_dir);
103 if st.is_stale(Utc::now(), Duration::hours(CHECK_INTERVAL_HOURS)) {
104 let release = provider().latest_stable()?;
105 Ok(Some(status_for(config_dir, &release)))
106 } else {
107 Ok(st
108 .latest_version
109 .as_deref()
110 .map(|v| build_status(config_dir, &st, v)))
111 }
112}
113
114fn build_status(config_dir: &Path, st: &UpdateState, version: &str) -> UpdateStatus {
117 UpdateStatus {
118 current: CURRENT_VERSION.to_string(),
119 update_available: is_newer(version, CURRENT_VERSION),
120 dismissed: st.dismissed_version.as_deref() == Some(version),
121 channel: channel::detect(config_dir),
122 latest: version.to_string(),
123 }
124}
125
126pub fn status_for(config_dir: &Path, latest: &LatestRelease) -> UpdateStatus {
130 let mut st = UpdateState::load(config_dir);
131 st.last_check = Some(Utc::now());
132 st.latest_version = Some(latest.version.clone());
133 if let Err(e) = st.save(config_dir) {
135 tracing::warn!("could not save update state: {e}");
136 }
137 build_status(config_dir, &st, &latest.version)
138}
139
140pub fn fetch_latest() -> Result<LatestRelease, UpdateError> {
143 provider().latest_stable()
144}
145
146pub fn apply(latest: &LatestRelease) -> Result<(), UpdateError> {
151 apply::self_update(latest)
152}
153
154async fn run_blocking<T, F>(f: F) -> Result<T, UpdateError>
158where
159 F: FnOnce() -> Result<T, UpdateError> + Send + 'static,
160 T: Send + 'static,
161{
162 match tokio::task::spawn_blocking(f).await {
163 Ok(result) => result,
164 Err(e) => Err(UpdateError::Task(e.to_string())),
165 }
166}
167
168pub async fn check_now(
171 config_dir: std::path::PathBuf,
172 force: bool,
173) -> Result<Option<UpdateStatus>, UpdateError> {
174 run_blocking(move || check(&config_dir, force)).await
175}
176
177pub async fn latest_release() -> Result<LatestRelease, UpdateError> {
179 run_blocking(fetch_latest).await
180}
181
182pub async fn install(latest: LatestRelease) -> Result<(), UpdateError> {
184 run_blocking(move || apply(&latest)).await
185}
186
187pub fn dismiss(config_dir: &Path, version: &str) -> std::io::Result<()> {
190 let mut st = UpdateState::load(config_dir);
191 st.dismissed_version = Some(version.to_string());
192 st.save(config_dir)
193}
194
195fn is_newer(candidate: &str, current: &str) -> bool {
198 match (parse_version(candidate), parse_version(current)) {
199 (Some(c), Some(cur)) => c > cur,
200 _ => false,
201 }
202}
203
204fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
208 let mut parts = v.split('.');
209 let major = parts.next()?.parse().ok()?;
210 let minor = parts.next()?.parse().ok()?;
211 let patch = parts.next()?.parse().ok()?;
212 if parts.next().is_some() {
213 return None;
214 }
215 Some((major, minor, patch))
216}
217
218#[derive(Debug)]
220pub enum UpdateError {
221 Http(Box<ureq::Error>),
223 Io(std::io::Error),
225 Parse(serde_json::Error),
227 NoRelease,
229 UnsupportedPlatform,
231 MissingAsset(String),
233 NoChecksum(String),
235 ChecksumMismatch { expected: String, actual: String },
237 Replace(std::io::Error),
239 Task(String),
241}
242
243impl std::fmt::Display for UpdateError {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self {
246 Self::Http(e) => write!(f, "network error: {e}"),
247 Self::Io(e) => write!(f, "I/O error: {e}"),
248 Self::Parse(e) => write!(f, "could not parse GitHub response: {e}"),
249 Self::NoRelease => write!(f, "no stable release found"),
250 Self::UnsupportedPlatform => {
251 write!(f, "no self-update binary is published for this platform")
252 }
253 Self::MissingAsset(name) => write!(f, "release is missing asset: {name}"),
254 Self::NoChecksum(name) => write!(f, "no checksum published for {name}"),
255 Self::ChecksumMismatch { expected, actual } => {
256 write!(f, "checksum mismatch (expected {expected}, got {actual})")
257 }
258 Self::Replace(e) => write!(f, "could not replace the running binary: {e}"),
259 Self::Task(e) => write!(f, "update task failed: {e}"),
260 }
261 }
262}
263
264impl std::error::Error for UpdateError {}
265
266impl From<ureq::Error> for UpdateError {
267 fn from(e: ureq::Error) -> Self {
268 Self::Http(Box::new(e))
269 }
270}
271
272impl From<std::io::Error> for UpdateError {
273 fn from(e: std::io::Error) -> Self {
274 Self::Io(e)
275 }
276}
277
278impl From<serde_json::Error> for UpdateError {
279 fn from(e: serde_json::Error) -> Self {
280 Self::Parse(e)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 #[test]
289 fn newer_versions_compare_correctly() {
290 assert!(is_newer("0.18.0", "0.17.0"));
291 assert!(is_newer("1.0.0", "0.99.99"));
292 assert!(is_newer("0.17.1", "0.17.0"));
293 assert!(!is_newer("0.17.0", "0.17.0"));
294 assert!(!is_newer("0.16.0", "0.17.0"));
295 }
296
297 #[test]
298 fn unparseable_versions_never_nudge() {
299 assert!(!is_newer("garbage", "0.17.0"));
300 assert!(!is_newer("0.18.0-beta.1", "0.17.0"));
301 assert!(!is_newer("0.18", "0.17.0"));
302 }
303}