vs_core/service/
upgrade.rs1use std::fs;
2use std::io::Cursor;
3use std::path::{Path, PathBuf};
4
5use flate2::read::GzDecoder;
6use reqwest::blocking::Client;
7use serde::Deserialize;
8use tar::Archive;
9use tempfile::Builder;
10use zip::ZipArchive;
11
12use crate::{App, CoreError, SelfUpgradeSummary};
13
14const RELEASE_REPOSITORY: &str = "unsdk/vs";
15
16#[derive(Debug, Deserialize)]
17struct LatestRelease {
18 tag_name: String,
19}
20
21impl App {
22 pub fn upgrade_self(&self) -> Result<SelfUpgradeSummary, CoreError> {
24 let current_version = format!("v{}", env!("CARGO_PKG_VERSION"));
25 let latest_version = fetch_latest_release_tag()?;
26 if current_version == latest_version {
27 return Ok(SelfUpgradeSummary {
28 current_version,
29 latest_version,
30 updated: false,
31 });
32 }
33
34 let executable = std::env::current_exe()?;
35 let executable_dir = executable.parent().ok_or_else(|| {
36 CoreError::Unsupported(String::from("failed to resolve executable directory"))
37 })?;
38 let temp_dir = Builder::new()
39 .prefix("vs-upgrade-")
40 .tempdir_in(executable_dir)?;
41 let archive_name = release_archive_name(&latest_version);
42 let archive_path = temp_dir.path().join(&archive_name);
43 let download_url = release_asset_url(&latest_version);
44
45 let bytes = download_release_bytes(&download_url)?;
46 fs::write(&archive_path, bytes)?;
47
48 let replacement = if cfg!(windows) {
49 extract_zip_binary(&archive_path, temp_dir.path())?
50 } else {
51 extract_tar_gz_binary(&archive_path, temp_dir.path())?
52 };
53
54 replace_running_executable(&executable, &replacement)?;
55
56 Ok(SelfUpgradeSummary {
57 current_version,
58 latest_version,
59 updated: true,
60 })
61 }
62}
63
64fn fetch_latest_release_tag() -> Result<String, CoreError> {
65 let client = Client::builder()
66 .user_agent(format!("vs/{}", env!("CARGO_PKG_VERSION")))
67 .build()?;
68 let response = client
69 .get(format!(
70 "https://api.github.com/repos/{RELEASE_REPOSITORY}/releases/latest"
71 ))
72 .send()?
73 .error_for_status()?;
74 let release = response.json::<LatestRelease>()?;
75 Ok(release.tag_name)
76}
77
78fn download_release_bytes(url: &str) -> Result<Vec<u8>, CoreError> {
79 let client = Client::builder()
80 .user_agent(format!("vs/{}", env!("CARGO_PKG_VERSION")))
81 .build()?;
82 let response = client.get(url).send()?.error_for_status()?;
83 response
84 .bytes()
85 .map(|bytes| bytes.to_vec())
86 .map_err(Into::into)
87}
88
89fn release_asset_url(tag: &str) -> String {
90 format!(
91 "https://github.com/{RELEASE_REPOSITORY}/releases/download/{tag}/{}",
92 release_archive_name(tag)
93 )
94}
95
96fn release_archive_name(tag: &str) -> String {
97 format!(
98 "vs-{tag}-{}-{}.{}",
99 release_platform_label(),
100 release_feature_label(),
101 release_archive_extension()
102 )
103}
104
105fn release_platform_label() -> &'static str {
106 match std::env::consts::OS {
107 "macos" => "macos",
108 "windows" => "windows",
109 _ => "linux",
110 }
111}
112
113fn release_archive_extension() -> &'static str {
114 if cfg!(windows) { "zip" } else { "tar.gz" }
115}
116
117fn release_feature_label() -> &'static str {
118 #[cfg(all(feature = "lua", feature = "wasi"))]
119 {
120 "lua-wasi"
121 }
122 #[cfg(all(feature = "lua", not(feature = "wasi")))]
123 {
124 "lua"
125 }
126 #[cfg(all(feature = "wasi", not(feature = "lua")))]
127 {
128 "wasi"
129 }
130 #[cfg(not(any(feature = "lua", feature = "wasi")))]
131 {
132 "bare"
133 }
134}
135
136fn extract_tar_gz_binary(archive_path: &Path, destination: &Path) -> Result<PathBuf, CoreError> {
137 let bytes = fs::read(archive_path)?;
138 let cursor = Cursor::new(bytes);
139 let decoder = GzDecoder::new(cursor);
140 let mut archive = Archive::new(decoder);
141 archive.unpack(destination)?;
142 let binary_name = executable_name();
143 let extracted = destination.join(binary_name);
144 if extracted.exists() {
145 return Ok(extracted);
146 }
147 Err(CoreError::Unsupported(format!(
148 "failed to find extracted binary {}",
149 extracted.display()
150 )))
151}
152
153fn extract_zip_binary(archive_path: &Path, destination: &Path) -> Result<PathBuf, CoreError> {
154 let bytes = fs::read(archive_path)?;
155 let cursor = Cursor::new(bytes);
156 let mut archive = ZipArchive::new(cursor)?;
157 let binary_name = executable_name();
158
159 for index in 0..archive.len() {
160 let mut file = archive.by_index(index)?;
161 let Some(relative_path) = file.enclosed_name() else {
162 continue;
163 };
164 let output_path = destination.join(relative_path);
165 if file.name().ends_with('/') {
166 fs::create_dir_all(&output_path)?;
167 continue;
168 }
169 if let Some(parent) = output_path.parent() {
170 fs::create_dir_all(parent)?;
171 }
172 let mut output = fs::File::create(&output_path)?;
173 std::io::copy(&mut file, &mut output)?;
174 if output_path
175 .file_name()
176 .is_some_and(|name| name == binary_name)
177 {
178 return Ok(output_path);
179 }
180 }
181
182 Err(CoreError::Unsupported(format!(
183 "failed to find extracted binary {binary_name}"
184 )))
185}
186
187fn replace_running_executable(executable: &Path, replacement: &Path) -> Result<(), CoreError> {
188 #[cfg(windows)]
189 {
190 let backup = executable.with_extension("old.exe");
191 if backup.exists() {
192 fs::remove_file(&backup)?;
193 }
194 fs::rename(executable, &backup)?;
195 fs::rename(replacement, executable)?;
196
197 let cleanup_script = executable.with_extension("cleanup.bat");
198 let script = format!(
199 ":Repeat\r\ndel \"{}\"\r\nif exist \"{}\" goto Repeat\r\ndel \"{}\"\r\n",
200 backup.display(),
201 backup.display(),
202 cleanup_script.display()
203 );
204 fs::write(&cleanup_script, script)?;
205 std::process::Command::new("cmd.exe")
206 .args(["/C", cleanup_script.to_string_lossy().as_ref()])
207 .spawn()
208 .map_err(|error| CoreError::CommandExecution {
209 command: String::from("cmd.exe"),
210 message: error.to_string(),
211 })?;
212 Ok(())
213 }
214
215 #[cfg(not(windows))]
216 {
217 fs::rename(replacement, executable)?;
218 let mut permissions = fs::metadata(executable)?.permissions();
219 #[cfg(unix)]
220 {
221 use std::os::unix::fs::PermissionsExt;
222 permissions.set_mode(0o755);
223 }
224 fs::set_permissions(executable, permissions)?;
225 Ok(())
226 }
227}
228
229fn executable_name() -> &'static str {
230 if cfg!(windows) { "vs.exe" } else { "vs" }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::{release_archive_name, release_asset_url, release_feature_label};
236
237 #[test]
238 fn release_asset_url_should_reference_current_repository() {
239 let url = release_asset_url("v1.2.3");
240 assert!(url.contains("github.com/unsdk/vs/releases/download/v1.2.3/"));
241 assert!(url.contains("v1.2.3"));
242 }
243
244 #[test]
245 fn release_archive_name_should_include_feature_variant() {
246 let archive_name = release_archive_name("v1.2.3");
247 assert!(archive_name.contains(release_feature_label()));
248 }
249}