kasl/libs/update.rs
1//! Self-update from GitHub releases.
2//!
3//! ```rust,no_run
4//! use kasl::libs::update::Updater;
5//!
6//! #[tokio::main]
7//! async fn main() -> anyhow::Result<()> {
8//! let mut updater = Updater::new()?;
9//!
10//! if updater.check_for_latest_release().await? {
11//! updater.perform_update().await?;
12//! }
13//!
14//! Ok(())
15//! }
16//! ```
17
18use 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::PathBuf;
28use tar::Archive;
29
30// Include application metadata (name, version, owner) generated at build time.
31include!(concat!(env!("OUT_DIR"), "/app_metadata.rs"));
32
33/// Cache file holding the timestamp of the last update check.
34const LAST_CHECK_FILE: &str = ".last_update_check";
35
36/// Minimum days between startup update checks.
37const DAILY_CHECK_INTERVAL: i64 = 1;
38
39/// Extension the replaced executable is kept under (`kasl.bak`).
40const BACKUP_EXTENSION: &str = "bak";
41
42/// The update workflow: check the latest tag, download the platform asset,
43/// swap the binary keeping the old one as `.bak`.
44#[derive(Debug)]
45pub struct Updater {
46 pub client: Client,
47
48 /// Repository owner, from build-time metadata.
49 pub owner: String,
50
51 /// Repository/app name, from build-time metadata.
52 pub name: String,
53
54 /// Version of the running binary.
55 pub version: String,
56
57 /// Newer version found by the check, if any.
58 pub latest_version: Option<String>,
59
60 /// Asset URL for this platform, set when a newer version is found.
61 pub download_url: Option<String>,
62
63 /// URL of the repository's `releases/latest` page.
64 ///
65 /// The latest tag is read from this page's redirect `Location` header
66 /// instead of `api.github.com`: the API allows only 60 anonymous
67 /// requests per hour per IP, which starves every machine behind a
68 /// shared NAT (the same failure the installers hit).
69 releases_url: String,
70
71 /// Path of the check-throttling timestamp file.
72 last_check_file: PathBuf,
73}
74
75impl Updater {
76 /// Builds an updater from build-time metadata.
77 ///
78 /// ```rust,no_run
79 /// # fn f() -> anyhow::Result<()> {
80 /// use kasl::libs::update::Updater;
81 ///
82 /// let updater = Updater::new()?;
83 /// println!("Updater configured for {} v{}", updater.name, updater.version);
84 /// # Ok(())
85 /// # }
86 /// ```
87 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 // Release page whose redirect reveals the latest tag (no API quota)
94 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 /// Prints an update notice when one is available - throttled to one
109 /// check per day, and silent on any failure, so startup never blocks
110 /// or complains because of the network.
111 ///
112 /// ```rust,no_run
113 /// # async fn f() {
114 /// use kasl::libs::update::Updater;
115 ///
116 /// // Call during application startup
117 /// Updater::show_update_notification().await;
118 /// # }
119 /// ```
120 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 // Show with extra spacing for visibility
139 )
140 }
141 }
142
143 /// Downloads the release archive and swaps the binary in.
144 ///
145 /// Requires a prior successful [`Updater::check_for_latest_release`]
146 /// (it sets `download_url`). The old executable stays next to the new
147 /// one as `.bak` - restoring it is a manual copy, nothing automatic.
148 ///
149 /// ```rust,no_run
150 /// # async fn f() -> anyhow::Result<()> {
151 /// use kasl::libs::update::Updater;
152 ///
153 /// let mut updater = Updater::new()?;
154 /// if updater.check_for_latest_release().await? {
155 /// updater.perform_update().await?;
156 /// println!("Update completed successfully");
157 /// }
158 /// # Ok(())
159 /// # }
160 /// ```
161 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 /// Compares the latest published tag against the running version;
178 /// on a newer one, stores it and the platform asset URL.
179 ///
180 /// ```rust,no_run
181 /// # async fn f() -> anyhow::Result<()> {
182 /// use kasl::libs::update::Updater;
183 ///
184 /// let mut updater = Updater::new()?;
185 /// if updater.check_for_latest_release().await? {
186 /// println!("Update available: {} -> {}",
187 /// updater.version,
188 /// updater.latest_version.unwrap());
189 /// }
190 /// # Ok(())
191 /// # }
192 /// ```
193 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 // String comparison; adequate for this project's version scheme.
201 if latest_version > self.version {
202 // Asset names follow the release convention: {name}-{tag}-{platform}.tar.gz
203 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 /// Reads the latest release tag from the `releases/latest` redirect.
221 ///
222 /// GitHub answers this page with a `302` to `.../releases/tag/<tag>`;
223 /// the tag is taken from the `Location` header. Unlike `api.github.com`,
224 /// this endpoint has no anonymous rate limit, so it keeps working for
225 /// every machine behind a shared NAT.
226 async fn fetch_latest_tag(&self) -> Result<String> {
227 // The shared client follows redirects (needed for asset downloads),
228 // so the redirect probe uses its own non-following client.
229 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 /// Unpacks the archive: the entry matching the current executable's
245 /// name replaces it (old binary renamed to `.bak` first), everything
246 /// else lands next to it. Errors if the archive holds no executable.
247 fn extract_and_replace_binary(&self, tar_gz_path: &PathBuf) -> Result<()> {
248 let tar_gz = File::open(tar_gz_path)?;
249 let tar = GzDecoder::new(tar_gz);
250 let mut archive = Archive::new(tar);
251 let mut is_updated = false;
252
253 let current_exe = env::current_exe()?;
254 let current_exe_backup = current_exe.with_extension(BACKUP_EXTENSION);
255
256 for entry_result in archive.entries()? {
257 let mut entry = entry_result?;
258 let entry_path = entry.path()?;
259
260 if entry_path.ends_with(current_exe.file_name().unwrap()) {
261 // Keep the running binary as the one-and-only backup.
262 fs::rename(¤t_exe, ¤t_exe_backup)?;
263 entry.unpack(¤t_exe)?;
264 is_updated = true;
265 } else {
266 let dest_path = current_exe.parent().unwrap().join(&entry_path);
267 entry.unpack(dest_path)?;
268 }
269 }
270
271 if is_updated {
272 Ok(())
273 } else {
274 msg_bail_anyhow!(Message::UpdateBinaryNotFoundInArchive);
275 }
276 }
277
278 /// Target triple used in release asset names, e.g.
279 /// `x86_64-pc-windows-msvc`, `aarch64-apple-darwin`,
280 /// `x86_64-unknown-linux-gnu`.
281 fn get_platform_identifier(&self) -> String {
282 let arch = env::consts::ARCH;
283 let os = match env::consts::OS {
284 "windows" => "pc-windows-msvc",
285 "macos" => "apple-darwin",
286 // Must match the published asset triple; releases ship glibc
287 // builds (the installers hit 404s on the old musl guess).
288 _ => "unknown-linux-gnu",
289 };
290
291 format!("{}-{}", arch, os)
292 }
293
294 /// Stamps the throttle file; write errors are ignored on purpose -
295 /// throttling is a convenience, and a failed write only means one
296 /// extra check later.
297 fn update_last_check_time(&self) {
298 let now = Utc::now().to_rfc3339();
299 let _ = fs::write(&self.last_check_file, now);
300 }
301
302 /// True when the daily check interval has passed. Fails open: a
303 /// missing or unreadable stamp allows the check rather than blocking
304 /// updates forever.
305 fn is_check_due(&self) -> bool {
306 match fs::read_to_string(&self.last_check_file) {
307 Ok(content) => {
308 let last_check = content
309 .parse::<DateTime<Utc>>()
310 .unwrap_or_else(|_| Utc::now() - Duration::days(DAILY_CHECK_INTERVAL + 1));
311
312 Utc::now().signed_duration_since(last_check) > Duration::days(DAILY_CHECK_INTERVAL)
313 }
314 Err(_) => true,
315 }
316 }
317}