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