1use std::fs;
4use std::io::{Cursor, Read};
5use std::path::{Path, PathBuf};
6
7use flate2::read::GzDecoder;
8use indicatif::{ProgressBar, ProgressStyle};
9use reqwest::blocking::Client;
10use serde::Deserialize;
11use tar::Archive;
12use tempfile::Builder;
13use zip::ZipArchive;
14
15use super::version::{release_archive_extension, release_feature_label, release_target_triple};
16use crate::{App, CoreError, SelfUpgradeSummary};
17
18const RELEASE_REPOSITORY: &str = "unsdk/vs";
19
20#[derive(Debug, Deserialize)]
21struct ReleaseMetadata {
22 tag_name: String,
23 #[serde(default)]
24 assets: Vec<ReleaseAsset>,
25}
26
27#[derive(Debug, Deserialize)]
28struct ReleaseAsset {
29 name: String,
30 browser_download_url: String,
31}
32
33impl App {
34 pub fn self_upgrade_summary(&self) -> Result<SelfUpgradeSummary, CoreError> {
36 let current_version = self.version_info()?.current_version;
37 let latest_version = fetch_latest_release()?.tag_name;
38 Ok(SelfUpgradeSummary {
39 updated: current_version != latest_version,
40 current_version,
41 latest_version,
42 })
43 }
44
45 pub fn upgrade_self_to(&self, latest_version: &str) -> Result<SelfUpgradeSummary, CoreError> {
47 let current_version = self.version_info()?.current_version;
48 if current_version == latest_version {
49 return Ok(SelfUpgradeSummary {
50 current_version,
51 latest_version: latest_version.to_string(),
52 updated: false,
53 });
54 }
55
56 let executable = std::env::current_exe()?;
57 let executable_dir = executable.parent().ok_or_else(|| {
58 CoreError::Unsupported(String::from("failed to resolve executable directory"))
59 })?;
60 let temp_dir = Builder::new()
61 .prefix("vs-upgrade-")
62 .tempdir_in(executable_dir)?;
63 println!(
64 "Preparing upgrade workspace in {}...",
65 temp_dir.path().display()
66 );
67
68 let release = fetch_release_by_tag(latest_version)?;
69 let asset = select_release_asset(&release)?;
70 println!(
71 "Resolved release asset {} for target {} with feature {}.",
72 asset.name,
73 release_target_triple(),
74 release_feature_label()?
75 );
76
77 let archive_name = &asset.name;
78 let archive_path = temp_dir.path().join(archive_name);
79 println!(
80 "Downloading {} to {}...",
81 asset.browser_download_url,
82 archive_path.display()
83 );
84 download_release_archive(&asset.browser_download_url, &archive_path)?;
85
86 let unpack_dir = temp_dir.path().join("unpacked");
87 let replacement = if cfg!(windows) {
88 extract_zip_binary(&archive_path, &unpack_dir)?
89 } else {
90 extract_tar_gz_binary(&archive_path, &unpack_dir)?
91 };
92
93 replace_running_executable(&executable, &replacement)?;
94
95 Ok(SelfUpgradeSummary {
96 current_version,
97 latest_version: latest_version.to_string(),
98 updated: true,
99 })
100 }
101}
102
103fn fetch_latest_release() -> Result<ReleaseMetadata, CoreError> {
104 fetch_release_from_endpoint(&format!(
105 "https://api.github.com/repos/{RELEASE_REPOSITORY}/releases/latest"
106 ))
107}
108
109fn fetch_release_by_tag(tag: &str) -> Result<ReleaseMetadata, CoreError> {
110 fetch_release_from_endpoint(&format!(
111 "https://api.github.com/repos/{RELEASE_REPOSITORY}/releases/tags/{tag}"
112 ))
113}
114
115fn fetch_release_from_endpoint(url: &str) -> Result<ReleaseMetadata, CoreError> {
116 let response = github_client()?.get(url).send()?.error_for_status()?;
117 response.json::<ReleaseMetadata>().map_err(Into::into)
118}
119
120fn github_client() -> Result<Client, CoreError> {
121 Client::builder()
122 .user_agent(format!("vs/{}", env!("CARGO_PKG_VERSION")))
123 .build()
124 .map_err(Into::into)
125}
126
127fn select_release_asset(release: &ReleaseMetadata) -> Result<&ReleaseAsset, CoreError> {
128 let target = release_target_triple();
129 let feature = release_feature_label()?;
130 let expected_name = release_archive_name(&release.tag_name)?;
131 release.assets.iter().find(|asset| asset.name == expected_name).ok_or_else(|| {
132 let available = release
133 .assets
134 .iter()
135 .map(|asset| asset.name.as_str())
136 .collect::<Vec<_>>()
137 .join(", ");
138 CoreError::Unsupported(format!(
139 "no release asset matched target {} with feature {}. expected {}, available assets: {}",
140 target,
141 feature,
142 expected_name,
143 available
144 ))
145 })
146}
147
148fn download_release_archive(url: &str, archive_path: &Path) -> Result<(), CoreError> {
149 let mut response = github_client()?.get(url).send()?.error_for_status()?;
150 let progress_bar = create_download_progress_bar(response.content_length());
151 let mut output = fs::File::create(archive_path)?;
152 let mut buffer = [0_u8; 8192];
153
154 loop {
155 let read = response.read(&mut buffer)?;
156 if read == 0 {
157 break;
158 }
159 std::io::Write::write_all(&mut output, &buffer[..read])?;
160 progress_bar.inc(read as u64);
161 }
162
163 progress_bar.finish_and_clear();
164 Ok(())
165}
166
167fn create_download_progress_bar(total_size: Option<u64>) -> ProgressBar {
168 let progress_bar = match total_size {
169 Some(total_size) => ProgressBar::new(total_size),
170 None => ProgressBar::new_spinner(),
171 };
172 let style = ProgressStyle::with_template(
173 "Downloading... {wide_bar} {bytes}/{total_bytes} ({bytes_per_sec})",
174 )
175 .unwrap_or_else(|_| ProgressStyle::default_bar())
176 .progress_chars("=> ");
177 progress_bar.set_style(style);
178 progress_bar
179}
180
181fn release_archive_name(tag: &str) -> Result<String, CoreError> {
182 let target = release_target_triple();
183 let feature = release_feature_label()?;
184 Ok(format!(
185 "vs-{tag}-{target}-{feature}.{}",
186 release_archive_extension()
187 ))
188}
189
190fn extract_tar_gz_binary(archive_path: &Path, destination: &Path) -> Result<PathBuf, CoreError> {
191 println!(
192 "Unpacking {} to {}...",
193 archive_path.display(),
194 destination.display()
195 );
196 fs::create_dir_all(destination)?;
197 let bytes = fs::read(archive_path)?;
198 let cursor = Cursor::new(bytes);
199 let decoder = GzDecoder::new(cursor);
200 let mut archive = Archive::new(decoder);
201 archive.unpack(destination)?;
202 let extracted = destination.join(executable_name());
203 if extracted.exists() {
204 println!("Extracted binary to {}.", extracted.display());
205 return Ok(extracted);
206 }
207 Err(CoreError::Unsupported(format!(
208 "failed to find extracted binary {}",
209 extracted.display()
210 )))
211}
212
213fn extract_zip_binary(archive_path: &Path, destination: &Path) -> Result<PathBuf, CoreError> {
214 println!(
215 "Unpacking {} to {}...",
216 archive_path.display(),
217 destination.display()
218 );
219 fs::create_dir_all(destination)?;
220 let bytes = fs::read(archive_path)?;
221 let cursor = Cursor::new(bytes);
222 let mut archive = ZipArchive::new(cursor)?;
223 let binary_name = executable_name();
224
225 for index in 0..archive.len() {
226 let mut file = archive.by_index(index)?;
227 let Some(relative_path) = file.enclosed_name() else {
228 continue;
229 };
230 let output_path = destination.join(relative_path);
231 if file.name().ends_with('/') {
232 fs::create_dir_all(&output_path)?;
233 continue;
234 }
235 if let Some(parent) = output_path.parent() {
236 fs::create_dir_all(parent)?;
237 }
238 let mut output = fs::File::create(&output_path)?;
239 std::io::copy(&mut file, &mut output)?;
240 if output_path
241 .file_name()
242 .is_some_and(|name| name == binary_name)
243 {
244 println!("Extracted binary to {}.", output_path.display());
245 return Ok(output_path);
246 }
247 }
248
249 Err(CoreError::Unsupported(format!(
250 "failed to find extracted binary {binary_name}"
251 )))
252}
253
254fn replace_running_executable(executable: &Path, replacement: &Path) -> Result<(), CoreError> {
255 #[cfg(windows)]
256 {
257 let backup = executable.with_extension("old.exe");
258 if backup.exists() {
259 fs::remove_file(&backup)?;
260 }
261 println!("Moving {} to {}...", executable.display(), backup.display());
262 fs::rename(executable, &backup)?;
263 println!(
264 "Moving {} to {}...",
265 replacement.display(),
266 executable.display()
267 );
268 fs::rename(replacement, executable)?;
269
270 let cleanup_script = executable.with_extension("cleanup.bat");
271 let script = format!(
272 ":Repeat\r\ndel \"{}\"\r\nif exist \"{}\" goto Repeat\r\ndel \"{}\"\r\n",
273 backup.display(),
274 backup.display(),
275 cleanup_script.display()
276 );
277 println!("Writing cleanup script to {}...", cleanup_script.display());
278 fs::write(&cleanup_script, script)?;
279 println!("Starting cleanup helper process...");
280 std::process::Command::new("cmd.exe")
281 .args(["/C", cleanup_script.to_string_lossy().as_ref()])
282 .spawn()
283 .map_err(|error| CoreError::CommandExecution {
284 command: String::from("cmd.exe"),
285 message: error.to_string(),
286 })?;
287 Ok(())
288 }
289
290 #[cfg(not(windows))]
291 {
292 println!(
293 "Moving {} to {}...",
294 replacement.display(),
295 executable.display()
296 );
297 fs::rename(replacement, executable)?;
298 let mut permissions = fs::metadata(executable)?.permissions();
299 #[cfg(unix)]
300 {
301 use std::os::unix::fs::PermissionsExt;
302 permissions.set_mode(0o755);
303 }
304 println!("Updating file permissions for {}...", executable.display());
305 fs::set_permissions(executable, permissions)?;
306 Ok(())
307 }
308}
309
310fn executable_name() -> &'static str {
311 if cfg!(windows) { "vs.exe" } else { "vs" }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::{
317 ReleaseAsset, ReleaseMetadata, release_archive_name, release_feature_label,
318 release_target_triple, select_release_asset,
319 };
320
321 #[test]
322 fn select_release_asset_should_match_expected_archive_name() {
323 let archive_name = match release_archive_name("v1.2.3") {
324 Ok(name) => name,
325 Err(error) => panic!("release archive name should resolve: {error}"),
326 };
327 let release = ReleaseMetadata {
328 tag_name: String::from("v1.2.3"),
329 assets: vec![
330 ReleaseAsset {
331 name: String::from("vs-v1.2.3-other-target-full.tar.gz"),
332 browser_download_url: String::from("https://example.com/other"),
333 },
334 ReleaseAsset {
335 name: archive_name,
336 browser_download_url: String::from("https://example.com/match"),
337 },
338 ],
339 };
340
341 let asset = match select_release_asset(&release) {
342 Ok(asset) => asset,
343 Err(error) => panic!("release asset should match: {error}"),
344 };
345 assert_eq!(asset.browser_download_url, "https://example.com/match");
346 }
347
348 #[test]
349 fn release_archive_name_should_include_feature_variant() {
350 let archive_name = match release_archive_name("v1.2.3") {
351 Ok(name) => name,
352 Err(error) => panic!("release archive name should resolve: {error}"),
353 };
354 let feature = match release_feature_label() {
355 Ok(feature) => feature,
356 Err(error) => panic!("feature label should resolve: {error}"),
357 };
358 assert!(archive_name.contains(feature));
359 }
360
361 #[test]
362 fn release_archive_name_should_include_target_triple() {
363 let archive_name = match release_archive_name("v1.2.3") {
364 Ok(name) => name,
365 Err(error) => panic!("release archive name should resolve: {error}"),
366 };
367 let target = release_target_triple();
368 assert!(archive_name.contains(target));
369 }
370}