auto_update.rs

  1use anyhow::{Context as _, Result};
  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                .context("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                .context("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            anyhow::ensure!(
460                response.status().is_success(),
461                "failed to fetch release: {:?}",
462                String::from_utf8_lossy(&body),
463            );
464
465            serde_json::from_slice(body.as_slice()).with_context(|| {
466                format!(
467                    "error deserializing release {:?}",
468                    String::from_utf8_lossy(&body),
469                )
470            })
471        }
472    }
473
474    async fn get_latest_release(
475        this: &Entity<Self>,
476        asset: &str,
477        os: &str,
478        arch: &str,
479        release_channel: Option<ReleaseChannel>,
480        cx: &mut AsyncApp,
481    ) -> Result<JsonRelease> {
482        Self::get_release(this, asset, os, arch, None, release_channel, cx).await
483    }
484
485    async fn update(this: Entity<Self>, mut cx: AsyncApp) -> Result<()> {
486        let (client, current_version, release_channel) = this.update(&mut cx, |this, cx| {
487            this.status = AutoUpdateStatus::Checking;
488            cx.notify();
489            (
490                this.http_client.clone(),
491                this.current_version,
492                ReleaseChannel::try_global(cx),
493            )
494        })?;
495
496        let release =
497            Self::get_latest_release(&this, "zed", OS, ARCH, release_channel, &mut cx).await?;
498
499        let should_download = match *RELEASE_CHANNEL {
500            ReleaseChannel::Nightly => cx
501                .update(|cx| AppCommitSha::try_global(cx).map(|sha| release.version != sha.0))
502                .ok()
503                .flatten()
504                .unwrap_or(true),
505            _ => release.version.parse::<SemanticVersion>()? > current_version,
506        };
507
508        if !should_download {
509            this.update(&mut cx, |this, cx| {
510                this.status = AutoUpdateStatus::Idle;
511                cx.notify();
512            })?;
513            return Ok(());
514        }
515
516        this.update(&mut cx, |this, cx| {
517            this.status = AutoUpdateStatus::Downloading;
518            cx.notify();
519        })?;
520
521        let installer_dir = InstallerDir::new().await?;
522        let filename = match OS {
523            "macos" => anyhow::Ok("Zed.dmg"),
524            "linux" => Ok("zed.tar.gz"),
525            "windows" => Ok("ZedUpdateInstaller.exe"),
526            unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
527        }?;
528
529        #[cfg(not(target_os = "windows"))]
530        anyhow::ensure!(
531            which::which("rsync").is_ok(),
532            "Aborting. Could not find rsync which is required for auto-updates."
533        );
534
535        let downloaded_asset = installer_dir.path().join(filename);
536        download_release(&downloaded_asset, release, client, &cx).await?;
537
538        this.update(&mut cx, |this, cx| {
539            this.status = AutoUpdateStatus::Installing;
540            cx.notify();
541        })?;
542
543        let binary_path = match OS {
544            "macos" => install_release_macos(&installer_dir, downloaded_asset, &cx).await,
545            "linux" => install_release_linux(&installer_dir, downloaded_asset, &cx).await,
546            "windows" => install_release_windows(downloaded_asset).await,
547            unsupported_os => anyhow::bail!("not supported: {unsupported_os}"),
548        }?;
549
550        this.update(&mut cx, |this, cx| {
551            this.set_should_show_update_notification(true, cx)
552                .detach_and_log_err(cx);
553            this.status = AutoUpdateStatus::Updated { binary_path };
554            cx.notify();
555        })?;
556
557        Ok(())
558    }
559
560    pub fn set_should_show_update_notification(
561        &self,
562        should_show: bool,
563        cx: &App,
564    ) -> Task<Result<()>> {
565        cx.background_spawn(async move {
566            if should_show {
567                KEY_VALUE_STORE
568                    .write_kvp(
569                        SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string(),
570                        "".to_string(),
571                    )
572                    .await?;
573            } else {
574                KEY_VALUE_STORE
575                    .delete_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string())
576                    .await?;
577            }
578            Ok(())
579        })
580    }
581
582    pub fn should_show_update_notification(&self, cx: &App) -> Task<Result<bool>> {
583        cx.background_spawn(async move {
584            Ok(KEY_VALUE_STORE
585                .read_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)?
586                .is_some())
587        })
588    }
589}
590
591async fn download_remote_server_binary(
592    target_path: &PathBuf,
593    release: JsonRelease,
594    client: Arc<HttpClientWithUrl>,
595    cx: &AsyncApp,
596) -> Result<()> {
597    let temp = tempfile::Builder::new().tempfile_in(remote_servers_dir())?;
598    let mut temp_file = File::create(&temp).await?;
599    let update_request_body = build_remote_server_update_request_body(cx)?;
600    let request_body = AsyncBody::from(serde_json::to_string(&update_request_body)?);
601
602    let mut response = client.get(&release.url, request_body, true).await?;
603    anyhow::ensure!(
604        response.status().is_success(),
605        "failed to download remote server release: {:?}",
606        response.status()
607    );
608    smol::io::copy(response.body_mut(), &mut temp_file).await?;
609    smol::fs::rename(&temp, &target_path).await?;
610
611    Ok(())
612}
613
614fn build_remote_server_update_request_body(cx: &AsyncApp) -> Result<UpdateRequestBody> {
615    let (installation_id, release_channel, telemetry_enabled, is_staff) = cx.update(|cx| {
616        let telemetry = Client::global(cx).telemetry().clone();
617        let is_staff = telemetry.is_staff();
618        let installation_id = telemetry.installation_id();
619        let release_channel =
620            ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
621        let telemetry_enabled = TelemetrySettings::get_global(cx).metrics;
622
623        (
624            installation_id,
625            release_channel,
626            telemetry_enabled,
627            is_staff,
628        )
629    })?;
630
631    Ok(UpdateRequestBody {
632        installation_id,
633        release_channel,
634        telemetry: telemetry_enabled,
635        is_staff,
636        destination: "remote",
637    })
638}
639
640async fn download_release(
641    target_path: &Path,
642    release: JsonRelease,
643    client: Arc<HttpClientWithUrl>,
644    cx: &AsyncApp,
645) -> Result<()> {
646    let mut target_file = File::create(&target_path).await?;
647
648    let (installation_id, release_channel, telemetry_enabled, is_staff) = cx.update(|cx| {
649        let telemetry = Client::global(cx).telemetry().clone();
650        let is_staff = telemetry.is_staff();
651        let installation_id = telemetry.installation_id();
652        let release_channel =
653            ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
654        let telemetry_enabled = TelemetrySettings::get_global(cx).metrics;
655
656        (
657            installation_id,
658            release_channel,
659            telemetry_enabled,
660            is_staff,
661        )
662    })?;
663
664    let request_body = AsyncBody::from(serde_json::to_string(&UpdateRequestBody {
665        installation_id,
666        release_channel,
667        telemetry: telemetry_enabled,
668        is_staff,
669        destination: "local",
670    })?);
671
672    let mut response = client.get(&release.url, request_body, true).await?;
673    smol::io::copy(response.body_mut(), &mut target_file).await?;
674    log::info!("downloaded update. path:{:?}", target_path);
675
676    Ok(())
677}
678
679async fn install_release_linux(
680    temp_dir: &InstallerDir,
681    downloaded_tar_gz: PathBuf,
682    cx: &AsyncApp,
683) -> Result<PathBuf> {
684    let channel = cx.update(|cx| ReleaseChannel::global(cx).dev_name())?;
685    let home_dir = PathBuf::from(env::var("HOME").context("no HOME env var set")?);
686    let running_app_path = cx.update(|cx| cx.app_path())??;
687
688    let extracted = temp_dir.path().join("zed");
689    fs::create_dir_all(&extracted)
690        .await
691        .context("failed to create directory into which to extract update")?;
692
693    let output = Command::new("tar")
694        .arg("-xzf")
695        .arg(&downloaded_tar_gz)
696        .arg("-C")
697        .arg(&extracted)
698        .output()
699        .await?;
700
701    anyhow::ensure!(
702        output.status.success(),
703        "failed to extract {:?} to {:?}: {:?}",
704        downloaded_tar_gz,
705        extracted,
706        String::from_utf8_lossy(&output.stderr)
707    );
708
709    let suffix = if channel != "stable" {
710        format!("-{}", channel)
711    } else {
712        String::default()
713    };
714    let app_folder_name = format!("zed{}.app", suffix);
715
716    let from = extracted.join(&app_folder_name);
717    let mut to = home_dir.join(".local");
718
719    let expected_suffix = format!("{}/libexec/zed-editor", app_folder_name);
720
721    if let Some(prefix) = running_app_path
722        .to_str()
723        .and_then(|str| str.strip_suffix(&expected_suffix))
724    {
725        to = PathBuf::from(prefix);
726    }
727
728    let output = Command::new("rsync")
729        .args(["-av", "--delete"])
730        .arg(&from)
731        .arg(&to)
732        .output()
733        .await?;
734
735    anyhow::ensure!(
736        output.status.success(),
737        "failed to copy Zed update from {:?} to {:?}: {:?}",
738        from,
739        to,
740        String::from_utf8_lossy(&output.stderr)
741    );
742
743    Ok(to.join(expected_suffix))
744}
745
746async fn install_release_macos(
747    temp_dir: &InstallerDir,
748    downloaded_dmg: PathBuf,
749    cx: &AsyncApp,
750) -> Result<PathBuf> {
751    let running_app_path = cx.update(|cx| cx.app_path())??;
752    let running_app_filename = running_app_path
753        .file_name()
754        .with_context(|| format!("invalid running app path {running_app_path:?}"))?;
755
756    let mount_path = temp_dir.path().join("Zed");
757    let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
758
759    mounted_app_path.push("/");
760    let output = Command::new("hdiutil")
761        .args(["attach", "-nobrowse"])
762        .arg(&downloaded_dmg)
763        .arg("-mountroot")
764        .arg(temp_dir.path())
765        .output()
766        .await?;
767
768    anyhow::ensure!(
769        output.status.success(),
770        "failed to mount: {:?}",
771        String::from_utf8_lossy(&output.stderr)
772    );
773
774    // Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
775    let _unmounter = MacOsUnmounter {
776        mount_path: mount_path.clone(),
777    };
778
779    let output = Command::new("rsync")
780        .args(["-av", "--delete"])
781        .arg(&mounted_app_path)
782        .arg(&running_app_path)
783        .output()
784        .await?;
785
786    anyhow::ensure!(
787        output.status.success(),
788        "failed to copy app: {:?}",
789        String::from_utf8_lossy(&output.stderr)
790    );
791
792    Ok(running_app_path)
793}
794
795async fn install_release_windows(downloaded_installer: PathBuf) -> Result<PathBuf> {
796    let output = Command::new(downloaded_installer)
797        .arg("/verysilent")
798        .arg("/update=true")
799        .arg("!desktopicon")
800        .arg("!quicklaunchicon")
801        .output()
802        .await?;
803    anyhow::ensure!(
804        output.status.success(),
805        "failed to start installer: {:?}",
806        String::from_utf8_lossy(&output.stderr)
807    );
808    Ok(std::env::current_exe()?)
809}
810
811pub fn check_pending_installation() -> bool {
812    let Some(installer_path) = std::env::current_exe()
813        .ok()
814        .and_then(|p| p.parent().map(|p| p.join("updates")))
815    else {
816        return false;
817    };
818
819    // The installer will create a flag file after it finishes updating
820    let flag_file = installer_path.join("versions.txt");
821    if flag_file.exists() {
822        if let Some(helper) = installer_path
823            .parent()
824            .map(|p| p.join("tools\\auto_update_helper.exe"))
825        {
826            let _ = std::process::Command::new(helper).spawn();
827            return true;
828        }
829    }
830    false
831}