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