Use cloud for auto-update (#42246)

Conrad Irwin created

We've had several outages with a proximate cause of "vercel is
complicated",
and auto-update is considered a critical feature; so lets not use vercel
for
that.

Release Notes:

- Auto Updates (and remote server binaries) are now downloaded via
https://cloud.zed.dev instead of https://zed.dev. As before, these URLs
redirect to the GitHub release for actual downloads.

Change summary

Cargo.lock                                       |   6 
crates/auto_update/Cargo.toml                    |   5 
crates/auto_update/src/auto_update.rs            | 438 +++++++++++------
crates/client/src/client.rs                      |   2 
crates/cloud_api_client/src/cloud_api_client.rs  |   8 
crates/gpui/src/app/test_context.rs              |  11 
crates/gpui/src/platform/test/platform.rs        |   8 
crates/http_client/Cargo.toml                    |   1 
crates/http_client/src/http_client.rs            |  21 
crates/recent_projects/src/remote_connections.rs |  12 
crates/release_channel/src/lib.rs                |   6 
crates/remote/src/remote_client.rs               |   8 
crates/remote/src/transport/ssh.rs               |  22 
crates/zed/src/main.rs                           |   2 
script/bundle-mac                                |  22 
15 files changed, 350 insertions(+), 222 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1330,10 +1330,14 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "client",
+ "clock",
+ "ctor",
  "db",
+ "futures 0.3.31",
  "gpui",
  "http_client",
  "log",
+ "parking_lot",
  "paths",
  "release_channel",
  "serde",
@@ -1344,6 +1348,7 @@ dependencies = [
  "util",
  "which 6.0.3",
  "workspace",
+ "zlog",
 ]
 
 [[package]]
@@ -7799,6 +7804,7 @@ dependencies = [
  "parking_lot",
  "serde",
  "serde_json",
+ "serde_urlencoded",
  "sha2",
  "tempfile",
  "url",

crates/auto_update/Cargo.toml 🔗

@@ -33,4 +33,9 @@ workspace.workspace = true
 which.workspace = true
 
 [dev-dependencies]
+ctor.workspace = true
+clock= { workspace = true, "features" = ["test-support"] }
+futures.workspace = true
 gpui = { workspace = true, "features" = ["test-support"] }
+parking_lot.workspace = true
+zlog.workspace = true

crates/auto_update/src/auto_update.rs 🔗

@@ -1,12 +1,11 @@
 use anyhow::{Context as _, Result};
-use client::{Client, TelemetrySettings};
-use db::RELEASE_CHANNEL;
+use client::Client;
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{
     App, AppContext as _, AsyncApp, BackgroundExecutor, Context, Entity, Global, SemanticVersion,
     Task, Window, actions,
 };
-use http_client::{AsyncBody, HttpClient, HttpClientWithUrl};
+use http_client::{HttpClient, HttpClientWithUrl};
 use paths::remote_servers_dir;
 use release_channel::{AppCommitSha, ReleaseChannel};
 use serde::{Deserialize, Serialize};
@@ -41,22 +40,23 @@ actions!(
     ]
 );
 
-#[derive(Serialize)]
-struct UpdateRequestBody {
-    installation_id: Option<Arc<str>>,
-    release_channel: Option<&'static str>,
-    telemetry: bool,
-    is_staff: Option<bool>,
-    destination: &'static str,
-}
-
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum VersionCheckType {
     Sha(AppCommitSha),
     Semantic(SemanticVersion),
 }
 
-#[derive(Clone)]
+#[derive(Serialize, Debug)]
+pub struct AssetQuery<'a> {
+    asset: &'a str,
+    os: &'a str,
+    arch: &'a str,
+    metrics_id: Option<&'a str>,
+    system_id: Option<&'a str>,
+    is_staff: Option<bool>,
+}
+
+#[derive(Clone, Debug)]
 pub enum AutoUpdateStatus {
     Idle,
     Checking,
@@ -66,6 +66,31 @@ pub enum AutoUpdateStatus {
     Errored { error: Arc<anyhow::Error> },
 }
 
+impl PartialEq for AutoUpdateStatus {
+    fn eq(&self, other: &Self) -> bool {
+        match (self, other) {
+            (AutoUpdateStatus::Idle, AutoUpdateStatus::Idle) => true,
+            (AutoUpdateStatus::Checking, AutoUpdateStatus::Checking) => true,
+            (
+                AutoUpdateStatus::Downloading { version: v1 },
+                AutoUpdateStatus::Downloading { version: v2 },
+            ) => v1 == v2,
+            (
+                AutoUpdateStatus::Installing { version: v1 },
+                AutoUpdateStatus::Installing { version: v2 },
+            ) => v1 == v2,
+            (
+                AutoUpdateStatus::Updated { version: v1 },
+                AutoUpdateStatus::Updated { version: v2 },
+            ) => v1 == v2,
+            (AutoUpdateStatus::Errored { error: e1 }, AutoUpdateStatus::Errored { error: e2 }) => {
+                e1.to_string() == e2.to_string()
+            }
+            _ => false,
+        }
+    }
+}
+
 impl AutoUpdateStatus {
     pub fn is_updated(&self) -> bool {
         matches!(self, Self::Updated { .. })
@@ -75,13 +100,13 @@ impl AutoUpdateStatus {
 pub struct AutoUpdater {
     status: AutoUpdateStatus,
     current_version: SemanticVersion,
-    http_client: Arc<HttpClientWithUrl>,
+    client: Arc<Client>,
     pending_poll: Option<Task<Option<()>>>,
     quit_subscription: Option<gpui::Subscription>,
 }
 
-#[derive(Deserialize, Clone, Debug)]
-pub struct JsonRelease {
+#[derive(Deserialize, Serialize, Clone, Debug)]
+pub struct ReleaseAsset {
     pub version: String,
     pub url: String,
 }
@@ -137,7 +162,7 @@ struct GlobalAutoUpdate(Option<Entity<AutoUpdater>>);
 
 impl Global for GlobalAutoUpdate {}
 
-pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
+pub fn init(client: Arc<Client>, cx: &mut App) {
     cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
         workspace.register_action(|_, action, window, cx| check(action, window, cx));
 
@@ -149,7 +174,7 @@ pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
 
     let version = release_channel::AppVersion::global(cx);
     let auto_updater = cx.new(|cx| {
-        let updater = AutoUpdater::new(version, http_client, cx);
+        let updater = AutoUpdater::new(version, client, cx);
 
         let poll_for_updates = ReleaseChannel::try_global(cx)
             .map(|channel| channel.poll_for_updates())
@@ -233,7 +258,7 @@ pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut App) -> Option<()> {
             let current_version = auto_updater.current_version;
             let release_channel = release_channel.dev_name();
             let path = format!("/releases/{release_channel}/{current_version}");
-            let url = &auto_updater.http_client.build_url(&path);
+            let url = &auto_updater.client.http_client().build_url(&path);
             cx.open_url(url);
         }
         ReleaseChannel::Nightly => {
@@ -296,11 +321,7 @@ impl AutoUpdater {
         cx.default_global::<GlobalAutoUpdate>().0.clone()
     }
 
-    fn new(
-        current_version: SemanticVersion,
-        http_client: Arc<HttpClientWithUrl>,
-        cx: &mut Context<Self>,
-    ) -> Self {
+    fn new(current_version: SemanticVersion, client: Arc<Client>, cx: &mut Context<Self>) -> Self {
         // On windows, executable files cannot be overwritten while they are
         // running, so we must wait to overwrite the application until quitting
         // or restarting. When quitting the app, we spawn the auto update helper
@@ -321,7 +342,7 @@ impl AutoUpdater {
         Self {
             status: AutoUpdateStatus::Idle,
             current_version,
-            http_client,
+            client,
             pending_poll: None,
             quit_subscription,
         }
@@ -354,7 +375,7 @@ impl AutoUpdater {
         cx.notify();
 
         self.pending_poll = Some(cx.spawn(async move |this, cx| {
-            let result = Self::update(this.upgrade()?, cx.clone()).await;
+            let result = Self::update(this.upgrade()?, cx).await;
             this.update(cx, |this, cx| {
                 this.pending_poll = None;
                 if let Err(error) = result {
@@ -400,10 +421,10 @@ impl AutoUpdater {
     // you can override this function. You should also update get_remote_server_release_url to return
     // Ok(None).
     pub async fn download_remote_server_release(
-        os: &str,
-        arch: &str,
         release_channel: ReleaseChannel,
         version: Option<SemanticVersion>,
+        os: &str,
+        arch: &str,
         set_status: impl Fn(&str, &mut AsyncApp) + Send + 'static,
         cx: &mut AsyncApp,
     ) -> Result<PathBuf> {
@@ -415,13 +436,13 @@ impl AutoUpdater {
         })??;
 
         set_status("Fetching remote server release", cx);
-        let release = Self::get_release(
+        let release = Self::get_release_asset(
             &this,
+            release_channel,
+            version,
             "zed-remote-server",
             os,
             arch,
-            version,
-            Some(release_channel),
             cx,
         )
         .await?;
@@ -432,7 +453,7 @@ impl AutoUpdater {
         let version_path = platform_dir.join(format!("{}.gz", release.version));
         smol::fs::create_dir_all(&platform_dir).await.ok();
 
-        let client = this.read_with(cx, |this, _| this.http_client.clone())?;
+        let client = this.read_with(cx, |this, _| this.client.http_client())?;
 
         if smol::fs::metadata(&version_path).await.is_err() {
             log::info!(
@@ -440,19 +461,19 @@ impl AutoUpdater {
                 release.version
             );
             set_status("Downloading remote server", cx);
-            download_remote_server_binary(&version_path, release, client, cx).await?;
+            download_remote_server_binary(&version_path, release, client).await?;
         }
 
         Ok(version_path)
     }
 
     pub async fn get_remote_server_release_url(
+        channel: ReleaseChannel,
+        version: Option<SemanticVersion>,
         os: &str,
         arch: &str,
-        release_channel: ReleaseChannel,
-        version: Option<SemanticVersion>,
         cx: &mut AsyncApp,
-    ) -> Result<Option<(String, String)>> {
+    ) -> Result<Option<String>> {
         let this = cx.update(|cx| {
             cx.default_global::<GlobalAutoUpdate>()
                 .0
@@ -460,108 +481,99 @@ impl AutoUpdater {
                 .context("auto-update not initialized")
         })??;
 
-        let release = Self::get_release(
-            &this,
-            "zed-remote-server",
-            os,
-            arch,
-            version,
-            Some(release_channel),
-            cx,
-        )
-        .await?;
-
-        let update_request_body = build_remote_server_update_request_body(cx)?;
-        let body = serde_json::to_string(&update_request_body)?;
+        let release =
+            Self::get_release_asset(&this, channel, version, "zed-remote-server", os, arch, cx)
+                .await?;
 
-        Ok(Some((release.url, body)))
+        Ok(Some(release.url))
     }
 
-    async fn get_release(
+    async fn get_release_asset(
         this: &Entity<Self>,
+        release_channel: ReleaseChannel,
+        version: Option<SemanticVersion>,
         asset: &str,
         os: &str,
         arch: &str,
-        version: Option<SemanticVersion>,
-        release_channel: Option<ReleaseChannel>,
         cx: &mut AsyncApp,
-    ) -> Result<JsonRelease> {
-        let client = this.read_with(cx, |this, _| this.http_client.clone())?;
-
-        if let Some(version) = version {
-            let channel = release_channel.map(|c| c.dev_name()).unwrap_or("stable");
-
-            let url = format!("/api/releases/{channel}/{version}/{asset}-{os}-{arch}.gz?update=1",);
-
-            Ok(JsonRelease {
-                version: version.to_string(),
-                url: client.build_url(&url),
-            })
+    ) -> Result<ReleaseAsset> {
+        let client = this.read_with(cx, |this, _| this.client.clone())?;
+
+        let (system_id, metrics_id, is_staff) = if client.telemetry().metrics_enabled() {
+            (
+                client.telemetry().system_id(),
+                client.telemetry().metrics_id(),
+                client.telemetry().is_staff(),
+            )
         } else {
-            let mut url_string = client.build_url(&format!(
-                "/api/releases/latest?asset={}&os={}&arch={}",
-                asset, os, arch
-            ));
-            if let Some(param) = release_channel.and_then(|c| c.release_query_param()) {
-                url_string += "&";
-                url_string += param;
-            }
+            (None, None, None)
+        };
 
-            let mut response = client.get(&url_string, Default::default(), true).await?;
-            let mut body = Vec::new();
-            response.body_mut().read_to_end(&mut body).await?;
+        let version = if let Some(version) = version {
+            version.to_string()
+        } else {
+            "latest".to_string()
+        };
+        let http_client = client.http_client();
+
+        let path = format!("/releases/{}/{}/asset", release_channel.dev_name(), version,);
+        let url = http_client.build_zed_cloud_url_with_query(
+            &path,
+            AssetQuery {
+                os,
+                arch,
+                asset,
+                metrics_id: metrics_id.as_deref(),
+                system_id: system_id.as_deref(),
+                is_staff: is_staff,
+            },
+        )?;
 
-            anyhow::ensure!(
-                response.status().is_success(),
-                "failed to fetch release: {:?}",
-                String::from_utf8_lossy(&body),
-            );
+        let mut response = http_client
+            .get(url.as_str(), Default::default(), true)
+            .await?;
+        let mut body = Vec::new();
+        response.body_mut().read_to_end(&mut body).await?;
 
-            serde_json::from_slice(body.as_slice()).with_context(|| {
-                format!(
-                    "error deserializing release {:?}",
-                    String::from_utf8_lossy(&body),
-                )
-            })
-        }
-    }
+        anyhow::ensure!(
+            response.status().is_success(),
+            "failed to fetch release: {:?}",
+            String::from_utf8_lossy(&body),
+        );
 
-    async fn get_latest_release(
-        this: &Entity<Self>,
-        asset: &str,
-        os: &str,
-        arch: &str,
-        release_channel: Option<ReleaseChannel>,
-        cx: &mut AsyncApp,
-    ) -> Result<JsonRelease> {
-        Self::get_release(this, asset, os, arch, None, release_channel, cx).await
+        serde_json::from_slice(body.as_slice()).with_context(|| {
+            format!(
+                "error deserializing release {:?}",
+                String::from_utf8_lossy(&body),
+            )
+        })
     }
 
-    async fn update(this: Entity<Self>, mut cx: AsyncApp) -> Result<()> {
+    async fn update(this: Entity<Self>, cx: &mut AsyncApp) -> Result<()> {
         let (client, installed_version, previous_status, release_channel) =
-            this.read_with(&cx, |this, cx| {
+            this.read_with(cx, |this, cx| {
                 (
-                    this.http_client.clone(),
+                    this.client.http_client(),
                     this.current_version,
                     this.status.clone(),
-                    ReleaseChannel::try_global(cx),
+                    ReleaseChannel::try_global(cx).unwrap_or(ReleaseChannel::Stable),
                 )
             })?;
 
         Self::check_dependencies()?;
 
-        this.update(&mut cx, |this, cx| {
+        this.update(cx, |this, cx| {
             this.status = AutoUpdateStatus::Checking;
             log::info!("Auto Update: checking for updates");
             cx.notify();
         })?;
 
         let fetched_release_data =
-            Self::get_latest_release(&this, "zed", OS, ARCH, release_channel, &mut cx).await?;
+            Self::get_release_asset(&this, release_channel, None, "zed", OS, ARCH, cx).await?;
         let fetched_version = fetched_release_data.clone().version;
         let app_commit_sha = cx.update(|cx| AppCommitSha::try_global(cx).map(|sha| sha.full()));
         let newer_version = Self::check_if_fetched_version_is_newer(
-            *RELEASE_CHANNEL,
+            release_channel,
             app_commit_sha,
             installed_version,
             fetched_version,
@@ -569,7 +581,7 @@ impl AutoUpdater {
         )?;
 
         let Some(newer_version) = newer_version else {
-            return this.update(&mut cx, |this, cx| {
+            return this.update(cx, |this, cx| {
                 let status = match previous_status {
                     AutoUpdateStatus::Updated { .. } => previous_status,
                     _ => AutoUpdateStatus::Idle,
@@ -579,7 +591,7 @@ impl AutoUpdater {
             });
         };
 
-        this.update(&mut cx, |this, cx| {
+        this.update(cx, |this, cx| {
             this.status = AutoUpdateStatus::Downloading {
                 version: newer_version.clone(),
             };
@@ -588,21 +600,21 @@ impl AutoUpdater {
 
         let installer_dir = InstallerDir::new().await?;
         let target_path = Self::target_path(&installer_dir).await?;
-        download_release(&target_path, fetched_release_data, client, &cx).await?;
+        download_release(&target_path, fetched_release_data, client).await?;
 
-        this.update(&mut cx, |this, cx| {
+        this.update(cx, |this, cx| {
             this.status = AutoUpdateStatus::Installing {
                 version: newer_version.clone(),
             };
             cx.notify();
         })?;
 
-        let new_binary_path = Self::install_release(installer_dir, target_path, &cx).await?;
+        let new_binary_path = Self::install_release(installer_dir, target_path, cx).await?;
         if let Some(new_binary_path) = new_binary_path {
             cx.update(|cx| cx.set_restart_path(new_binary_path))?;
         }
 
-        this.update(&mut cx, |this, cx| {
+        this.update(cx, |this, cx| {
             this.set_should_show_update_notification(true, cx)
                 .detach_and_log_err(cx);
             this.status = AutoUpdateStatus::Updated {
@@ -681,6 +693,12 @@ impl AutoUpdater {
         target_path: PathBuf,
         cx: &AsyncApp,
     ) -> Result<Option<PathBuf>> {
+        #[cfg(test)]
+        if let Some(test_install) =
+            cx.try_read_global::<tests::InstallOverride, _>(|g, _| g.0.clone())
+        {
+            return test_install(target_path, cx);
+        }
         match OS {
             "macos" => install_release_macos(&installer_dir, target_path, cx).await,
             "linux" => install_release_linux(&installer_dir, target_path, cx).await,
@@ -731,16 +749,13 @@ impl AutoUpdater {
 
 async fn download_remote_server_binary(
     target_path: &PathBuf,
-    release: JsonRelease,
+    release: ReleaseAsset,
     client: Arc<HttpClientWithUrl>,
-    cx: &AsyncApp,
 ) -> Result<()> {
     let temp = tempfile::Builder::new().tempfile_in(remote_servers_dir())?;
     let mut temp_file = File::create(&temp).await?;
-    let update_request_body = build_remote_server_update_request_body(cx)?;
-    let request_body = AsyncBody::from(serde_json::to_string(&update_request_body)?);
 
-    let mut response = client.get(&release.url, request_body, true).await?;
+    let mut response = client.get(&release.url, Default::default(), true).await?;
     anyhow::ensure!(
         response.status().is_success(),
         "failed to download remote server release: {:?}",
@@ -752,65 +767,19 @@ async fn download_remote_server_binary(
     Ok(())
 }
 
-fn build_remote_server_update_request_body(cx: &AsyncApp) -> Result<UpdateRequestBody> {
-    let (installation_id, release_channel, telemetry_enabled, is_staff) = cx.update(|cx| {
-        let telemetry = Client::global(cx).telemetry().clone();
-        let is_staff = telemetry.is_staff();
-        let installation_id = telemetry.installation_id();
-        let release_channel =
-            ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
-        let telemetry_enabled = TelemetrySettings::get_global(cx).metrics;
-
-        (
-            installation_id,
-            release_channel,
-            telemetry_enabled,
-            is_staff,
-        )
-    })?;
-
-    Ok(UpdateRequestBody {
-        installation_id,
-        release_channel,
-        telemetry: telemetry_enabled,
-        is_staff,
-        destination: "remote",
-    })
-}
-
 async fn download_release(
     target_path: &Path,
-    release: JsonRelease,
+    release: ReleaseAsset,
     client: Arc<HttpClientWithUrl>,
-    cx: &AsyncApp,
 ) -> Result<()> {
     let mut target_file = File::create(&target_path).await?;
 
-    let (installation_id, release_channel, telemetry_enabled, is_staff) = cx.update(|cx| {
-        let telemetry = Client::global(cx).telemetry().clone();
-        let is_staff = telemetry.is_staff();
-        let installation_id = telemetry.installation_id();
-        let release_channel =
-            ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
-        let telemetry_enabled = TelemetrySettings::get_global(cx).metrics;
-
-        (
-            installation_id,
-            release_channel,
-            telemetry_enabled,
-            is_staff,
-        )
-    })?;
-
-    let request_body = AsyncBody::from(serde_json::to_string(&UpdateRequestBody {
-        installation_id,
-        release_channel,
-        telemetry: telemetry_enabled,
-        is_staff,
-        destination: "local",
-    })?);
-
-    let mut response = client.get(&release.url, request_body, true).await?;
+    let mut response = client.get(&release.url, Default::default(), true).await?;
+    anyhow::ensure!(
+        response.status().is_success(),
+        "failed to download update: {:?}",
+        response.status()
+    );
     smol::io::copy(response.body_mut(), &mut target_file).await?;
     log::info!("downloaded update. path:{:?}", target_path);
 
@@ -1010,11 +979,33 @@ pub async fn finalize_auto_update_on_quit() {
 
 #[cfg(test)]
 mod tests {
+    use client::Client;
+    use clock::FakeSystemClock;
+    use futures::channel::oneshot;
     use gpui::TestAppContext;
+    use http_client::{FakeHttpClient, Response};
     use settings::default_settings;
+    use std::{
+        rc::Rc,
+        sync::{
+            Arc,
+            atomic::{self, AtomicBool},
+        },
+    };
+    use tempfile::tempdir;
+
+    #[ctor::ctor]
+    fn init_logger() {
+        zlog::init_test();
+    }
 
     use super::*;
 
+    pub(super) struct InstallOverride(
+        pub Rc<dyn Fn(PathBuf, &AsyncApp) -> Result<Option<PathBuf>>>,
+    );
+    impl Global for InstallOverride {}
+
     #[gpui::test]
     fn test_auto_update_defaults_to_true(cx: &mut TestAppContext) {
         cx.update(|cx| {
@@ -1030,6 +1021,115 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_auto_update_downloads(cx: &mut TestAppContext) {
+        cx.background_executor.allow_parking();
+        zlog::init_test();
+        let release_available = Arc::new(AtomicBool::new(false));
+
+        let (dmg_tx, dmg_rx) = oneshot::channel::<String>();
+
+        cx.update(|cx| {
+            settings::init(cx);
+
+            let current_version = SemanticVersion::new(0, 100, 0);
+            release_channel::init_test(current_version, ReleaseChannel::Stable, cx);
+
+            let clock = Arc::new(FakeSystemClock::new());
+            let release_available = Arc::clone(&release_available);
+            let dmg_rx = Arc::new(parking_lot::Mutex::new(Some(dmg_rx)));
+            let fake_client_http = FakeHttpClient::create(move |req| {
+                let release_available = release_available.load(atomic::Ordering::Relaxed);
+                let dmg_rx = dmg_rx.clone();
+                async move {
+                if req.uri().path() == "/releases/stable/latest/asset" {
+                    if release_available {
+                        return Ok(Response::builder().status(200).body(
+                            r#"{"version":"0.100.1","url":"https://test.example/new-download"}"#.into()
+                        ).unwrap());
+                    } else {
+                        return Ok(Response::builder().status(200).body(
+                            r#"{"version":"0.100.0","url":"https://test.example/old-download"}"#.into()
+                        ).unwrap());
+                    }
+                } else if req.uri().path() == "/new-download" {
+                    return Ok(Response::builder().status(200).body({
+                        let dmg_rx = dmg_rx.lock().take().unwrap();
+                        dmg_rx.await.unwrap().into()
+                    }).unwrap());
+                }
+                Ok(Response::builder().status(404).body("".into()).unwrap())
+                }
+            });
+            let client = Client::new(clock, fake_client_http, cx);
+            crate::init(client, cx);
+        });
+
+        let auto_updater = cx.update(|cx| AutoUpdater::get(cx).expect("auto updater should exist"));
+
+        cx.background_executor.run_until_parked();
+
+        auto_updater.read_with(cx, |updater, _| {
+            assert_eq!(updater.status(), AutoUpdateStatus::Idle);
+            assert_eq!(updater.current_version(), SemanticVersion::new(0, 100, 0));
+        });
+
+        release_available.store(true, atomic::Ordering::SeqCst);
+        cx.background_executor.advance_clock(POLL_INTERVAL);
+        cx.background_executor.run_until_parked();
+
+        loop {
+            cx.background_executor.timer(Duration::from_millis(0)).await;
+            cx.run_until_parked();
+            let status = auto_updater.read_with(cx, |updater, _| updater.status());
+            if !matches!(status, AutoUpdateStatus::Idle) {
+                break;
+            }
+        }
+        let status = auto_updater.read_with(cx, |updater, _| updater.status());
+        assert_eq!(
+            status,
+            AutoUpdateStatus::Downloading {
+                version: VersionCheckType::Semantic(SemanticVersion::new(0, 100, 1))
+            }
+        );
+
+        dmg_tx.send("<fake-zed-update>".to_owned()).unwrap();
+
+        let tmp_dir = Arc::new(tempdir().unwrap());
+
+        cx.update(|cx| {
+            let tmp_dir = tmp_dir.clone();
+            cx.set_global(InstallOverride(Rc::new(move |target_path, _cx| {
+                let tmp_dir = tmp_dir.clone();
+                let dest_path = tmp_dir.path().join("zed");
+                std::fs::copy(&target_path, &dest_path)?;
+                Ok(Some(dest_path))
+            })));
+        });
+
+        loop {
+            cx.background_executor.timer(Duration::from_millis(0)).await;
+            cx.run_until_parked();
+            let status = auto_updater.read_with(cx, |updater, _| updater.status());
+            if !matches!(status, AutoUpdateStatus::Downloading { .. }) {
+                break;
+            }
+        }
+        let status = auto_updater.read_with(cx, |updater, _| updater.status());
+        assert_eq!(
+            status,
+            AutoUpdateStatus::Updated {
+                version: VersionCheckType::Semantic(SemanticVersion::new(0, 100, 1))
+            }
+        );
+        let will_restart = cx.expect_restart();
+        cx.update(|cx| cx.restart());
+        let path = will_restart.await.unwrap().unwrap();
+        assert_eq!(path, tmp_dir.path().join("zed"));
+        assert_eq!(std::fs::read_to_string(path).unwrap(), "<fake-zed-update>");
+    }
+
     #[test]
     fn test_stable_does_not_update_when_fetched_version_is_not_higher() {
         let release_channel = ReleaseChannel::Stable;

crates/client/src/client.rs 🔗

@@ -1487,7 +1487,7 @@ impl Client {
 
         let url = self
             .http
-            .build_zed_cloud_url("/internal/users/impersonate", &[])?;
+            .build_zed_cloud_url("/internal/users/impersonate")?;
         let request = Request::post(url.as_str())
             .header("Content-Type", "application/json")
             .header("Authorization", format!("Bearer {api_token}"))

crates/cloud_api_client/src/cloud_api_client.rs 🔗

@@ -62,7 +62,7 @@ impl CloudApiClient {
         let request = self.build_request(
             Request::builder().method(Method::GET).uri(
                 self.http_client
-                    .build_zed_cloud_url("/client/users/me", &[])?
+                    .build_zed_cloud_url("/client/users/me")?
                     .as_ref(),
             ),
             AsyncBody::default(),
@@ -89,7 +89,7 @@ impl CloudApiClient {
     pub fn connect(&self, cx: &App) -> Result<Task<Result<Connection>>> {
         let mut connect_url = self
             .http_client
-            .build_zed_cloud_url("/client/users/connect", &[])?;
+            .build_zed_cloud_url("/client/users/connect")?;
         connect_url
             .set_scheme(match connect_url.scheme() {
                 "https" => "wss",
@@ -123,7 +123,7 @@ impl CloudApiClient {
             .method(Method::POST)
             .uri(
                 self.http_client
-                    .build_zed_cloud_url("/client/llm_tokens", &[])?
+                    .build_zed_cloud_url("/client/llm_tokens")?
                     .as_ref(),
             )
             .when_some(system_id, |builder, system_id| {
@@ -154,7 +154,7 @@ impl CloudApiClient {
         let request = build_request(
             Request::builder().method(Method::GET).uri(
                 self.http_client
-                    .build_zed_cloud_url("/client/users/me", &[])?
+                    .build_zed_cloud_url("/client/users/me")?
                     .as_ref(),
             ),
             AsyncBody::default(),

crates/gpui/src/app/test_context.rs 🔗

@@ -10,7 +10,9 @@ use crate::{
 use anyhow::{anyhow, bail};
 use futures::{Stream, StreamExt, channel::oneshot};
 use rand::{SeedableRng, rngs::StdRng};
-use std::{cell::RefCell, future::Future, ops::Deref, rc::Rc, sync::Arc, time::Duration};
+use std::{
+    cell::RefCell, future::Future, ops::Deref, path::PathBuf, rc::Rc, sync::Arc, time::Duration,
+};
 
 /// A TestAppContext is provided to tests created with `#[gpui::test]`, it provides
 /// an implementation of `Context` with additional methods that are useful in tests.
@@ -331,6 +333,13 @@ impl TestAppContext {
         self.test_window(window_handle).simulate_resize(size);
     }
 
+    /// Returns true if there's an alert dialog open.
+    pub fn expect_restart(&self) -> oneshot::Receiver<Option<PathBuf>> {
+        let (tx, rx) = futures::channel::oneshot::channel();
+        self.test_platform.expect_restart.borrow_mut().replace(tx);
+        rx
+    }
+
     /// Causes the given sources to be returned if the application queries for screen
     /// capture sources.
     pub fn set_screen_capture_sources(&self, sources: Vec<TestScreenCaptureSource>) {

crates/gpui/src/platform/test/platform.rs 🔗

@@ -36,6 +36,7 @@ pub(crate) struct TestPlatform {
     screen_capture_sources: RefCell<Vec<TestScreenCaptureSource>>,
     pub opened_url: RefCell<Option<String>>,
     pub text_system: Arc<dyn PlatformTextSystem>,
+    pub expect_restart: RefCell<Option<oneshot::Sender<Option<PathBuf>>>>,
     #[cfg(target_os = "windows")]
     bitmap_factory: std::mem::ManuallyDrop<IWICImagingFactory>,
     weak: Weak<Self>,
@@ -112,6 +113,7 @@ impl TestPlatform {
             active_cursor: Default::default(),
             active_display: Rc::new(TestDisplay::new()),
             active_window: Default::default(),
+            expect_restart: Default::default(),
             current_clipboard_item: Mutex::new(None),
             #[cfg(any(target_os = "linux", target_os = "freebsd"))]
             current_primary_item: Mutex::new(None),
@@ -250,8 +252,10 @@ impl Platform for TestPlatform {
 
     fn quit(&self) {}
 
-    fn restart(&self, _: Option<PathBuf>) {
-        //
+    fn restart(&self, path: Option<PathBuf>) {
+        if let Some(tx) = self.expect_restart.take() {
+            tx.send(path).unwrap();
+        }
     }
 
     fn activate(&self, _ignoring_other_apps: bool) {

crates/http_client/Cargo.toml 🔗

@@ -31,6 +31,7 @@ parking_lot.workspace = true
 reqwest.workspace = true
 serde.workspace = true
 serde_json.workspace = true
+serde_urlencoded.workspace = true
 sha2.workspace = true
 tempfile.workspace = true
 url.workspace = true

crates/http_client/src/http_client.rs 🔗

@@ -13,6 +13,7 @@ use futures::{
     future::{self, BoxFuture},
 };
 use parking_lot::Mutex;
+use serde::Serialize;
 #[cfg(feature = "test-support")]
 use std::fmt;
 use std::{any::type_name, sync::Arc};
@@ -255,7 +256,7 @@ impl HttpClientWithUrl {
     }
 
     /// Builds a Zed Cloud URL using the given path.
-    pub fn build_zed_cloud_url(&self, path: &str, query: &[(&str, &str)]) -> Result<Url> {
+    pub fn build_zed_cloud_url(&self, path: &str) -> Result<Url> {
         let base_url = self.base_url();
         let base_api_url = match base_url.as_ref() {
             "https://zed.dev" => "https://cloud.zed.dev",
@@ -264,10 +265,20 @@ impl HttpClientWithUrl {
             other => other,
         };
 
-        Ok(Url::parse_with_params(
-            &format!("{}{}", base_api_url, path),
-            query,
-        )?)
+        Ok(Url::parse(&format!("{}{}", base_api_url, path))?)
+    }
+
+    /// Builds a Zed Cloud URL using the given path and query params.
+    pub fn build_zed_cloud_url_with_query(&self, path: &str, query: impl Serialize) -> Result<Url> {
+        let base_url = self.base_url();
+        let base_api_url = match base_url.as_ref() {
+            "https://zed.dev" => "https://cloud.zed.dev",
+            "https://staging.zed.dev" => "https://cloud.zed.dev",
+            "http://localhost:3000" => "http://localhost:8787",
+            other => other,
+        };
+        let query = serde_urlencoded::to_string(&query)?;
+        Ok(Url::parse(&format!("{}{}?{}", base_api_url, path, query))?)
     }
 
     /// Builds a Zed LLM URL using the given path.

crates/recent_projects/src/remote_connections.rs 🔗

@@ -486,10 +486,10 @@ impl remote::RemoteClientDelegate for RemoteClientDelegate {
         let this = self.clone();
         cx.spawn(async move |cx| {
             AutoUpdater::download_remote_server_release(
-                platform.os,
-                platform.arch,
                 release_channel,
                 version,
+                platform.os,
+                platform.arch,
                 move |status, cx| this.set_status(Some(status), cx),
                 cx,
             )
@@ -507,19 +507,19 @@ impl remote::RemoteClientDelegate for RemoteClientDelegate {
         })
     }
 
-    fn get_download_params(
+    fn get_download_url(
         &self,
         platform: RemotePlatform,
         release_channel: ReleaseChannel,
         version: Option<SemanticVersion>,
         cx: &mut AsyncApp,
-    ) -> Task<Result<Option<(String, String)>>> {
+    ) -> Task<Result<Option<String>>> {
         cx.spawn(async move |cx| {
             AutoUpdater::get_remote_server_release_url(
-                platform.os,
-                platform.arch,
                 release_channel,
                 version,
+                platform.os,
+                platform.arch,
                 cx,
             )
             .await

crates/release_channel/src/lib.rs 🔗

@@ -126,6 +126,12 @@ pub fn init(app_version: SemanticVersion, cx: &mut App) {
     cx.set_global(GlobalReleaseChannel(*RELEASE_CHANNEL))
 }
 
+/// Initializes the release channel for tests that rely on fake release channel.
+pub fn init_test(app_version: SemanticVersion, release_channel: ReleaseChannel, cx: &mut App) {
+    cx.set_global(GlobalAppVersion(app_version));
+    cx.set_global(GlobalReleaseChannel(release_channel))
+}
+
 impl ReleaseChannel {
     /// Returns the global [`ReleaseChannel`].
     pub fn global(cx: &App) -> Self {

crates/remote/src/remote_client.rs 🔗

@@ -67,13 +67,13 @@ pub trait RemoteClientDelegate: Send + Sync {
         tx: oneshot::Sender<EncryptedPassword>,
         cx: &mut AsyncApp,
     );
-    fn get_download_params(
+    fn get_download_url(
         &self,
         platform: RemotePlatform,
         release_channel: ReleaseChannel,
         version: Option<SemanticVersion>,
         cx: &mut AsyncApp,
-    ) -> Task<Result<Option<(String, String)>>>;
+    ) -> Task<Result<Option<String>>>;
     fn download_server_binary_locally(
         &self,
         platform: RemotePlatform,
@@ -1669,13 +1669,13 @@ mod fake {
             unreachable!()
         }
 
-        fn get_download_params(
+        fn get_download_url(
             &self,
             _platform: RemotePlatform,
             _release_channel: ReleaseChannel,
             _version: Option<SemanticVersion>,
             _cx: &mut AsyncApp,
-        ) -> Task<Result<Option<(String, String)>>> {
+        ) -> Task<Result<Option<String>>> {
             unreachable!()
         }
 

crates/remote/src/transport/ssh.rs 🔗

@@ -606,12 +606,12 @@ impl SshRemoteConnection {
             .unwrap(),
         );
         if !self.socket.connection_options.upload_binary_over_ssh
-            && let Some((url, body)) = delegate
-                .get_download_params(self.ssh_platform, release_channel, wanted_version, cx)
+            && let Some(url) = delegate
+                .get_download_url(self.ssh_platform, release_channel, wanted_version, cx)
                 .await?
         {
             match self
-                .download_binary_on_server(&url, &body, &tmp_path_gz, delegate, cx)
+                .download_binary_on_server(&url, &tmp_path_gz, delegate, cx)
                 .await
             {
                 Ok(_) => {
@@ -644,7 +644,6 @@ impl SshRemoteConnection {
     async fn download_binary_on_server(
         &self,
         url: &str,
-        body: &str,
         tmp_path_gz: &RelPath,
         delegate: &Arc<dyn RemoteClientDelegate>,
         cx: &mut AsyncApp,
@@ -670,12 +669,6 @@ impl SshRemoteConnection {
                 &[
                     "-f",
                     "-L",
-                    "-X",
-                    "GET",
-                    "-H",
-                    "Content-Type: application/json",
-                    "-d",
-                    body,
                     url,
                     "-o",
                     &tmp_path_gz.display(self.path_style()),
@@ -700,14 +693,7 @@ impl SshRemoteConnection {
                     .run_command(
                         self.ssh_shell_kind,
                         "wget",
-                        &[
-                            "--header=Content-Type: application/json",
-                            "--body-data",
-                            body,
-                            url,
-                            "-O",
-                            &tmp_path_gz.display(self.path_style()),
-                        ],
+                        &[url, "-O", &tmp_path_gz.display(self.path_style())],
                         true,
                     )
                     .await

crates/zed/src/main.rs 🔗

@@ -539,7 +539,7 @@ pub fn main() {
         });
         AppState::set_global(Arc::downgrade(&app_state), cx);
 
-        auto_update::init(client.http_client(), cx);
+        auto_update::init(client.clone(), cx);
         dap_adapters::init(cx);
         auto_update_ui::init(cx);
         reliability::init(

script/bundle-mac 🔗

@@ -22,7 +22,7 @@ Build the application bundle for macOS.
 Options:
   -d    Compile in debug mode
   -o    Open dir with the resulting DMG or launch the app itself in local mode.
-  -i    Install the resulting DMG into /Applications in local mode. Noop without -l.
+  -i    Install the resulting DMG into /Applications.
   -h    Display this help and exit.
   "
 }
@@ -209,16 +209,6 @@ function sign_app_binaries() {
         codesign --force --deep --entitlements "${app_path}/Contents/Resources/zed.entitlements" --sign ${MACOS_SIGNING_KEY:- -} "${app_path}" -v
     fi
 
-    if [[ "$target_dir" = "debug" ]]; then
-        if [ "$open_result" = true ]; then
-            open "$app_path"
-        else
-            echo "Created application bundle:"
-            echo "$app_path"
-        fi
-        exit 0
-    fi
-
     bundle_name=$(basename "$app_path")
 
     if [ "$local_install" = true ]; then
@@ -229,6 +219,16 @@ function sign_app_binaries() {
             echo "Opening /Applications/$bundle_name"
             open "/Applications/$bundle_name"
         fi
+    elif [ "$open_result" = true ]; then
+        open "$app_path"
+    fi
+
+    if [[ "$target_dir" = "debug" ]]; then
+        echo "Debug build detected - skipping DMG creation and signing"
+        if [ "$local_install" = false ]; then
+            echo "Created application bundle:"
+            echo "$app_path"
+        fi
     else
         dmg_target_directory="target/${target_triple}/${target_dir}"
         dmg_source_directory="${dmg_target_directory}/dmg"