WIP: Start on auto-update

Antonio Scandurra , Nathan Sobo , Max Brunsfeld , and Keith Simmons created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>
Co-Authored-By: Max Brunsfeld <max@zed.dev>
Co-Authored-By: Keith Simmons <keith@zed.dev>

Change summary

crates/client/src/client.rs              |   2 
crates/gpui/src/platform.rs              |  36 +++++++
crates/gpui/src/platform/mac/platform.rs |  16 +++
crates/gpui/src/platform/test.rs         |  10 ++
crates/zed/Cargo.toml                    |  17 ---
crates/zed/src/auto_updater.rs           | 117 ++++++++++++++++++++++++++
crates/zed/src/main.rs                   |  10 ++
crates/zed/src/zed.rs                    |   1 
8 files changed, 189 insertions(+), 20 deletions(-)

Detailed changes

crates/client/src/client.rs 🔗

@@ -43,7 +43,7 @@ pub use rpc::*;
 pub use user::*;
 
 lazy_static! {
-    static ref ZED_SERVER_URL: String =
+    pub static ref ZED_SERVER_URL: String =
         std::env::var("ZED_SERVER_URL").unwrap_or("https://zed.dev".to_string());
     pub static ref IMPERSONATE_LOGIN: Option<String> = std::env::var("ZED_IMPERSONATE")
         .ok()

crates/gpui/src/platform.rs 🔗

@@ -17,7 +17,7 @@ use crate::{
     text_layout::{LineLayout, RunStyle},
     AnyAction, ClipboardItem, Menu, Scene,
 };
-use anyhow::Result;
+use anyhow::{anyhow, Result};
 use async_task::Runnable;
 pub use event::{Event, NavigationDirection};
 use postage::oneshot;
@@ -25,6 +25,7 @@ use std::{
     any::Any,
     path::{Path, PathBuf},
     rc::Rc,
+    str::FromStr,
     sync::Arc,
 };
 use time::UtcOffset;
@@ -56,6 +57,7 @@ pub trait Platform: Send + Sync {
     fn local_timezone(&self) -> UtcOffset;
 
     fn path_for_resource(&self, name: Option<&str>, extension: Option<&str>) -> Result<PathBuf>;
+    fn app_version(&self) -> Result<AppVersion>;
 }
 
 pub(crate) trait ForegroundPlatform {
@@ -129,6 +131,38 @@ pub enum CursorStyle {
     PointingHand,
 }
 
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub struct AppVersion {
+    major: usize,
+    minor: usize,
+    patch: usize,
+}
+
+impl FromStr for AppVersion {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self> {
+        let mut components = s.trim().split('.');
+        let major = components
+            .next()
+            .ok_or_else(|| anyhow!("missing major version number"))?
+            .parse()?;
+        let minor = components
+            .next()
+            .ok_or_else(|| anyhow!("missing minor version number"))?
+            .parse()?;
+        let patch = components
+            .next()
+            .ok_or_else(|| anyhow!("missing patch version number"))?
+            .parse()?;
+        Ok(Self {
+            major,
+            minor,
+            patch,
+        })
+    }
+}
+
 pub trait FontSystem: Send + Sync {
     fn add_fonts(&self, fonts: &[Arc<Vec<u8>>]) -> anyhow::Result<()>;
     fn load_family(&self, name: &str) -> anyhow::Result<Vec<FontId>>;

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

@@ -623,6 +623,22 @@ impl platform::Platform for MacPlatform {
             }
         }
     }
+
+    fn app_version(&self) -> Result<platform::AppVersion> {
+        unsafe {
+            let bundle: id = NSBundle::mainBundle();
+            if bundle.is_null() {
+                Err(anyhow!("app is not running inside a bundle"))
+            } else {
+                let version: id =
+                    msg_send![bundle, objectForInfoDictionaryKey: "CFBundleShortVersionString"];
+                let len = msg_send![version, lengthOfBytesUsingEncoding: NSUTF8StringEncoding];
+                let bytes = version.UTF8String() as *const u8;
+                let version = str::from_utf8(slice::from_raw_parts(bytes, len)).unwrap();
+                version.parse()
+            }
+        }
+    }
 }
 
 unsafe fn get_foreground_platform(object: &mut Object) -> &MacForegroundPlatform {

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

@@ -1,4 +1,4 @@
-use super::{CursorStyle, WindowBounds};
+use super::{AppVersion, CursorStyle, WindowBounds};
 use crate::{
     geometry::vector::{vec2f, Vector2F},
     AnyAction, ClipboardItem,
@@ -164,6 +164,14 @@ impl super::Platform for Platform {
     fn path_for_resource(&self, _name: Option<&str>, _extension: Option<&str>) -> Result<PathBuf> {
         Err(anyhow!("app not running inside a bundle"))
     }
+
+    fn app_version(&self) -> Result<AppVersion> {
+        Ok(AppVersion {
+            major: 1,
+            minor: 0,
+            patch: 0,
+        })
+    }
 }
 
 impl Window {

crates/zed/Cargo.toml 🔗

@@ -14,20 +14,6 @@ doctest = false
 name = "Zed"
 path = "src/main.rs"
 
-[features]
-test-support = [
-    "text/test-support",
-    "client/test-support",
-    "editor/test-support",
-    "gpui/test-support",
-    "language/test-support",
-    "lsp/test-support",
-    "project/test-support",
-    "rpc/test-support",
-    "tempdir",
-    "workspace/test-support",
-]
-
 [dependencies]
 breadcrumbs = { path = "../breadcrumbs" }
 chat_panel = { path = "../chat_panel" }
@@ -90,7 +76,7 @@ simplelog = "0.9"
 smallvec = { version = "1.6", features = ["union"] }
 smol = "1.2.5"
 surf = "2.2"
-tempdir = { version = "0.3.7", optional = true }
+tempdir = { version = "0.3.7" }
 thiserror = "1.0.29"
 tiny_http = "0.8"
 toml = "0.5"
@@ -115,7 +101,6 @@ util = { path = "../util", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }
 env_logger = "0.8"
 serde_json = { version = "1.0.64", features = ["preserve_order"] }
-tempdir = { version = "0.3.7" }
 unindent = "0.1.7"
 
 [package.metadata.bundle]

crates/zed/src/auto_updater.rs 🔗

@@ -0,0 +1,117 @@
+use anyhow::{anyhow, Result};
+use client::http::{self, HttpClient};
+use gpui::{platform::AppVersion, AsyncAppContext, Entity, ModelContext, ModelHandle, Task};
+use serde::Deserialize;
+use smol::io::AsyncReadExt;
+use std::{sync::Arc, time::Duration};
+use surf::Request;
+
+const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
+
+#[derive(Clone, PartialEq, Eq)]
+pub enum AutoUpdateStatus {
+    Idle,
+    Checking,
+    Downloading,
+    Updated,
+    Errored { error: String },
+}
+
+pub struct AutoUpdater {
+    status: AutoUpdateStatus,
+    current_version: AppVersion,
+    http_client: Arc<dyn HttpClient>,
+    pending_poll: Option<Task<()>>,
+    server_url: String,
+}
+
+#[derive(Deserialize)]
+struct JsonRelease {
+    version: String,
+    url: http::Url,
+}
+
+impl Entity for AutoUpdater {
+    type Event = ();
+}
+
+impl AutoUpdater {
+    pub fn new(
+        current_version: AppVersion,
+        http_client: Arc<dyn HttpClient>,
+        server_url: String,
+    ) -> Self {
+        Self {
+            status: AutoUpdateStatus::Idle,
+            current_version,
+            http_client,
+            server_url,
+            pending_poll: None,
+        }
+    }
+
+    pub fn start_polling(&mut self, cx: &mut ModelContext<Self>) -> Task<()> {
+        cx.spawn(|this, mut cx| async move {
+            loop {
+                this.update(&mut cx, |this, cx| this.poll(cx));
+                cx.background().timer(POLL_INTERVAL).await;
+            }
+        })
+    }
+
+    pub fn poll(&mut self, cx: &mut ModelContext<Self>) {
+        if self.pending_poll.is_some() {
+            return;
+        }
+
+        self.status = AutoUpdateStatus::Checking;
+        self.pending_poll = Some(cx.spawn(|this, mut cx| async move {
+            if let Err(error) = Self::update(this.clone(), cx.clone()).await {
+                this.update(&mut cx, |this, cx| {
+                    this.status = AutoUpdateStatus::Errored {
+                        error: error.to_string(),
+                    };
+                    cx.notify();
+                });
+            }
+
+            this.update(&mut cx, |this, _| this.pending_poll = None);
+        }));
+        cx.notify();
+    }
+
+    async fn update(this: ModelHandle<Self>, mut cx: AsyncAppContext) -> Result<()> {
+        let (client, server_url) = this.read_with(&cx, |this, _| {
+            (this.http_client.clone(), this.server_url.clone())
+        });
+        let mut response = client
+            .send(Request::new(
+                http::Method::Get,
+                http::Url::parse(&format!("{server_url}/api/releases/latest"))?,
+            ))
+            .await?;
+        let release = response
+            .body_json::<JsonRelease>()
+            .await
+            .map_err(|err| anyhow!("error deserializing release {:?}", err))?;
+        let latest_version = release.version.parse::<AppVersion>()?;
+        let current_version = cx.platform().app_version()?;
+        if latest_version <= current_version {
+            this.update(&mut cx, |this, cx| {
+                this.status = AutoUpdateStatus::Idle;
+                cx.notify();
+            });
+            return Ok(());
+        }
+
+        let temp_dir = tempdir::TempDir::new("zed")?;
+        let dmg_path = temp_dir.path().join("Zed.dmg");
+        let mut dmg_file = smol::fs::File::create(dmg_path).await?;
+        let response = client
+            .send(Request::new(http::Method::Get, release.url))
+            .await?;
+        smol::io::copy(response.bytes(), &mut dmg_file).await?;
+
+        Ok(())
+    }
+}

crates/zed/src/main.rs 🔗

@@ -19,7 +19,8 @@ use workspace::{
     AppState, OpenNew, OpenParams, OpenPaths, Settings,
 };
 use zed::{
-    self, assets::Assets, build_window_options, build_workspace, fs::RealFs, languages, menus,
+    self, assets::Assets, auto_updater::AutoUpdater, build_window_options, build_workspace,
+    fs::RealFs, languages, menus,
 };
 
 fn main() {
@@ -64,6 +65,13 @@ fn main() {
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
         let channel_list =
             cx.add_model(|cx| ChannelList::new(user_store.clone(), client.clone(), cx));
+        let auto_updater = if let Ok(current_version) = cx.platform().app_version() {
+            Some(cx.add_model(|cx| {
+                AutoUpdater::new(current_version, http, client::ZED_SERVER_URL.clone())
+            }))
+        } else {
+            None
+        };
 
         project::Project::init(&client);
         client::Channel::init(&client);

crates/zed/src/zed.rs 🔗

@@ -1,4 +1,5 @@
 pub mod assets;
+pub mod auto_updater;
 pub mod languages;
 pub mod menus;
 #[cfg(any(test, feature = "test-support"))]