auto_update.rs

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