diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 1bae6cd49e..f0bc41008c 100644 --- a/crates/client/src/client.rs +++ b/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 = std::env::var("ZED_IMPERSONATE") .ok() diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 66bd44b26f..cb62885651 100644 --- a/crates/gpui/src/platform.rs +++ b/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; + fn app_version(&self) -> Result; } 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 { + 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>]) -> anyhow::Result<()>; fn load_family(&self, name: &str) -> anyhow::Result>; diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 0b612c978c..138fd106fb 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -623,6 +623,22 @@ impl platform::Platform for MacPlatform { } } } + + fn app_version(&self) -> Result { + 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 { diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index 706439a955..ccb98c6ba6 100644 --- a/crates/gpui/src/platform/test.rs +++ b/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 { Err(anyhow!("app not running inside a bundle")) } + + fn app_version(&self) -> Result { + Ok(AppVersion { + major: 1, + minor: 0, + patch: 0, + }) + } } impl Window { diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 2e1ef780ca..937b9208ce 100644 --- a/crates/zed/Cargo.toml +++ b/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] diff --git a/crates/zed/src/auto_updater.rs b/crates/zed/src/auto_updater.rs new file mode 100644 index 0000000000..f04fbcfcfb --- /dev/null +++ b/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, + pending_poll: Option>, + 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, + 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) -> 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) { + 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, 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::() + .await + .map_err(|err| anyhow!("error deserializing release {:?}", err))?; + let latest_version = release.version.parse::()?; + 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(()) + } +} diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 49efc9ade2..69f8222d77 100644 --- a/crates/zed/src/main.rs +++ b/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); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index aec0bc533e..b98c5d0dfd 100644 --- a/crates/zed/src/zed.rs +++ b/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"))]