1use crate::libs::data_storage::DataStorage;
19use crate::libs::messages::Message;
20use crate::{msg_bail_anyhow, msg_error_anyhow, msg_info};
21use anyhow::Result;
22use chrono::{DateTime, Duration, Utc};
23use flate2::read::GzDecoder;
24use reqwest::Client;
25use std::env;
26use std::fs::{self, File};
27use std::path::{Path, PathBuf};
28use tar::Archive;
29
30include!(concat!(env!("OUT_DIR"), "/app_metadata.rs"));
32
33const LAST_CHECK_FILE: &str = ".last_update_check";
35
36const DAILY_CHECK_INTERVAL: i64 = 1;
38
39const BACKUP_EXTENSION: &str = "bak";
41
42#[derive(Debug)]
45pub struct Updater {
46 pub client: Client,
47
48 pub owner: String,
50
51 pub name: String,
53
54 pub version: String,
56
57 pub latest_version: Option<String>,
59
60 pub download_url: Option<String>,
62
63 releases_url: String,
70
71 last_check_file: PathBuf,
73}
74
75impl Updater {
76 pub fn new() -> Result<Self> {
88 let owner = APP_METADATA_OWNER.to_owned();
89 let name = APP_METADATA_NAME.to_owned();
90
91 let last_check_file = DataStorage::new().get_path(LAST_CHECK_FILE)?;
92
93 let releases_url = format!("https://github.com/{}/{}/releases/latest", owner, name);
95
96 Ok(Self {
97 client: Client::new(),
98 owner,
99 name,
100 version: APP_METADATA_VERSION.to_owned(),
101 latest_version: None,
102 download_url: None,
103 last_check_file,
104 releases_url,
105 })
106 }
107
108 pub async fn show_update_notification() {
121 let mut updater = match Self::new() {
122 Ok(up) => up,
123 Err(_) => return,
124 };
125
126 if !updater.is_check_due() {
127 return;
128 }
129
130 if let Ok(true) = updater.check_for_latest_release().await
131 && let Some(latest_version) = &updater.latest_version
132 {
133 msg_info!(
134 Message::UpdateAvailable {
135 app_name: updater.name,
136 latest: latest_version.to_string()
137 },
138 true )
140 }
141 }
142
143 pub async fn perform_update(&self) -> Result<crate::libs::alias::Outcome> {
162 let download_url = self.download_url.as_ref().ok_or(msg_error_anyhow!(Message::UpdateDownloadUrlNotSet))?;
163
164 let response = self.client.get(download_url).send().await?;
165 let content = response.bytes().await?;
166
167 let tar_gz_path = env::temp_dir().join(format!("{}.tar.gz", self.name));
168 fs::write(&tar_gz_path, &content)?;
169
170 let alias = self.extract_and_replace_binary(&tar_gz_path)?;
171
172 fs::remove_file(&tar_gz_path)?;
173
174 Ok(alias)
177 }
178
179 pub async fn check_for_latest_release(&mut self) -> Result<bool> {
196 let tag = self.fetch_latest_tag().await?;
197
198 self.update_last_check_time();
199
200 let latest_version = tag.trim_start_matches('v').to_string();
201
202 if is_newer(&latest_version, &self.version) {
203 self.download_url = Some(format!(
205 "https://github.com/{}/{}/releases/download/{}/{}-{}-{}.tar.gz",
206 self.owner,
207 self.name,
208 tag,
209 self.name,
210 tag,
211 self.get_platform_identifier()
212 ));
213 self.latest_version = Some(latest_version);
214
215 Ok(true)
216 } else {
217 Ok(false)
218 }
219 }
220
221 async fn fetch_latest_tag(&self) -> Result<String> {
228 let client = Client::builder().redirect(reqwest::redirect::Policy::none()).build()?;
231 let response = client.get(&self.releases_url).header("User-Agent", &self.name).send().await?;
232
233 let location = response
234 .headers()
235 .get(reqwest::header::LOCATION)
236 .and_then(|value| value.to_str().ok())
237 .ok_or_else(|| msg_error_anyhow!(Message::UpdateLatestTagNotFound(self.releases_url.clone())))?;
238
239 match location.rsplit_once("/releases/tag/") {
240 Some((_, tag)) if !tag.is_empty() => Ok(tag.to_string()),
241 _ => Err(msg_error_anyhow!(Message::UpdateLatestTagNotFound(self.releases_url.clone()))),
242 }
243 }
244
245 pub fn sweep_backup() {
256 let Ok(exe) = env::current_exe() else { return };
257 let _ = fs::remove_file(exe.with_extension(BACKUP_EXTENSION));
258 if let Some(other) = crate::libs::alias::counterpart(&exe) {
261 let _ = fs::remove_file(other.with_extension(BACKUP_EXTENSION));
262 }
263 }
264
265 fn extract_and_replace_binary(&self, tar_gz_path: &PathBuf) -> Result<crate::libs::alias::Outcome> {
290 let current_exe = env::current_exe()?;
291 let install_dir = current_exe.parent().unwrap().to_path_buf();
292 let running_name = install_dir.join(current_exe.file_name().unwrap_or_default());
293
294 let primary = install_dir.join(format!("{}{}", self.name, env::consts::EXE_SUFFIX));
297 if running_name != primary && running_name.exists() {
298 fs::rename(&running_name, running_name.with_extension(BACKUP_EXTENSION))?;
299 }
300
301 Self::unpack_binaries(tar_gz_path, &install_dir, &self.name)?;
302
303 if running_name != primary {
307 return Ok(match crate::libs::alias::link(&primary, &running_name) {
308 Ok(()) => crate::libs::alias::Outcome::Relinked(running_name),
309 Err(err) => crate::libs::alias::Outcome::Failed(running_name, err.to_string()),
310 });
311 }
312 Ok(crate::libs::alias::refresh(&primary))
313 }
314
315 pub(crate) fn unpack_binaries(tar_gz_path: &PathBuf, install_dir: &Path, app_name: &str) -> Result<()> {
322 let exe_suffix = env::consts::EXE_SUFFIX;
325 let primary = format!("{}{}", app_name, exe_suffix);
326
327 let tar_gz = File::open(tar_gz_path)?;
328 let tar = GzDecoder::new(tar_gz);
329 let mut archive = Archive::new(tar);
330 let mut is_updated = false;
331
332 for entry_result in archive.entries()? {
333 let mut entry = entry_result?;
334 let entry_path = entry.path()?.to_path_buf();
335 let Some(file_name) = entry_path.file_name().and_then(|name| name.to_str()) else {
336 continue;
337 };
338
339 if file_name == primary {
343 let target = install_dir.join(&primary);
344 if target.exists() {
349 fs::rename(&target, target.with_extension(BACKUP_EXTENSION))?;
350 }
351 entry.unpack(&target)?;
352 is_updated = true;
353 }
354 }
355
356 if is_updated {
357 Ok(())
358 } else {
359 msg_bail_anyhow!(Message::UpdateBinaryNotFoundInArchive);
360 }
361 }
362
363 fn get_platform_identifier(&self) -> String {
367 let arch = env::consts::ARCH;
368 let os = match env::consts::OS {
369 "windows" => "pc-windows-msvc",
370 "macos" => "apple-darwin",
371 _ => "unknown-linux-gnu",
374 };
375
376 format!("{}-{}", arch, os)
377 }
378
379 fn update_last_check_time(&self) {
383 let now = Utc::now().to_rfc3339();
384 let _ = fs::write(&self.last_check_file, now);
385 }
386
387 fn is_check_due(&self) -> bool {
391 match fs::read_to_string(&self.last_check_file) {
392 Ok(content) => {
393 let last_check = content
394 .parse::<DateTime<Utc>>()
395 .unwrap_or_else(|_| Utc::now() - Duration::days(DAILY_CHECK_INTERVAL + 1));
396
397 Utc::now().signed_duration_since(last_check) > Duration::days(DAILY_CHECK_INTERVAL)
398 }
399 Err(_) => true,
400 }
401 }
402}
403
404fn is_newer(candidate: &str, current: &str) -> bool {
412 match (parse_semver(candidate), parse_semver(current)) {
413 (Some(candidate), Some(current)) => candidate > current,
414 _ => false,
415 }
416}
417
418fn parse_semver(version: &str) -> Option<(u64, u64, u64, bool)> {
420 let core = version.split(['-', '+']).next()?;
421 let mut parts = core.split('.');
422 let major = parts.next()?.parse().ok()?;
423 let minor = parts.next()?.parse().ok()?;
424 let patch = parts.next()?.parse().ok()?;
425 if parts.next().is_some() {
426 return None;
427 }
428 Some((major, minor, patch, !version.contains('-')))
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn newer_versions_win() {
437 assert!(is_newer("1.10.0", "1.9.2"));
438 assert!(is_newer("1.9.3", "1.9.2"));
439 assert!(is_newer("2.0.0", "1.99.99"));
440 assert!(!is_newer("1.9.2", "1.9.2"));
441 assert!(!is_newer("1.9.1", "1.9.2"));
442 }
443
444 #[test]
447 fn versions_compare_numerically() {
448 assert!(is_newer("1.10.0", "1.9.9"));
449 assert!(is_newer("1.9.10", "1.9.9"));
450 assert!(!is_newer("1.9.9", "1.10.0"));
451 }
452
453 #[test]
454 fn a_release_beats_its_own_prerelease() {
455 assert!(is_newer("2.0.0", "2.0.0-rc.1"));
456 assert!(!is_newer("2.0.0-rc.1", "2.0.0"));
457 }
458
459 #[test]
461 fn unparsable_versions_never_announce() {
462 assert!(!is_newer("next", "1.9.2"));
463 assert!(!is_newer("1.10.0", "unknown"));
464 assert!(!is_newer("1.10.0.1", "1.9.2"));
465 }
466
467 #[tokio::test]
470 async fn a_later_minor_is_seen_as_an_update() {
471 let server = wiremock::MockServer::start().await;
472 wiremock::Mock::given(wiremock::matchers::method("GET"))
473 .and(wiremock::matchers::path("/releases/latest"))
474 .respond_with(wiremock::ResponseTemplate::new(302).insert_header("Location", "https://github.com/lacodda/kasl/releases/tag/v1.10.0"))
475 .mount(&server)
476 .await;
477 let dir = tempfile::tempdir().unwrap();
478 let mut updater = Updater {
479 client: Client::new(),
480 owner: "lacodda".to_string(),
481 name: "kasl".to_string(),
482 version: "1.9.2".to_string(),
483 latest_version: None,
484 download_url: None,
485 releases_url: format!("{}/releases/latest", server.uri()),
486 last_check_file: dir.path().join("last-check"),
487 };
488 assert!(updater.check_for_latest_release().await.unwrap(), "1.10.0 was not seen as newer than 1.9.2");
489 assert_eq!(updater.latest_version.as_deref(), Some("1.10.0"));
490 assert!(updater.download_url.unwrap().contains("/releases/download/v1.10.0/kasl-v1.10.0-"));
491 }
492 use flate2::Compression;
493 use flate2::write::GzEncoder;
494 use tempfile::TempDir;
495
496 fn release_archive(dir: &Path, files: &[(&str, &str)]) -> PathBuf {
499 let path = dir.join("release.tar.gz");
500 let encoder = GzEncoder::new(File::create(&path).unwrap(), Compression::default());
501 let mut builder = tar::Builder::new(encoder);
502
503 for (name, contents) in files {
504 let mut header = tar::Header::new_gnu();
505 header.set_size(contents.len() as u64);
506 header.set_mode(0o755);
507 header.set_cksum();
508 builder
509 .append_data(&mut header, format!("kasl-v9.9.9-x86_64-pc-windows-msvc/{name}"), contents.as_bytes())
510 .unwrap();
511 }
512
513 builder.into_inner().unwrap().finish().unwrap();
514 path
515 }
516
517 fn exe(name: &str) -> String {
518 format!("{}{}", name, env::consts::EXE_SUFFIX)
519 }
520
521 #[test]
522 fn the_archive_directory_prefix_stays_out_of_the_installation() {
523 let temp = TempDir::new().unwrap();
527 let install = temp.path().join("install");
528 fs::create_dir(&install).unwrap();
529 fs::write(install.join(exe("kasl")), "old").unwrap();
530
531 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), ("LICENSE", "MIT"), ("README.md", "docs")]);
532
533 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
534
535 let leftovers: Vec<_> = fs::read_dir(&install)
536 .unwrap()
537 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
538 .filter(|name| name.starts_with("kasl-v"))
539 .collect();
540 assert!(leftovers.is_empty(), "update left {leftovers:?} in the installation directory");
541 assert!(!install.join("LICENSE").exists(), "LICENSE does not belong next to the binary");
542 assert!(!install.join("README.md").exists(), "README does not belong next to the binary");
543 }
544
545 #[test]
546 fn the_binary_is_replaced_and_the_old_one_kept_as_backup() {
547 let temp = TempDir::new().unwrap();
548 let install = temp.path().join("install");
549 fs::create_dir(&install).unwrap();
550 fs::write(install.join(exe("kasl")), "old").unwrap();
551
552 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new")]);
553 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
554
555 assert_eq!(fs::read_to_string(install.join(exe("kasl"))).unwrap(), "new");
556 assert_eq!(
557 fs::read_to_string(install.join("kasl.bak")).unwrap(),
558 "old",
559 "the replaced binary must remain recoverable"
560 );
561 }
562
563 #[test]
576 fn the_outgoing_file_is_not_left_under_a_live_name() {
577 let temp = TempDir::new().unwrap();
578 let install = temp.path().join("install");
579 fs::create_dir(&install).unwrap();
580 fs::write(install.join(exe("kasl")), "old").unwrap();
581 crate::libs::alias::link(&install.join(exe("kasl")), &install.join(exe("ka"))).unwrap();
582
583 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new")]);
584 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
585
586 assert_eq!(fs::read_to_string(install.join(exe("kasl"))).unwrap(), "new");
587 assert_eq!(fs::read_to_string(install.join("kasl.bak")).unwrap(), "old");
591 }
592
593 #[test]
597 fn an_update_never_unpacks_a_second_binary_for_the_alias() {
598 let temp = TempDir::new().unwrap();
599 let install = temp.path().join("install");
600 fs::create_dir(&install).unwrap();
601 fs::write(install.join(exe("kasl")), "old").unwrap();
602 crate::libs::alias::link(&install.join(exe("kasl")), &install.join(exe("ka"))).unwrap();
604
605 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), (&exe("ka"), "stale copy")]);
607 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
608
609 assert_eq!(fs::read_to_string(install.join(exe("kasl"))).unwrap(), "new");
610 assert_ne!(
611 fs::read_to_string(install.join(exe("ka"))).unwrap(),
612 "stale copy",
613 "the archive's `ka` was unpacked over the link, which is what made it a second binary"
614 );
615 }
616
617 #[test]
620 fn an_absent_alias_is_not_installed_by_an_update() {
621 let temp = TempDir::new().unwrap();
622 let install = temp.path().join("install");
623 fs::create_dir(&install).unwrap();
624 fs::write(install.join(exe("kasl")), "old").unwrap();
625
626 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), (&exe("ka"), "new")]);
627 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
628
629 assert!(!install.join(exe("ka")).exists(), "the update added an alias the user never installed");
630 }
631
632 #[test]
633 fn an_archive_without_the_binary_fails_instead_of_reporting_success() {
634 let temp = TempDir::new().unwrap();
635 let install = temp.path().join("install");
636 fs::create_dir(&install).unwrap();
637
638 let archive = release_archive(temp.path(), &[("LICENSE", "MIT")]);
639 assert!(Updater::unpack_binaries(&archive, &install, "kasl").is_err());
640 }
641}