auto_update.rs

  1use anyhow::{Context as _, Result, anyhow};
  2use client::{Client, TelemetrySettings};
  3use db::RELEASE_CHANNEL;
  4use db::kvp::KEY_VALUE_STORE;
  5use gpui::{
  6    App, AppContext as _, AsyncApp, Context, Entity, Global, SemanticVersion, Task, Window, actions,
  7};
  8use http_client::{AsyncBody, HttpClient, HttpClientWithUrl};
  9use paths::remote_servers_dir;
 10use release_channel::{AppCommitSha, ReleaseChannel};
 11use schemars::JsonSchema;
 12use serde::{Deserialize, Serialize};
 13use settings::{Settings, SettingsSources, SettingsStore};
 14use smol::{fs, io::AsyncReadExt};
 15use smol::{fs::File, process::Command};
 16use std::{
 17    env::{
 18        self,
 19        consts::{ARCH, OS},
 20    },
 21    ffi::OsString,
 22    path::{Path, PathBuf},
 23    sync::Arc,
 24    time::Duration,
 25};
 26use workspace::Workspace;
 27
 28const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification";
 29const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
 30
 31actions!(auto_update, [Check, DismissErrorMessage, ViewReleaseNotes,]);
 32
 33#[derive(Serialize)]
 34struct UpdateRequestBody {
 35    installation_id: Option<Arc<str>>,
 36    release_channel: Option<&'static str>,
 37    telemetry: bool,
 38    is_staff: Option<bool>,
 39    destination: &'static str,
 40}
 41
 42#[derive(Clone, PartialEq, Eq)]
 43pub enum AutoUpdateStatus {
 44    Idle,
 45    Checking,
 46    Downloading,
 47    Installing,
 48    Updated { binary_path: PathBuf },
 49    Errored,
 50}
 51
 52impl AutoUpdateStatus {
 53    pub fn is_updated(&self) -> bool {
 54        matches!(self, Self::Updated { .. })
 55    }
 56}
 57
 58pub struct AutoUpdater {
 59    status: AutoUpdateStatus,
 60    current_version: SemanticVersion,
 61    http_client: Arc<HttpClientWithUrl>,
 62    pending_poll: Option<Task<Option<()>>>,
 63}
 64
 65#[derive(Deserialize, Debug)]
 66pub struct JsonRelease {
 67    pub version: String,
 68    pub url: String,
 69}
 70
 71struct MacOsUnmounter {
 72    mount_path: PathBuf,
 73}
 74
 75impl Drop for MacOsUnmounter {
 76    fn drop(&mut self) {
 77        let unmount_output = std::process::Command::new("hdiutil")
 78            .args(["detach", "-force"])
 79            .arg(&self.mount_path)
 80            .output();
 81
 82        match unmount_output {
 83            Ok(output) if output.status.success() => {
 84                log::info!("Successfully unmounted the disk image");
 85            }
 86            Ok(output) => {
 87                log::error!(
 88                    "Failed to unmount disk image: {:?}",
 89                    String::from_utf8_lossy(&output.stderr)
 90                );
 91            }
 92            Err(error) => {
 93                log::error!("Error while trying to unmount disk image: {:?}", error);
 94            }
 95        }
 96    }
 97}
 98
 99struct AutoUpdateSetting(bool);
