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<()> {
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 self.extract_and_replace_binary(&tar_gz_path)?;
171
172 fs::remove_file(&tar_gz_path)?;
173
174 Ok(())
175 }
176
177 pub async fn check_for_latest_release(&mut self) -> Result<bool> {
194 let tag = self.fetch_latest_tag().await?;
195
196 self.update_last_check_time();
197
198 let latest_version = tag.trim_start_matches('v').to_string();
199
200 if latest_version > self.version {
202 self.download_url = Some(format!(
204 "https://github.com/{}/{}/releases/download/{}/{}-{}-{}.tar.gz",
205 self.owner,
206 self.name,
207 tag,
208 self.name,
209 tag,
210 self.get_platform_identifier()
211 ));
212 self.latest_version = Some(latest_version);
213
214 Ok(true)
215 } else {
216 Ok(false)
217 }
218 }
219
220 async fn fetch_latest_tag(&self) -> Result<String> {
227 let client = Client::builder().redirect(reqwest::redirect::Policy::none()).build()?;
230 let response = client.get(&self.releases_url).header("User-Agent", &self.name).send().await?;
231
232 let location = response
233 .headers()
234 .get(reqwest::header::LOCATION)
235 .and_then(|value| value.to_str().ok())
236 .ok_or_else(|| msg_error_anyhow!(Message::UpdateLatestTagNotFound(self.releases_url.clone())))?;
237
238 match location.rsplit_once("/releases/tag/") {
239 Some((_, tag)) if !tag.is_empty() => Ok(tag.to_string()),
240 _ => Err(msg_error_anyhow!(Message::UpdateLatestTagNotFound(self.releases_url.clone()))),
241 }
242 }
243
244 fn extract_and_replace_binary(&self, tar_gz_path: &PathBuf) -> Result<()> {
253 let current_exe = env::current_exe()?;
254 let install_dir = current_exe.parent().unwrap().to_path_buf();
255
256 Self::unpack_binaries(tar_gz_path, &install_dir, &self.name)
257 }
258
259 pub(crate) fn unpack_binaries(tar_gz_path: &PathBuf, install_dir: &Path, app_name: &str) -> Result<()> {
266 let exe_suffix = env::consts::EXE_SUFFIX;
269 let primary = format!("{}{}", app_name, exe_suffix);
270 let alias = format!("ka{}", exe_suffix);
271
272 let tar_gz = File::open(tar_gz_path)?;
273 let tar = GzDecoder::new(tar_gz);
274 let mut archive = Archive::new(tar);
275 let mut is_updated = false;
276
277 for entry_result in archive.entries()? {
278 let mut entry = entry_result?;
279 let entry_path = entry.path()?.to_path_buf();
280 let Some(file_name) = entry_path.file_name().and_then(|name| name.to_str()) else {
281 continue;
282 };
283
284 if file_name == primary {
288 let target = install_dir.join(&primary);
289 if target.exists() {
291 fs::rename(&target, target.with_extension(BACKUP_EXTENSION))?;
292 }
293 entry.unpack(&target)?;
294 is_updated = true;
295 } else if file_name == alias {
296 let target = install_dir.join(&alias);
297 if target.exists() {
302 fs::remove_file(&target)?;
303 entry.unpack(&target)?;
304 }
305 }
306 }
307
308 if is_updated {
309 Ok(())
310 } else {
311 msg_bail_anyhow!(Message::UpdateBinaryNotFoundInArchive);
312 }
313 }
314
315 fn get_platform_identifier(&self) -> String {
319 let arch = env::consts::ARCH;
320 let os = match env::consts::OS {
321 "windows" => "pc-windows-msvc",
322 "macos" => "apple-darwin",
323 _ => "unknown-linux-gnu",
326 };
327
328 format!("{}-{}", arch, os)
329 }
330
331 fn update_last_check_time(&self) {
335 let now = Utc::now().to_rfc3339();
336 let _ = fs::write(&self.last_check_file, now);
337 }
338
339 fn is_check_due(&self) -> bool {
343 match fs::read_to_string(&self.last_check_file) {
344 Ok(content) => {
345 let last_check = content
346 .parse::<DateTime<Utc>>()
347 .unwrap_or_else(|_| Utc::now() - Duration::days(DAILY_CHECK_INTERVAL + 1));
348
349 Utc::now().signed_duration_since(last_check) > Duration::days(DAILY_CHECK_INTERVAL)
350 }
351 Err(_) => true,
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use flate2::Compression;
360 use flate2::write::GzEncoder;
361 use tempfile::TempDir;
362
363 fn release_archive(dir: &Path, files: &[(&str, &str)]) -> PathBuf {
366 let path = dir.join("release.tar.gz");
367 let encoder = GzEncoder::new(File::create(&path).unwrap(), Compression::default());
368 let mut builder = tar::Builder::new(encoder);
369
370 for (name, contents) in files {
371 let mut header = tar::Header::new_gnu();
372 header.set_size(contents.len() as u64);
373 header.set_mode(0o755);
374 header.set_cksum();
375 builder
376 .append_data(&mut header, format!("kasl-v9.9.9-x86_64-pc-windows-msvc/{name}"), contents.as_bytes())
377 .unwrap();
378 }
379
380 builder.into_inner().unwrap().finish().unwrap();
381 path
382 }
383
384 fn exe(name: &str) -> String {
385 format!("{}{}", name, env::consts::EXE_SUFFIX)
386 }
387
388 #[test]
389 fn the_archive_directory_prefix_stays_out_of_the_installation() {
390 let temp = TempDir::new().unwrap();
394 let install = temp.path().join("install");
395 fs::create_dir(&install).unwrap();
396 fs::write(install.join(exe("kasl")), "old").unwrap();
397
398 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), ("LICENSE", "MIT"), ("README.md", "docs")]);
399
400 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
401
402 let leftovers: Vec<_> = fs::read_dir(&install)
403 .unwrap()
404 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
405 .filter(|name| name.starts_with("kasl-v"))
406 .collect();
407 assert!(leftovers.is_empty(), "update left {leftovers:?} in the installation directory");
408 assert!(!install.join("LICENSE").exists(), "LICENSE does not belong next to the binary");
409 assert!(!install.join("README.md").exists(), "README does not belong next to the binary");
410 }
411
412 #[test]
413 fn the_binary_is_replaced_and_the_old_one_kept_as_backup() {
414 let temp = TempDir::new().unwrap();
415 let install = temp.path().join("install");
416 fs::create_dir(&install).unwrap();
417 fs::write(install.join(exe("kasl")), "old").unwrap();
418
419 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new")]);
420 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
421
422 assert_eq!(fs::read_to_string(install.join(exe("kasl"))).unwrap(), "new");
423 assert_eq!(
424 fs::read_to_string(install.join("kasl.bak")).unwrap(),
425 "old",
426 "the replaced binary must remain recoverable"
427 );
428 }
429
430 #[test]
431 fn an_installed_alias_is_updated_together_with_the_binary() {
432 let temp = TempDir::new().unwrap();
435 let install = temp.path().join("install");
436 fs::create_dir(&install).unwrap();
437 fs::write(install.join(exe("kasl")), "old").unwrap();
438 fs::write(install.join(exe("ka")), "old").unwrap();
439
440 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), (&exe("ka"), "new")]);
441 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
442
443 assert_eq!(fs::read_to_string(install.join(exe("ka"))).unwrap(), "new");
444 }
445
446 #[test]
447 fn an_absent_alias_is_not_installed_by_an_update() {
448 let temp = TempDir::new().unwrap();
451 let install = temp.path().join("install");
452 fs::create_dir(&install).unwrap();
453 fs::write(install.join(exe("kasl")), "old").unwrap();
454
455 let archive = release_archive(temp.path(), &[(&exe("kasl"), "new"), (&exe("ka"), "new")]);
456 Updater::unpack_binaries(&archive, &install, "kasl").unwrap();
457
458 assert!(!install.join(exe("ka")).exists(), "the update added an alias the user never installed");
459 }
460
461 #[test]
462 fn an_archive_without_the_binary_fails_instead_of_reporting_success() {
463 let temp = TempDir::new().unwrap();
464 let install = temp.path().join("install");
465 fs::create_dir(&install).unwrap();
466
467 let archive = release_archive(temp.path(), &[("LICENSE", "MIT")]);
468 assert!(Updater::unpack_binaries(&archive, &install, "kasl").is_err());
469 }
470}