From dd283b471a0e35b1ab6c1a6264eab92951cbab16 Mon Sep 17 00:00:00 2001 From: Mikayla Date: Fri, 17 Nov 2023 15:48:32 -0800 Subject: [PATCH] Add autoupdate2 co-authoredby: max@zed.dev --- Cargo.lock | 25 ++ Cargo.toml | 1 + crates/auto_update2/Cargo.toml | 29 ++ crates/auto_update2/src/auto_update.rs | 388 ++++++++++++++++++ .../auto_update2/src/update_notification.rs | 87 ++++ crates/gpui2/src/app.rs | 4 + crates/workspace2/src/notifications.rs | 5 +- crates/zed2/Cargo.toml | 2 +- crates/zed2/src/main.rs | 2 +- crates/zed2/src/zed2.rs | 2 +- 10 files changed, 540 insertions(+), 5 deletions(-) create mode 100644 crates/auto_update2/Cargo.toml create mode 100644 crates/auto_update2/src/auto_update.rs create mode 100644 crates/auto_update2/src/update_notification.rs diff --git a/Cargo.lock b/Cargo.lock index 1747eae2d2..43e4ea6082 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -724,6 +724,30 @@ dependencies = [ "workspace", ] +[[package]] +name = "auto_update2" +version = "0.1.0" +dependencies = [ + "anyhow", + "client2", + "db2", + "gpui2", + "isahc", + "lazy_static", + "log", + "menu2", + "project2", + "serde", + "serde_derive", + "serde_json", + "settings2", + "smol", + "tempdir", + "theme2", + "util", + "workspace2", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -11543,6 +11567,7 @@ dependencies = [ "async-recursion 0.3.2", "async-tar", "async-trait", + "auto_update2", "backtrace", "call2", "chrono", diff --git a/Cargo.toml b/Cargo.toml index f107dc5390..f495f47505 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/audio", "crates/audio2", "crates/auto_update", + "crates/auto_update2", "crates/breadcrumbs", "crates/call", "crates/call2", diff --git a/crates/auto_update2/Cargo.toml b/crates/auto_update2/Cargo.toml new file mode 100644 index 0000000000..20eb129746 --- /dev/null +++ b/crates/auto_update2/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "auto_update2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/auto_update.rs" +doctest = false + +[dependencies] +db = { package = "db2", path = "../db2" } +client = { package = "client2", path = "../client2" } +gpui = { package = "gpui2", path = "../gpui2" } +menu = { package = "menu2", path = "../menu2" } +project = { package = "project2", path = "../project2" } +settings = { package = "settings2", path = "../settings2" } +theme = { package = "theme2", path = "../theme2" } +workspace = { package = "workspace2", path = "../workspace2" } +util = { path = "../util" } +anyhow.workspace = true +isahc.workspace = true +lazy_static.workspace = true +log.workspace = true +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true +smol.workspace = true +tempdir.workspace = true diff --git a/crates/auto_update2/src/auto_update.rs b/crates/auto_update2/src/auto_update.rs new file mode 100644 index 0000000000..273d877967 --- /dev/null +++ b/crates/auto_update2/src/auto_update.rs @@ -0,0 +1,388 @@ +mod update_notification; + +use anyhow::{anyhow, Context, Result}; +use client::{Client, TelemetrySettings, ZED_APP_PATH, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN}; +use db::kvp::KEY_VALUE_STORE; +use db::RELEASE_CHANNEL; +use gpui::{ + actions, AppContext, AsyncAppContext, Context as _, Model, ModelContext, SemanticVersion, Task, + ViewContext, VisualContext, +}; +use isahc::AsyncBody; +use serde::Deserialize; +use serde_derive::Serialize; +use smol::io::AsyncReadExt; + +use settings::{Settings, SettingsStore}; +use smol::{fs::File, process::Command}; +use std::{ffi::OsString, sync::Arc, time::Duration}; +use update_notification::UpdateNotification; +use util::channel::{AppCommitSha, ReleaseChannel}; +use util::http::HttpClient; +use workspace::Workspace; + +const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification"; +const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60); + +actions!(Check, DismissErrorMessage, ViewReleaseNotes); + +#[derive(Serialize)] +struct UpdateRequestBody { + installation_id: Option>, + release_channel: Option<&'static str>, + telemetry: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AutoUpdateStatus { + Idle, + Checking, + Downloading, + Installing, + Updated, + Errored, +} + +pub struct AutoUpdater { + status: AutoUpdateStatus, + current_version: SemanticVersion, + http_client: Arc, + pending_poll: Option>>, + server_url: String, +} + +#[derive(Deserialize)] +struct JsonRelease { + version: String, + url: String, +} + +struct AutoUpdateSetting(bool); + +impl Settings for AutoUpdateSetting { + const KEY: Option<&'static str> = Some("auto_update"); + + type FileContent = Option; + + fn load( + default_value: &Option, + user_values: &[&Option], + _: &mut AppContext, + ) -> Result { + Ok(Self( + Self::json_merge(default_value, user_values)?.ok_or_else(Self::missing_default)?, + )) + } +} + +pub fn init(http_client: Arc, server_url: String, cx: &mut AppContext) { + AutoUpdateSetting::register(cx); + + if let Some(version) = *ZED_APP_VERSION { + let auto_updater = cx.build_model(|cx| { + let updater = AutoUpdater::new(version, http_client, server_url); + + let mut update_subscription = AutoUpdateSetting::get_global(cx) + .0 + .then(|| updater.start_polling(cx)); + + cx.observe_global::(move |updater, cx| { + if AutoUpdateSetting::get_global(cx).0 { + if update_subscription.is_none() { + update_subscription = Some(updater.start_polling(cx)) + } + } else { + update_subscription.take(); + } + }) + .detach(); + + updater + }); + cx.set_global(Some(auto_updater)); + //todo!(action) + // cx.add_global_action(check); + // cx.add_global_action(view_release_notes); + // cx.add_action(UpdateNotification::dismiss); + } +} + +pub fn check(_: &Check, cx: &mut AppContext) { + if let Some(updater) = AutoUpdater::get(cx) { + updater.update(cx, |updater, cx| updater.poll(cx)); + } +} + +fn _view_release_notes(_: &ViewReleaseNotes, cx: &mut AppContext) { + if let Some(auto_updater) = AutoUpdater::get(cx) { + let auto_updater = auto_updater.read(cx); + let server_url = &auto_updater.server_url; + let current_version = auto_updater.current_version; + if cx.has_global::() { + match cx.global::() { + ReleaseChannel::Dev => {} + ReleaseChannel::Nightly => {} + ReleaseChannel::Preview => { + cx.open_url(&format!("{server_url}/releases/preview/{current_version}")) + } + ReleaseChannel::Stable => { + cx.open_url(&format!("{server_url}/releases/stable/{current_version}")) + } + } + } + } +} + +pub fn notify_of_any_new_update(cx: &mut ViewContext) -> Option<()> { + let updater = AutoUpdater::get(cx)?; + let version = updater.read(cx).current_version; + let should_show_notification = updater.read(cx).should_show_update_notification(cx); + + cx.spawn(|workspace, mut cx| async move { + let should_show_notification = should_show_notification.await?; + if should_show_notification { + workspace.update(&mut cx, |workspace, cx| { + workspace.show_notification(0, cx, |cx| { + cx.build_view(|_| UpdateNotification::new(version)) + }); + updater + .read(cx) + .set_should_show_update_notification(false, cx) + .detach_and_log_err(cx); + })?; + } + anyhow::Ok(()) + }) + .detach(); + + None +} + +impl AutoUpdater { + pub fn get(cx: &mut AppContext) -> Option> { + cx.default_global::>>().clone() + } + + fn new( + current_version: SemanticVersion, + http_client: Arc, + server_url: String, + ) -> Self { + Self { + status: AutoUpdateStatus::Idle, + current_version, + http_client, + server_url, + pending_poll: None, + } + } + + pub fn start_polling(&self, cx: &mut ModelContext) -> Task> { + cx.spawn(|this, mut cx| async move { + loop { + this.update(&mut cx, |this, cx| this.poll(cx))?; + cx.background_executor().timer(POLL_INTERVAL).await; + } + }) + } + + pub fn poll(&mut self, cx: &mut ModelContext) { + if self.pending_poll.is_some() || self.status == AutoUpdateStatus::Updated { + return; + } + + self.status = AutoUpdateStatus::Checking; + cx.notify(); + + self.pending_poll = Some(cx.spawn(|this, mut cx| async move { + let result = Self::update(this.upgrade()?, cx.clone()).await; + this.update(&mut cx, |this, cx| { + this.pending_poll = None; + if let Err(error) = result { + log::error!("auto-update failed: error:{:?}", error); + this.status = AutoUpdateStatus::Errored; + cx.notify(); + } + }) + .ok() + })); + } + + pub fn status(&self) -> AutoUpdateStatus { + self.status + } + + pub fn dismiss_error(&mut self, cx: &mut ModelContext) { + self.status = AutoUpdateStatus::Idle; + cx.notify(); + } + + async fn update(this: Model, mut cx: AsyncAppContext) -> Result<()> { + let (client, server_url, current_version) = this.read_with(&cx, |this, _| { + ( + this.http_client.clone(), + this.server_url.clone(), + this.current_version, + ) + })?; + + let mut url_string = format!( + "{server_url}/api/releases/latest?token={ZED_SECRET_CLIENT_TOKEN}&asset=Zed.dmg" + ); + cx.update(|cx| { + if cx.has_global::() { + if let Some(param) = cx.global::().release_query_param() { + url_string += "&"; + url_string += param; + } + } + })?; + + 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 + .context("error reading release")?; + let release: JsonRelease = + serde_json::from_slice(body.as_slice()).context("error deserializing release")?; + + let should_download = match *RELEASE_CHANNEL { + ReleaseChannel::Nightly => cx + .try_read_global::(|sha, _| release.version != sha.0) + .unwrap_or(true), + _ => release.version.parse::()? <= current_version, + }; + + if !should_download { + this.update(&mut cx, |this, cx| { + this.status = AutoUpdateStatus::Idle; + cx.notify(); + })?; + return Ok(()); + } + + this.update(&mut cx, |this, cx| { + this.status = AutoUpdateStatus::Downloading; + cx.notify(); + })?; + + let temp_dir = tempdir::TempDir::new("zed-auto-update")?; + 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 = cx.global::>().telemetry().installation_id(); + let release_channel = cx + .has_global::() + .then(|| cx.global::().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); + + 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) + ))?; + } + + this.update(&mut cx, |this, cx| { + this.set_should_show_update_notification(true, cx) + .detach_and_log_err(cx); + this.status = AutoUpdateStatus::Updated; + cx.notify(); + })?; + Ok(()) + } + + fn set_should_show_update_notification( + &self, + should_show: bool, + cx: &AppContext, + ) -> Task> { + cx.background_executor().spawn(async move { + if should_show { + KEY_VALUE_STORE + .write_kvp( + SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string(), + "".to_string(), + ) + .await?; + } else { + KEY_VALUE_STORE + .delete_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string()) + .await?; + } + Ok(()) + }) + } + + fn should_show_update_notification(&self, cx: &AppContext) -> Task> { + cx.background_executor().spawn(async move { + Ok(KEY_VALUE_STORE + .read_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)? + .is_some()) + }) + } +} diff --git a/crates/auto_update2/src/update_notification.rs b/crates/auto_update2/src/update_notification.rs new file mode 100644 index 0000000000..b77682c9ae --- /dev/null +++ b/crates/auto_update2/src/update_notification.rs @@ -0,0 +1,87 @@ +use gpui::{div, Div, EventEmitter, ParentComponent, Render, SemanticVersion, ViewContext}; +use menu::Cancel; +use workspace::notifications::NotificationEvent; + +pub struct UpdateNotification { + _version: SemanticVersion, +} + +impl EventEmitter for UpdateNotification {} + +impl Render for UpdateNotification { + type Element = Div; + + fn render(&mut self, _cx: &mut gpui::ViewContext) -> Self::Element { + div().child("Updated zed!") + // let theme = theme::current(cx).clone(); + // let theme = &theme.update_notification; + + // let app_name = cx.global::().display_name(); + + // MouseEventHandler::new::(0, cx, |state, cx| { + // Flex::column() + // .with_child( + // Flex::row() + // .with_child( + // Text::new( + // format!("Updated to {app_name} {}", self.version), + // theme.message.text.clone(), + // ) + // .contained() + // .with_style(theme.message.container) + // .aligned() + // .top() + // .left() + // .flex(1., true), + // ) + // .with_child( + // MouseEventHandler::new::(0, cx, |state, _| { + // let style = theme.dismiss_button.style_for(state); + // Svg::new("icons/x.svg") + // .with_color(style.color) + // .constrained() + // .with_width(style.icon_width) + // .aligned() + // .contained() + // .with_style(style.container) + // .constrained() + // .with_width(style.button_width) + // .with_height(style.button_width) + // }) + // .with_padding(Padding::uniform(5.)) + // .on_click(MouseButton::Left, move |_, this, cx| { + // this.dismiss(&Default::default(), cx) + // }) + // .aligned() + // .constrained() + // .with_height(cx.font_cache().line_height(theme.message.text.font_size)) + // .aligned() + // .top() + // .flex_float(), + // ), + // ) + // .with_child({ + // let style = theme.action_message.style_for(state); + // Text::new("View the release notes", style.text.clone()) + // .contained() + // .with_style(style.container) + // }) + // .contained() + // }) + // .with_cursor_style(CursorStyle::PointingHand) + // .on_click(MouseButton::Left, |_, _, cx| { + // crate::view_release_notes(&Default::default(), cx) + // }) + // .into_any_named("update notification") + } +} + +impl UpdateNotification { + pub fn new(version: SemanticVersion) -> Self { + Self { _version: version } + } + + pub fn _dismiss(&mut self, _: &Cancel, cx: &mut ViewContext) { + cx.emit(NotificationEvent::Dismiss); + } +} diff --git a/crates/gpui2/src/app.rs b/crates/gpui2/src/app.rs index b5083b97c2..ca96ba210e 100644 --- a/crates/gpui2/src/app.rs +++ b/crates/gpui2/src/app.rs @@ -492,6 +492,10 @@ impl AppContext { self.platform.open_url(url); } + pub fn app_path(&self) -> Result { + self.platform.app_path() + } + pub fn path_for_auxiliary_executable(&self, name: &str) -> Result { self.platform.path_for_auxiliary_executable(name) } diff --git a/crates/workspace2/src/notifications.rs b/crates/workspace2/src/notifications.rs index 7277cc6fc4..b1df74c61a 100644 --- a/crates/workspace2/src/notifications.rs +++ b/crates/workspace2/src/notifications.rs @@ -15,6 +15,8 @@ pub enum NotificationEvent { pub trait Notification: EventEmitter + Render {} +impl + Render> Notification for V {} + pub trait NotificationHandle: Send { fn id(&self) -> EntityId; fn to_any(&self) -> AnyView; @@ -164,7 +166,7 @@ impl Workspace { } pub mod simple_message_notification { - use super::{Notification, NotificationEvent}; + use super::NotificationEvent; use gpui::{AnyElement, AppContext, Div, EventEmitter, Render, TextStyle, ViewContext}; use serde::Deserialize; use std::{borrow::Cow, sync::Arc}; @@ -359,7 +361,6 @@ pub mod simple_message_notification { // } impl EventEmitter for MessageNotification {} - impl Notification for MessageNotification {} } pub trait NotifyResultExt { diff --git a/crates/zed2/Cargo.toml b/crates/zed2/Cargo.toml index c82f1eef5d..ec665e5f42 100644 --- a/crates/zed2/Cargo.toml +++ b/crates/zed2/Cargo.toml @@ -18,7 +18,7 @@ path = "src/main.rs" ai = { package = "ai2", path = "../ai2"} # audio = { path = "../audio" } # activity_indicator = { path = "../activity_indicator" } -# auto_update = { path = "../auto_update" } +auto_update = { package = "auto_update2", path = "../auto_update2" } # breadcrumbs = { path = "../breadcrumbs" } call = { package = "call2", path = "../call2" } # channel = { path = "../channel" } diff --git a/crates/zed2/src/main.rs b/crates/zed2/src/main.rs index 5206514dfe..11c45ef57e 100644 --- a/crates/zed2/src/main.rs +++ b/crates/zed2/src/main.rs @@ -186,7 +186,7 @@ fn main() { cx.set_global(Arc::downgrade(&app_state)); // audio::init(Assets, cx); - // auto_update::init(http.clone(), client::ZED_SERVER_URL.clone(), cx); + auto_update::init(http.clone(), client::ZED_SERVER_URL.clone(), cx); workspace::init(app_state.clone(), cx); // recent_projects::init(cx); diff --git a/crates/zed2/src/zed2.rs b/crates/zed2/src/zed2.rs index e2e113c9b0..54f95a1d3d 100644 --- a/crates/zed2/src/zed2.rs +++ b/crates/zed2/src/zed2.rs @@ -162,7 +162,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { // status_bar.add_right_item(cursor_position, cx); }); - // auto_update::notify_of_any_new_update(cx.weak_handle(), cx); + auto_update::notify_of_any_new_update(cx); // vim::observe_keystrokes(cx);