100
101/// Whether or not to automatically check for updates.
102///
103/// Default: true
104#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize)]
105#[serde(transparent)]
106struct AutoUpdateSettingContent(bool);
107
108impl Settings for AutoUpdateSetting {
109    const KEY: Option<&'static str> = Some("auto_update");
110
111    type FileContent = Option<AutoUpdateSettingContent>;
112
113    fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
114        let auto_update = [sources.server, sources.release_channel, sources.user]
115            .into_iter()
116            .find_map(|value| value.copied().flatten())
117            .unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
118
119        Ok(Self(auto_update.0))
120    }
121
122    fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {
123        vscode.enum_setting("update.mode", current, |s| match s {
124            "none" | "manual" => Some(AutoUpdateSettingContent(false)),
125            _ => Some(AutoUpdateSettingContent(true)),
126        });
127    }
128}
129
130#[derive(Default)]
131struct GlobalAutoUpdate(Option<Entity<AutoUpdater>>);
132
133impl Global for GlobalAutoUpdate {}
134
135pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
136    AutoUpdateSetting::register(cx);
137
138    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
139        workspace.register_action(|_, action: &Check, window, cx| check(action, window, cx));
140
141        workspace.register_action(|_, action, _, cx| {
142            view_release_notes(action, cx);
143        });
144    })
145    .detach();
146
147    let version = release_channel::AppVersion::global(cx);
148    let auto_updater = cx.new(|cx| {
149        let updater = AutoUpdater::new(version, http_client);
150
151        let poll_for_updates = ReleaseChannel::try_global(cx)
152            .map(|channel| channel.poll_for_updates())
153            .unwrap_or(false);
154
155        if option_env!("ZED_UPDATE_EXPLANATION").is_none()
156            && env::var("ZED_UPDATE_EXPLANATION").is_err()
157            && poll_for_updates
158        {
159            let mut update_subscription = AutoUpdateSetting::get_global(cx)
160                .0
161                .then(|| updater.start_polling(cx));
162
163            cx.observe_global::<SettingsStore>(move |updater: &mut AutoUpdater, cx| {
164                if AutoUpdateSetting::get_global(cx).0 {
165                    if update_subscription.is_none() {
166                        update_subscription = Some(updater.start_polling(cx))
167                    }
168                } else {
169                    update_subscription.take();
170                }
171            })
172            .detach();
173        }
174
175        updater
176    });
177    cx.set_global(GlobalAutoUpdate(Some(auto_updater)));
178}
179
180pub fn check(_: &Check, window: &mut Window, cx: &mut App) {
181    if let Some(message) = option_env!("ZED_UPDATE_EXPLANATION") {
182        drop(window.prompt(
183            gpui::PromptLevel::Info,
184            "Zed was installed via a package manager.",
185            Some(message),
186            &["Ok"],
187            cx,
188        ));
189        return;
190    }
191
192    if let Ok(message) = env::var("ZED_UPDATE_EXPLANATION") {
193        drop(window.prompt(
194            gpui::PromptLevel::Info,
195            "Zed was installed via a package manager.",
196            Some(&message),
197            &["Ok"],
198            cx,
199        ));
200        return;
201    }
202
203    if !ReleaseChannel::try_global(cx)
204        .map(|channel| channel.poll_for_updates())
205        .unwrap_or(false)
206    {
207        return;
208    }
209
210    if let Some(updater) = AutoUpdater::get(cx) {
211        updater.update(cx, |updater, cx| updater.poll(cx));
212    } else {
213        drop(window.prompt(
214            gpui::PromptLevel::Info,
215            "Could not check for updates",
216            Some("Auto-updates disabled for non-bundled app."),
217            &["Ok"],
218            cx,
219        ));
220    }
221}
222
223pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut App) -> Option<()> {
224    let auto_updater = AutoUpdater::get(cx)?;
225    let release_channel = ReleaseChannel::try_global(cx)?;
226
227    match release_channel {
228        ReleaseChannel::Stable | ReleaseChannel::Preview => {
229            let auto_updater = auto_updater.read(cx);
230            let current_version = auto_updater.current_version;
231            let release_channel = release_channel.dev_name();
232            let path = format!("/releases/{release_channel}/{current_version}");
233            let url = &auto_updater.http_client.build_url(&path);
234            cx.open_url(url);
235        }
236        ReleaseChannel::Nightly => {
237            cx.open_url("https://github.com/zed-industries/zed/commits/nightly/");
238        }
239        ReleaseChannel::Dev => {
240            cx.open_url("https://github.com/zed-industries/zed/commits/main/");
241        }
242    }
243    None
244}
245
246#[cfg(not(target_os = "windows"))]
247struct InstallerDir(tempfile::TempDir);
248
249#[cfg(not(target_os = "windows"))]
250impl InstallerDir {
251    async fn new() -> Result<Self> {
252        Ok(Self(
253            tempfile::Builder::new()
254                .prefix("zed-auto-update")
255                .tempdir()?,
256        ))
257    }
258
259    fn path(&self) -> &Path {
260        self.0.path()
261    }
262}
263
264#[cfg(target_os = "windows")]
265struct InstallerDir(PathBuf);
266
267#[cfg(target_os = "windows")]
268impl InstallerDir {
269    async fn new() -> Result<Self> {
270        let installer_dir = std::env::current_exe()?
271            .parent()
272            .context("No parent dir for Zed.exe")?
273            .join("updates");
274        if smol::fs::metadata(&installer_dir).await.is_ok() {
275            smol::fs::remove_dir_all(&installer_dir).await?;
276        }
277        smol::fs::create_dir(&installer_dir).await?;
278        Ok(Self(installer_dir))
279    }
280
281    fn path(&self) -> &Path {
282        self.0.as_path()
283    }
284}
285
286impl AutoUpdater {
287    pub fn get(cx: &mut App) -> Option<Entity<Self>> {
288        cx.default_global::<GlobalAutoUpdate>().0.clone()
289    }
290
291    fn new(current_version: SemanticVersion, http_client: Arc<HttpClientWithUrl>) -> Self {
292        Self {
293            status: AutoUpdateStatus::Idle,
294            current_version,
295            http_client,
296            pending_poll: None,
297        }
298    }
299
300    pub fn start_polling(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
301        cx.spawn(async move |this, cx| {
302            loop {
303                this.update(cx, |this, cx| this.poll(cx))?;
304                cx.background_executor().timer(POLL_INTERVAL).await;
305            }
306        })
307    }
308
309    pub fn poll(&mut self, cx: &mut Context<Self>) {
310        if self.pending_poll.is_some() || self.status.is_updated() {
311            return;
312        }
313
314        cx.notify();
315
316        self.pending_poll = Some(cx.spawn(async move |this, cx| {
317            let result = Self::update(this.upgrade()?, cx.clone()).await;
318            this.update(cx, |this, cx| {
319                this.pending_poll = None;
320                if let Err(error) = result {
321                    log::error!("auto-update failed: error:{:?}", error);
322                    this.status = AutoUpdateStatus::Errored;
323                    cx.notify();
324                }
325            })
326            .ok()
327        }));
328    }
329
330    pub fn current_version(&self) -> SemanticVersion {
331        self.current_version
332    }
333
334    pub fn status(&self) -> AutoUpdateStatus {
335        self.status.clone()
336    }
337
338    pub fn dismiss_error(&mut self, cx: &mut Context<Self>) -> bool {
339        if self.status == AutoUpdateStatus::Idle {
340            return false;
341        }
342        self.status = AutoUpdateStatus::Idle;
343        cx.notify();
344        true
345    }
346
347    // If you are packaging Zed and need to override the place it downloads SSH remotes from,
348    // you can override this function. You should also update get_remote_server_release_url to return
349    // Ok(None).
350    pub async fn download_remote_server_release(
351        os: &str,
352        arch: &str,
353        release_channel: ReleaseChannel,
354        version: Option<SemanticVersion>,
355        cx: &mut AsyncApp,
356    ) -> Result<PathBuf> {
357        let this = cx.update(|cx| {
358            cx.default_global::<GlobalAutoUpdate>()
359                .0
360                .clone()
361                .ok_or_else(|| anyhow!("auto-update not initialized"))
362        })??;
363
364        let release = Self::get_release(
365            &this,
366            "zed-remote-server",
367            os,
368            arch,
369            version,
370            Some(release_channel),
371            cx,
372        )
373        .await?;
374
375        let servers_dir = paths::remote_servers_dir();
376        let channel_dir = servers_dir.join(release_channel.dev_name());
377        let platform_dir = channel_dir.join(format!("{}-{}", os, arch));
378        let version_path = platform_dir.join(format!("{}.gz", release.version));
379        smol::fs::create_dir_all(&platform_dir).await.ok();
380
381        let client = this.read_with(cx, |this, _| this.http_client.clone())?;
382
383        if smol::fs::metadata(&version_path).await.is_err() {
384            log::info!(
385                "downloading zed-remote-server {os} {arch} version {}",
386                release.version
387            );
388            download_remote_server_binary(&version_path, release, client, cx).await?;
389        }
390
391        Ok(version_path)
392    }
393
394    pub async fn get_remote_server_release_url(
395        os: &str,
396        arch: &str,
397        release_channel: ReleaseChannel,
398        version: Option<SemanticVersion>,
399        cx: &mut AsyncApp,
400    ) -> Result<Option<(String, String)>> {
401        let this = cx.update(|cx| {
402            cx.default_global::<GlobalAutoUpdate>()
403                .0
404                .clone()
405                .ok_or_else(|| anyhow!("auto-update not initialized"))
406        })??;
407
408        let release = Self::get_release(
409            &this,
410            "zed-remote-server",
411            os,
412            arch,
413            version,
414            Some(release_channel),
415            cx,
416        )
417        .await?;
418
419        let update_request_body = build_remote_server_update_request_body(cx)?;
420        let body = serde_json::to_string(&update_request_body)?;
421
422        Ok(Some((release.url, body)))
423    }
424
425    async fn get_release(
426        this: &Entity<Self>,
427        asset: &str,
428        os: &str,
429        arch: &str,
430        version: Option<SemanticVersion>,
431        release_channel: Option<ReleaseChannel>,
432        cx: &mut AsyncApp,
433    ) -> Result<JsonRelease> {
434        let client = this.read_with(cx, |this, _| this.http_client.clone())?;
435
436        if let Some(version) = version {
437            let channel = release_channel.map(|c| c.dev_name()).unwrap_or("stable");
438
439            let url = format!("/api/releases/{channel}/{version}/{asset}-{os}-{arch}.gz?update=1",);
440
441            Ok(JsonRelease {
442                version: version.to_string(),
443                url: client.build_url(&url),
444            })
445        } else {
446            let mut url_string = client.build_url(&format!(
447                "/api/releases/latest?asset={}&os={}&arch={}",
448                asset, os, arch
449            ));
450            if let Some(param) = release_channel.and_then(|c| c.release_query_param()) {
451                url_string += "&";
452                url_string += param;
453            }
454
455            let mut response = client.get(&url_string, Default::default(), true).await?;
456            let mut body = Vec::new();
457            response.body_mut().read_to_end(&mut body).await?;
458
459            if !response.status().is_success() {
460                return Err(anyhow!(
461                    "failed to fetch release: {:?}",
462                    String::from_utf8_lossy(&body),
463                ));
464            }
465
466            serde_json::from_slice(body.as_slice()).with_context(|| {
467                format!(
468                    "error deserializing release {:?}",
469                    String::from_utf8_lossy(&body),
470                )
471            })
472        }
473    }
474
475    async fn get_latest_release(
476        this: &Entity<Self>,
477        asset: &str,
478        os: &str,
479        arch: &str,
480        release_channel: Option<ReleaseChannel>,
481        cx: &mut AsyncApp,
482    ) -> Result<JsonRelease> {
483        Self::get_release(this, asset, os, arch, None, release_channel, cx).await
484    }
485
486    async fn update(this: Entity<Self>, mut cx: AsyncApp) -> Result<()> {
487        let (client, current_version, release_channel) = this.update(&mut cx, |this, cx| {
488            this.status = AutoUpdateStatus::Checking;
489            cx.notify();
490            (
491                this.http_client.clone(),
492                this.current_version,
493                ReleaseChannel::try_global(cx),
494            )
495        })?;
496
497        let release =
498            Self::get_latest_release(&this, "zed", OS, ARCH, release_channel, &mut cx).await?;
499
500        let should_download = match *RELEASE_CHANNEL {
501            ReleaseChannel::Nightly => cx
502                .update(|cx| AppCommitSha::try_global(cx).map(|sha| release.version != sha.0))
503                .ok()
504                .flatten()
505                .unwrap_or(true),
506            _ => release.version.parse::<SemanticVersion>()? > current_version,
507        };
508
509        if !should_download {
510            this.update(&mut cx, |this, cx| {
511                this.status = AutoUpdateStatus::Idle;
512                cx.notify();
513            })?;
514            return Ok(());
515        }
516
517        this.update(&mut cx, |this, cx| {
518            this.status = AutoUpdateStatus::Downloading;
519            cx.notify();
520        })?;
521
522        let installer_dir = InstallerDir::new().await?;
523        let filename = match OS {
524            "macos" => Ok("Zed.dmg"),
525            "linux" => Ok("zed.tar.gz"),
526            "windows" => Ok("ZedUpdateInstaller.exe"),
527            _ => Err(anyhow!("not supported: {:?}", OS)),
528        }?;
529
530        #[cfg(not(target_os = "windows"))]
531        anyhow::ensure!(
532            which::which("rsync").is_ok(),
533            "Aborting. Could not find rsync which is required for auto-updates."
534        );
535
536        let downloaded_asset = installer_dir.path().join(filename);
537        download_release(&downloaded_asset, release, client, &cx).await?;
538
539        this.update(&mut cx, |this, cx| {
540            this.status = AutoUpdateStatus::Installing;
541            cx.notify();
542        })?;
543
544        let binary_path = match OS {
545            "macos" => install_release_macos(&installer_dir, downloaded_asset, &cx).await,
546            "linux" => install_release_linux(&installer_dir, downloaded_asset, &cx).await,
547            "windows" => install_release_windows(downloaded_asset).await,
548            _ => Err(anyhow!("not supported: {:?}", OS)),
549        }?;
550
551        this.update(&mut cx, |this, cx| {
552            this.set_should_show_update_notification(true, cx)
553                .detach_and_log_err(cx);
554            this.status = AutoUpdateStatus::Updated { binary_path };
555            cx.notify();
556        })?;
557
558        Ok(())
559    }
560
561    pub fn set_should_show_update_notification(
562        &self,
563        should_show: bool,
564        cx: &App,
565    ) -> Task<Result<()>> {
566        cx.background_spawn(async move {
567            if should_show {
568                KEY_VALUE_STORE
569                    .write_kvp(
570                        SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string(),
571                        "".to_string(),
572                    )
573                    .await?;
574            } else {
575                KEY_VALUE_STORE
576                    .delete_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string())
577                    .await?;
578            }
579            Ok(())
580        })
581    }
582
583    pub fn should_show_update_notification(&self, cx: &App) -> Task<Result<bool>> {
584        cx.background_spawn(async move {
585            Ok(KEY_VALUE_STORE
586                .read_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)?
587                .is_some())
588        })
589    }
590}
591
592async fn download_remote_server_binary(
593    target_path: &PathBuf,
594    release: JsonRelease,
595    client: Arc<HttpClientWithUrl>,
596    cx: &AsyncApp,
597) -> Result<()> {
598    let temp = tempfile::Builder::new().tempfile_in(remote_servers_dir())?;
599    let mut temp_file = File::create(&temp).await?;
600    let update_request_body = build_remote_server_update_request_body(cx)?;
601    let request_body = AsyncBody::from(serde_json::to_string(&update_request_body)?);
602
603    let mut response = client.get(&release.url, request_body, true).await?;
604    if !response.status().is_success() {
605        return Err(anyhow!(
606            "failed to download remote server release: {:?}",
607            response.status()
608        ));
609    }
610    smol::io::copy(response.body_mut(), &mut temp_file).await?;
611    smol::fs::rename(&temp, &target_path).await?;
612
613    Ok(())
614}
615
616fn build_remote_server_update_request_body(cx: &AsyncApp) -> Result<UpdateRequestBody> {
617    let (installation_id, release_channel, telemetry_enabled, is_staff) = cx.update(|cx| {
618        let telemetry = Client::global(cx).telemetry().clone();
619        let is_staff = telemetry.is_staff();
620        let installation_id = telemetry.installation_id();
621        let release_channel =
622            ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
623        let telemetry_enabled = TelemetrySettings::get_global(cx).metrics;
624
625        (
626            installation_id,
627            release_channel,
628            telemetry_enabled,
629            is_staff,
630        )
631    })?;
632
633    Ok(UpdateRequestBody {
634        installation_id,
635        release_channel,
636        telemetry: telemetry_enabled,
637        is_staff,
638        destination: "remote",
639    })
640}
641
642async fn download_release(
643    target_path: &Path,
644    release: JsonRelease,
645    client: Arc<HttpClientWithUrl>,
646    cx: &AsyncApp,
647) -> Result<()> {
648    let mut target_file = File::create(&target_path).await?;
649
650    let (installation_id, release_channel, telemetry_enabled, is_staff) = cx.update(|cx| {
651        let telemetry = Client::global(cx).telemetry().clone();
652        let is_staff = telemetry.is_staff();
653        let installation_id = telemetry.installation_id();
654        let release_channel =
655            ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
656        let telemetry_enabled = TelemetrySettings::get_global(cx).metrics;
657
658        (
659            installation_id,
660            release_channel,
661            telemetry_enabled,
662            is_staff,
663        )
664    })?;
665
666    let request_body = AsyncBody::from(serde_json::to_string(&UpdateRequestBody {
667        installation_id,
668        release_channel,
669        telemetry: telemetry_enabled,
670        is_staff,
671        destination: "local",
672    })?);
673
674    let mut response = client.get(&release.url, request_body, true).await?;
675    smol::io::copy(response.body_mut(), &mut target_file).await?;
676    log::info!("downloaded update. path:{:?}", target_path);
677
678    Ok(())
679}
680
681async fn install_release_linux(
682    temp_dir: &InstallerDir,
683    downloaded_tar_gz: PathBuf,
684    cx: &AsyncApp,
685) -> Result<PathBuf> {
686    let channel = cx.update(|cx| ReleaseChannel::global(cx).dev_name())?;
687    let home_dir = PathBuf::from(env::var("HOME").context("no HOME env var set")?);
688    let running_app_path = cx.update(|cx| cx.app_path())??;
689
690    let extracted = temp_dir.path().join("zed");
691    fs::create_dir_all(&extracted)
692        .await
693        .context("failed to create directory into which to extract update")?;
694
695    let output = Command::new("tar")
696        .arg("-xzf")
697        .arg(&downloaded_tar_gz)
698        .arg("-C")
699        .arg(&extracted)
700        .output()
701        .await?;
702
703    anyhow::ensure!(
704        output.status.success(),
705        "failed to extract {:?} to {:?}: {:?}",
706        downloaded_tar_gz,
707        extracted,
708        String::from_utf8_lossy(&output.stderr)
709    );
710
711    let suffix = if channel != "stable" {
712        format!("-{}", channel)
713    } else {
714        String::default()
715    };
716    let app_folder_name = format!("zed{}.app", suffix);
717
718    let from = extracted.join(&app_folder_name);
719    let mut to = home_dir.join(".local");
720
721    let expected_suffix = format!("{}/libexec/zed-editor", app_folder_name);
722
723    if let Some(prefix) = running_app_path
724        .to_str()
725        .and_then(|str| str.strip_suffix(&expected_suffix))
726    {
727        to = PathBuf::from(prefix);
728    }
729
730    let output = Command::new("rsync")
731        .args(["-av", "--delete"])
732        .arg(&from)
733        .arg(&to)
734        .output()
735        .await?;
736
737    anyhow::ensure!(
738        output.status.success(),
739        "failed to copy Zed update from {:?} to {:?}: {:?}",
740        from,
741        to,
742        String::from_utf8_lossy(&output.stderr)
743    );
744
745    Ok(to.join(expected_suffix))
746}
747
748async fn install_release_macos(
749    temp_dir: &InstallerDir,
750    downloaded_dmg: PathBuf,
751    cx: &AsyncApp,
752) -> Result<PathBuf> {
753    let running_app_path = cx.update(|cx| cx.app_path())??;
754    let running_app_filename = running_app_path
755        .file_name()
756        .ok_or_else(|| anyhow!("invalid running app path"))?;
757
758    let mount_path = temp_dir.path().join("Zed");
759    let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
760
761    mounted_app_path.push("/");
762    let output = Command::new("hdiutil")
763        .args(["attach", "-nobrowse"])
764        .arg(&downloaded_dmg)
765        .arg("-mountroot")
766        .arg(temp_dir.path())
767        .output()
768        .await?;
769
770    anyhow::ensure!(
771        output.status.success(),
772        "failed to mount: {:?}",
773        String::from_utf8_lossy(&output.stderr)
774    );
775
776    // Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
777    let _unmounter = MacOsUnmounter {
778        mount_path: mount_path.clone(),
779    };
780
781    let output = Command::new("rsync")
782        .args(["-av", "--delete"])
783        .arg(&mounted_app_path)
784        .arg(&running_app_path)
785        .output()
786        .await?;
787
788    anyhow::ensure!(
789        output.status.success(),
790        "failed to copy app: {:?}",
791        String::from_utf8_lossy(&output.stderr)
792    );
793
794    Ok(running_app_path)
795}
796
797async fn install_release_windows(downloaded_installer: PathBuf) -> Result<PathBuf> {
798    let output = Command::new(downloaded_installer)
799        .arg("/verysilent")
800        .arg("/update=true")
801        .arg("!desktopicon")
802        .arg("!quicklaunchicon")
803        .output()
804        .await?;
805    anyhow::ensure!(
806        output.status.success(),
807        "failed to start installer: {:?}",
808        String::from_utf8_lossy(&output.stderr)
809    );
810    Ok(std::env::current_exe()?)
811}
812
813pub fn check_pending_installation() -> bool {
814    let Some(installer_path) = std::env::current_exe()
815        .ok()
816        .and_then(|p| p.parent().map(|p| p.join("updates")))
817    else {
818        return false;
819    };
820
821    // The installer will create a flag file after it finishes updating
822    let flag_file = installer_path.join("versions.txt");
823    if flag_file.exists() {
824        if let Some(helper) = installer_path
825            .parent()
826            .map(|p| p.join("tools\\auto_update_helper.exe"))
827        {
828            let _ = std::process::Command::new(helper).spawn();
829            return true;
830        }
831    }
832    false
833}