1use std::{
2 path::Path,
3 sync::LazyLock,
4 time::{Duration, Instant},
5};
6
7use anyhow::{Context as _, Result};
8use windows::Win32::{
9 Foundation::{HWND, LPARAM, WPARAM},
10 UI::WindowsAndMessaging::PostMessageW,
11};
12
13use crate::windows_impl::WM_JOB_UPDATED;
14
15pub(crate) struct Job {
16 pub apply: Box<dyn Fn(&Path) -> Result<()> + Send + Sync>,
17 pub rollback: Box<dyn Fn(&Path) -> Result<()> + Send + Sync>,
18}
19
20impl Job {
21 pub fn mkdir(name: &'static Path) -> Self {
22 Job {
23 apply: Box::new(move |app_dir| {
24 let dir = app_dir.join(name);
25 std::fs::create_dir_all(&dir)
26 .context(format!("Failed to create directory {}", dir.display()))
27 }),
28 rollback: Box::new(move |app_dir| {
29 let dir = app_dir.join(name);
30 std::fs::remove_dir_all(&dir)
31 .context(format!("Failed to remove directory {}", dir.display()))
32 }),
33 }
34 }
35
36 pub fn mkdir_if_exists(name: &'static Path, check: &'static Path) -> Self {
37 Job {
38 apply: Box::new(move |app_dir| {
39 let dir = app_dir.join(name);
40 let check = app_dir.join(check);
41
42 if check.exists() {
43 std::fs::create_dir_all(&dir)
44 .context(format!("Failed to create directory {}", dir.display()))?
45 }
46 Ok(())
47 }),
48 rollback: Box::new(move |app_dir| {
49 let dir = app_dir.join(name);
50
51 if dir.exists() {
52 std::fs::remove_dir_all(&dir)
53 .context(format!("Failed to remove directory {}", dir.display()))?
54 }
55
56 Ok(())
57 }),
58 }
59 }
60
61 pub fn move_file(filename: &'static Path, new_filename: &'static Path) -> Self {
62 Job {
63 apply: Box::new(move |app_dir| {
64 let old_file = app_dir.join(filename);
65 let new_file = app_dir.join(new_filename);
66 log::info!(
67 "Moving file: {}->{}",
68 old_file.display(),
69 new_file.display()
70 );
71
72 std::fs::rename(&old_file, new_file)
73 .context(format!("Failed to move file {}", old_file.display()))
74 }),
75 rollback: Box::new(move |app_dir| {
76 let old_file = app_dir.join(filename);
77 let new_file = app_dir.join(new_filename);
78 log::info!(
79 "Rolling back file move: {}->{}",
80 old_file.display(),
81 new_file.display()
82 );
83
84 std::fs::rename(&new_file, &old_file).context(format!(
85 "Failed to rollback file move {}->{}",
86 new_file.display(),
87 old_file.display()
88 ))
89 }),
90 }
91 }
92
93 pub fn move_if_exists(filename: &'static Path, new_filename: &'static Path) -> Self {
94 Job {
95 apply: Box::new(move |app_dir| {
96 let old_file = app_dir.join(filename);
97 let new_file = app_dir.join(new_filename);
98
99 if old_file.exists() {
100 log::info!(
101 "Moving file: {}->{}",
102 old_file.display(),
103 new_file.display()
104 );
105
106 std::fs::rename(&old_file, new_file)
107 .context(format!("Failed to move file {}", old_file.display()))?;
108 }
109
110 Ok(())
111 }),
112 rollback: Box::new(move |app_dir| {
113 let old_file = app_dir.join(filename);
114 let new_file = app_dir.join(new_filename);
115
116 if new_file.exists() {
117 log::info!(
118 "Rolling back file move: {}->{}",
119 old_file.display(),
120 new_file.display()
121 );
122
123 std::fs::rename(&new_file, &old_file).context(format!(
124 "Failed to rollback file move {}->{}",
125 new_file.display(),
126 old_file.display()
127 ))?
128 }
129
130 Ok(())
131 }),
132 }
133 }
134
135 pub fn rmdir_nofail(filename: &'static Path) -> Self {
136 Job {
137 apply: Box::new(move |app_dir| {
138 let filename = app_dir.join(filename);
139 log::info!("Removing file: {}", filename.display());
140 if let Err(e) = std::fs::remove_dir_all(&filename) {
141 log::warn!("Failed to remove directory: {}", e);
142 }
143
144 Ok(())
145 }),
146 rollback: Box::new(move |app_dir| {
147 let filename = app_dir.join(filename);
148 anyhow::bail!(
149 "Delete operations cannot be rolled back, file: {}",
150 filename.display()
151 )
152 }),
153 }
154 }
155}
156
157#[cfg(not(test))]
158pub(crate) static JOBS: LazyLock<[Job; 22]> = LazyLock::new(|| {
159 fn p(value: &str) -> &Path {
160 Path::new(value)
161 }
162 [
163 // Move old files
164 // Not deleting because installing new files can fail
165 Job::mkdir(p("old")),
166 Job::move_file(p("Zed.exe"), p("old\\Zed.exe")),
167 Job::mkdir(p("old\\bin")),
168 Job::move_file(p("bin\\Zed.exe"), p("old\\bin\\Zed.exe")),
169 Job::move_file(p("bin\\zed"), p("old\\bin\\zed")),
170 //
171 // TODO: remove after a few weeks once everyone is on the new version and this file never exists
172 Job::move_if_exists(p("OpenConsole.exe"), p("old\\OpenConsole.exe")),
173 Job::mkdir(p("old\\x64")),
174 Job::mkdir(p("old\\arm64")),
175 Job::move_if_exists(p("x64\\OpenConsole.exe"), p("old\\x64\\OpenConsole.exe")),
176 Job::move_if_exists(
177 p("arm64\\OpenConsole.exe"),
178 p("old\\arm64\\OpenConsole.exe"),
179 ),
180 //
181 Job::move_file(p("conpty.dll"), p("old\\conpty.dll")),
182 // Copy new files
183 Job::move_file(p("install\\Zed.exe"), p("Zed.exe")),
184 Job::move_file(p("install\\bin\\Zed.exe"), p("bin\\Zed.exe")),
185 Job::move_file(p("install\\bin\\zed"), p("bin\\zed")),
186 //
187 Job::mkdir_if_exists(p("x64"), p("install\\x64")),
188 Job::mkdir_if_exists(p("arm64"), p("install\\arm64")),
189 Job::move_if_exists(
190 p("install\\x64\\OpenConsole.exe"),
191 p("x64\\OpenConsole.exe"),
192 ),
193 Job::move_if_exists(
194 p("install\\arm64\\OpenConsole.exe"),
195 p("arm64\\OpenConsole.exe"),
196 ),
197 //
198 Job::move_file(p("install\\conpty.dll"), p("conpty.dll")),
199 // Cleanup installer and updates folder
200 Job::rmdir_nofail(p("updates")),
201 Job::rmdir_nofail(p("install")),
202 // Cleanup old installation
203 Job::rmdir_nofail(p("old")),
204 ]
205});
206
207#[cfg(test)]
208pub(crate) static JOBS: LazyLock<[Job; 9]> = LazyLock::new(|| {
209 fn p(value: &str) -> &Path {
210 Path::new(value)
211 }
212 [
213 Job {
214 apply: Box::new(|_| {
215 std::thread::sleep(Duration::from_millis(1000));
216 if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") {
217 match config.as_str() {
218 "err1" => Err(std::io::Error::other("Simulated error")).context("Anyhow!"),
219 "err2" => Ok(()),
220 _ => panic!("Unknown ZED_AUTO_UPDATE value: {}", config),
221 }
222 } else {
223 Ok(())
224 }
225 }),
226 rollback: Box::new(|_| {
227 unsafe { std::env::set_var("ZED_AUTO_UPDATE_RB", "rollback1") };
228 Ok(())
229 }),
230 },
231 Job::mkdir(p("test1")),
232 Job::mkdir_if_exists(p("test_exists"), p("test1")),
233 Job::mkdir_if_exists(p("test_missing"), p("dont")),
234 Job {
235 apply: Box::new(|folder| {
236 std::fs::write(folder.join("test1/test"), "test")?;
237 Ok(())
238 }),
239 rollback: Box::new(|folder| {
240 std::fs::remove_file(folder.join("test1/test"))?;
241 Ok(())
242 }),
243 },
244 Job::move_file(p("test1/test"), p("test1/moved")),
245 Job::move_if_exists(p("test1/test"), p("test1/noop")),
246 Job {
247 apply: Box::new(|_| {
248 std::thread::sleep(Duration::from_millis(1000));
249 if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") {
250 match config.as_str() {
251 "err1" => Ok(()),
252 "err2" => Err(std::io::Error::other("Simulated error")).context("Anyhow!"),
253 _ => panic!("Unknown ZED_AUTO_UPDATE value: {}", config),
254 }
255 } else {
256 Ok(())
257 }
258 }),
259 rollback: Box::new(|_| Ok(())),
260 },
261 Job::rmdir_nofail(p("test1/nofolder")),
262 ]
263});
264
265pub(crate) fn perform_update(app_dir: &Path, hwnd: Option<isize>, launch: bool) -> Result<()> {
266 let hwnd = hwnd.map(|ptr| HWND(ptr as _));
267
268 let mut last_successful_job = None;
269 'outer: for (i, job) in JOBS.iter().enumerate() {
270 let start = Instant::now();
271 loop {
272 if start.elapsed().as_secs() > 2 {
273 log::error!("Timed out, rolling back");
274 break 'outer;
275 }
276 match (job.apply)(app_dir) {
277 Ok(_) => {
278 last_successful_job = Some(i);
279 unsafe { PostMessageW(hwnd, WM_JOB_UPDATED, WPARAM(0), LPARAM(0))? };
280 break;
281 }
282 Err(err) => {
283 // Check if it's a "not found" error
284 let io_err = err.downcast_ref::<std::io::Error>().unwrap();
285 if io_err.kind() == std::io::ErrorKind::NotFound {
286 log::warn!("File or folder not found.");
287 last_successful_job = Some(i);
288 unsafe { PostMessageW(hwnd, WM_JOB_UPDATED, WPARAM(0), LPARAM(0))? };
289 break;
290 }
291
292 log::error!("Operation failed: {} ({:?})", err, io_err.kind());
293 std::thread::sleep(Duration::from_millis(50));
294 }
295 }
296 }
297 }
298
299 if last_successful_job
300 .map(|job| job != JOBS.len() - 1)
301 .unwrap_or(true)
302 {
303 let Some(last_successful_job) = last_successful_job else {
304 anyhow::bail!("Autoupdate failed, nothing to rollback");
305 };
306
307 for job in (0..=last_successful_job).rev() {
308 let job = &JOBS[job];
309 if let Err(e) = (job.rollback)(app_dir) {
310 anyhow::bail!(
311 "Job rollback failed, the app might be left in an inconsistent state: ({:?})",
312 e
313 );
314 }
315 }
316
317 anyhow::bail!("Autoupdate failed, rollback successful");
318 }
319
320 if launch {
321 #[allow(clippy::disallowed_methods, reason = "doesn't run in the main binary")]
322 let _ = std::process::Command::new(app_dir.join("Zed.exe")).spawn();
323 }
324 log::info!("Update completed successfully");
325 Ok(())
326}
327
328#[cfg(test)]
329mod test {
330 use super::perform_update;
331
332 #[test]
333 fn test_perform_update() {
334 let app_dir = tempfile::tempdir().unwrap();
335 let app_dir = app_dir.path();
336 assert!(perform_update(app_dir, None, false).is_ok());
337
338 let app_dir = tempfile::tempdir().unwrap();
339 let app_dir = app_dir.path();
340 // Simulate a timeout
341 unsafe { std::env::set_var("ZED_AUTO_UPDATE", "err1") };
342 let ret = perform_update(app_dir, None, false);
343 assert!(
344 ret.is_err_and(|e| e.to_string().as_str() == "Autoupdate failed, nothing to rollback")
345 );
346
347 let app_dir = tempfile::tempdir().unwrap();
348 let app_dir = app_dir.path();
349 // Simulate a timeout
350 unsafe { std::env::set_var("ZED_AUTO_UPDATE", "err2") };
351 let ret = perform_update(app_dir, None, false);
352 assert!(
353 ret.is_err_and(|e| e.to_string().as_str() == "Autoupdate failed, rollback successful")
354 );
355 assert!(std::env::var("ZED_AUTO_UPDATE_RB").is_ok_and(|e| e == "rollback1"));
356 }
357}