Enable first version of auto-updates on Linux (#11348)

Thorsten Ball created

This downloads Nightly/Preview releases on Linux and copies the contents
the `zed-<channel>.app` to `~/.local`.

What's missing:

- Check if we're not installed in ~/.local and abort
- Update `.desktop` file


Release Notes:

- N/A

Change summary

crates/auto_update/src/auto_update.rs | 237 ++++++++++++++++++++--------
1 file changed, 165 insertions(+), 72 deletions(-)

Detailed changes

crates/auto_update/src/auto_update.rs 🔗

@@ -15,7 +15,7 @@ use markdown_preview::markdown_preview_view::{MarkdownPreviewMode, MarkdownPrevi
 use schemars::JsonSchema;
 use serde::Deserialize;
 use serde_derive::Serialize;
-use smol::io::AsyncReadExt;
+use smol::{fs, io::AsyncReadExt};
 
 use settings::{Settings, SettingsSources, SettingsStore};
 use smol::{fs::File, process::Command};
@@ -24,6 +24,7 @@ use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
 use std::{
     env::consts::{ARCH, OS},
     ffi::OsString,
+    path::PathBuf,
     sync::Arc,
     time::Duration,
 };
@@ -340,9 +341,15 @@ impl AutoUpdater {
             (this.http_client.clone(), this.current_version)
         })?;
 
+        let asset = match OS {
+            "linux" => format!("zed-linux-{}.tar.gz", ARCH),
+            "macos" => "Zed.dmg".into(),
+            _ => return Err(anyhow!("auto-update not supported for OS {:?}", OS)),
+        };
+
         let mut url_string = client.build_url(&format!(
-            "/api/releases/latest?asset=Zed.dmg&os={}&arch={}",
-            OS, ARCH
+            "/api/releases/latest?asset={}&os={}&arch={}",
+            asset, OS, ARCH
         ));
         cx.update(|cx| {
             if let Some(param) = ReleaseChannel::try_global(cx)
@@ -361,6 +368,7 @@ impl AutoUpdater {
             .read_to_end(&mut body)
             .await
             .context("error reading release")?;
+
         let release: JsonRelease =
             serde_json::from_slice(body.as_slice()).context("error deserializing release")?;
 
@@ -389,81 +397,18 @@ impl AutoUpdater {
         let temp_dir = tempfile::Builder::new()
             .prefix("zed-auto-update")
             .tempdir()?;
-        let dmg_path = temp_dir.path().join("Zed.dmg");
-        let mount_path = temp_dir.path().join("Zed");
-        let running_app_path = ZED_APP_PATH
-            .clone()
-            .map_or_else(|| cx.update(|cx| cx.app_path())?, Ok)?;
-        let running_app_filename = running_app_path
-            .file_name()
-            .ok_or_else(|| anyhow!("invalid running app path"))?;
-        let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
-        mounted_app_path.push("/");
-
-        let mut dmg_file = File::create(&dmg_path).await?;
-
-        let (installation_id, release_channel, telemetry) = cx.update(|cx| {
-            let installation_id = Client::global(cx).telemetry().installation_id();
-            let release_channel = ReleaseChannel::try_global(cx)
-                .map(|release_channel| release_channel.display_name());
-            let telemetry = TelemetrySettings::get_global(cx).metrics;
-
-            (installation_id, release_channel, telemetry)
-        })?;
-
-        let request_body = AsyncBody::from(serde_json::to_string(&UpdateRequestBody {
-            installation_id,
-            release_channel,
-            telemetry,
-        })?);
-
-        let mut response = client.get(&release.url, request_body, true).await?;
-        smol::io::copy(response.body_mut(), &mut dmg_file).await?;
-        log::info!("downloaded update. path:{:?}", dmg_path);
+        let downloaded_asset = download_release(&temp_dir, release, &asset, client, &cx).await?;
 
         this.update(&mut cx, |this, cx| {
             this.status = AutoUpdateStatus::Installing;
             cx.notify();
         })?;
 
-        let output = Command::new("hdiutil")
-            .args(&["attach", "-nobrowse"])
-            .arg(&dmg_path)
-            .arg("-mountroot")
-            .arg(&temp_dir.path())
-            .output()
-            .await?;
-        if !output.status.success() {
-            Err(anyhow!(
-                "failed to mount: {:?}",
-                String::from_utf8_lossy(&output.stderr)
-            ))?;
-        }
-
-        let output = Command::new("rsync")
-            .args(&["-av", "--delete"])
-            .arg(&mounted_app_path)
-            .arg(&running_app_path)
-            .output()
-            .await?;
-        if !output.status.success() {
-            Err(anyhow!(
-                "failed to copy app: {:?}",
-                String::from_utf8_lossy(&output.stderr)
-            ))?;
-        }
-
-        let output = Command::new("hdiutil")
-            .args(&["detach"])
-            .arg(&mount_path)
-            .output()
-            .await?;
-        if !output.status.success() {
-            Err(anyhow!(
-                "failed to unmount: {:?}",
-                String::from_utf8_lossy(&output.stderr)
-            ))?;
-        }
+        match OS {
+            "macos" => install_release_macos(&temp_dir, downloaded_asset, &cx).await,
+            "linux" => install_release_linux(&temp_dir, downloaded_asset, &cx).await,
+            _ => Err(anyhow!("not supported: {:?}", OS)),
+        }?;
 
         this.update(&mut cx, |this, cx| {
             this.set_should_show_update_notification(true, cx)
@@ -471,6 +416,7 @@ impl AutoUpdater {
             this.status = AutoUpdateStatus::Updated;
             cx.notify();
         })?;
+
         Ok(())
     }
 
@@ -504,3 +450,150 @@ impl AutoUpdater {
         })
     }
 }
+
+async fn download_release(
+    temp_dir: &tempfile::TempDir,
+    release: JsonRelease,
+    target_filename: &str,
+    client: Arc<HttpClientWithUrl>,
+    cx: &AsyncAppContext,
+) -> Result<PathBuf> {
+    let target_path = temp_dir.path().join(target_filename);
+    let mut target_file = File::create(&target_path).await?;
+
+    let (installation_id, release_channel, telemetry) = cx.update(|cx| {
+        let installation_id = Client::global(cx).telemetry().installation_id();
+        let release_channel =
+            ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
+        let telemetry = TelemetrySettings::get_global(cx).metrics;
+
+        (installation_id, release_channel, telemetry)
+    })?;
+
+    let request_body = AsyncBody::from(serde_json::to_string(&UpdateRequestBody {
+        installation_id,
+        release_channel,
+        telemetry,
+    })?);
+
+    let mut response = client.get(&release.url, request_body, true).await?;
+    smol::io::copy(response.body_mut(), &mut target_file).await?;
+    log::info!("downloaded update. path:{:?}", target_path);
+
+    Ok(target_path)
+}
+
+async fn install_release_linux(
+    temp_dir: &tempfile::TempDir,
+    downloaded_tar_gz: PathBuf,
+    cx: &AsyncAppContext,
+) -> Result<()> {
+    let channel = cx.update(|cx| ReleaseChannel::global(cx).dev_name())?;
+    let home_dir = PathBuf::from(std::env::var("HOME").context("no HOME env var set")?);
+
+    let extracted = temp_dir.path().join("zed");
+    fs::create_dir_all(&extracted)
+        .await
+        .context("failed to create directory into which to extract update")?;
+
+    let output = Command::new("tar")
+        .arg("-xzf")
+        .arg(&downloaded_tar_gz)
+        .arg("-C")
+        .arg(&extracted)
+        .output()
+        .await?;
+
+    anyhow::ensure!(
+        output.status.success(),
+        "failed to extract {:?} to {:?}: {:?}",
+        downloaded_tar_gz,
+        extracted,
+        String::from_utf8_lossy(&output.stderr)
+    );
+
+    let suffix = if channel != "stable" {
+        format!("-{}", channel)
+    } else {
+        String::default()
+    };
+    let app_folder_name = format!("zed{}.app", suffix);
+
+    let from = extracted.join(&app_folder_name);
+    let to = home_dir.join(".local");
+
+    let output = Command::new("rsync")
+        .args(&["-av", "--delete"])
+        .arg(&from)
+        .arg(&to)
+        .output()
+        .await?;
+
+    anyhow::ensure!(
+        output.status.success(),
+        "failed to copy Zed update from {:?} to {:?}: {:?}",
+        from,
+        to,
+        String::from_utf8_lossy(&output.stderr)
+    );
+
+    Ok(())
+}
+
+async fn install_release_macos(
+    temp_dir: &tempfile::TempDir,
+    downloaded_dmg: PathBuf,
+    cx: &AsyncAppContext,
+) -> Result<()> {
+    let running_app_path = ZED_APP_PATH
+        .clone()
+        .map_or_else(|| cx.update(|cx| cx.app_path())?, Ok)?;
+    let running_app_filename = running_app_path
+        .file_name()
+        .ok_or_else(|| anyhow!("invalid running app path"))?;
+
+    let mount_path = temp_dir.path().join("Zed");
+    let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
+
+    mounted_app_path.push("/");
+    let output = Command::new("hdiutil")
+        .args(&["attach", "-nobrowse"])
+        .arg(&downloaded_dmg)
+        .arg("-mountroot")
+        .arg(&temp_dir.path())
+        .output()
+        .await?;
+
+    anyhow::ensure!(
+        output.status.success(),
+        "failed to mount: {:?}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+
+    let output = Command::new("rsync")
+        .args(&["-av", "--delete"])
+        .arg(&mounted_app_path)
+        .arg(&running_app_path)
+        .output()
+        .await?;
+
+    anyhow::ensure!(
+        output.status.success(),
+        "failed to copy app: {:?}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+
+    let output = Command::new("hdiutil")
+        .args(&["detach"])
+        .arg(&mount_path)
+        .output()
+        .await?;
+
+    anyhow::ensure!(
+        output.status.success(),
+        "failed to unount: {:?}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+
+    Ok(())
+}