auto_update.rs

  1mod update_notification;
  2
  3use anyhow::{anyhow, Context, Result};
  4use client::{Client, TelemetrySettings, ZED_APP_PATH};
  5use db::kvp::KEY_VALUE_STORE;
  6use db::RELEASE_CHANNEL;
  7use editor::{Editor, MultiBuffer};
  8use gpui::{
  9    actions, AppContext, AsyncAppContext, Context as _, Global, Model, ModelContext,
 10    SemanticVersion, SharedString, Task, View, ViewContext, VisualContext, WindowContext,
 11};
 12use isahc::AsyncBody;
 13
 14use markdown_preview::markdown_preview_view::MarkdownPreviewView;
 15use schemars::JsonSchema;
 16use serde::Deserialize;
 17use serde_derive::Serialize;
 18use smol::io::AsyncReadExt;
 19
 20use settings::{Settings, SettingsStore};
 21use smol::{fs::File, process::Command};
 22
 23use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
 24use std::{
 25    env::consts::{ARCH, OS},
 26    ffi::OsString,
 27    sync::Arc,
 28    time::Duration,
 29};
 30use update_notification::UpdateNotification;
 31use util::{
 32    http::{HttpClient, HttpClientWithUrl},
 33    ResultExt,
 34};
 35use workspace::Workspace;
 36
 37const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification";
 38const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
 39
 40actions!(
 41    auto_update,
 42    [
 43        Check,
 44        DismissErrorMessage,
 45        ViewReleaseNotes,
 46        ViewReleaseNotesLocally
 47    ]
 48);
 49
 50#[derive(Serialize)]
 51struct UpdateRequestBody {
 52    installation_id: Option<Arc<str>>,
 53    release_channel: Option<&'static str>,
 54    telemetry: bool,
 55}
 56
 57#[derive(Clone, Copy, PartialEq, Eq)]
 58pub enum AutoUpdateStatus {
 59    Idle,
 60    Checking,
 61    Downloading,
 62    Installing,
 63    Updated,
 64    Errored,
 65}
 66
 67pub struct AutoUpdater {
 68    status: AutoUpdateStatus,
 69    current_version: SemanticVersion,
 70    http_client: Arc<HttpClientWithUrl>,
 71    pending_poll: Option<Task<Option<()>>>,
 72}
 73
 74#[derive(Deserialize)]
 75struct JsonRelease {
 76    version: String,
 77    url: String,
 78}
 79
 80struct AutoUpdateSetting(bool);
 81
 82/// Whether or not to automatically check for updates.
 83///
 84/// Default: true
 85#[derive(Clone, Default, JsonSchema, Deserialize, Serialize)]
 86#[serde(transparent)]
 87struct AutoUpdateSettingOverride(Option<bool>);
 88
 89impl Settings for AutoUpdateSetting {
 90    const KEY: Option<&'static str> = Some("auto_update");
 91
 92    type FileContent = AutoUpdateSettingOverride;
 93
 94    fn load(
 95        default_value: &Self::FileContent,
 96        user_values: &[&Self::FileContent],
 97        _: &mut AppContext,
 98    ) -> Result<Self> {
 99        Ok(Self(
100            Self::json_merge(default_value, user_values)?
101                .0
102                .ok_or_else(Self::missing_default)?,
103        ))
104    }
105}
106
107#[derive(Default)]
108struct GlobalAutoUpdate(Option<Model<AutoUpdater>>);
109
110impl Global for GlobalAutoUpdate {}
111
112#[derive(Deserialize)]
113struct ReleaseNotesBody {
114    title: String,
115    release_notes: String,
116}
117
118pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut AppContext) {
119    AutoUpdateSetting::register(cx);
120
121    cx.observe_new_views(|workspace: &mut Workspace, _cx| {
122        workspace.register_action(|_, action: &Check, cx| check(action, cx));
123
124        workspace.register_action(|_, action, cx| {
125            view_release_notes(action, cx);
126        });
127
128        workspace.register_action(|workspace, _: &ViewReleaseNotesLocally, cx| {
129            view_release_notes_locally(workspace, cx);
130        });
131    })
132    .detach();
133
134    let version = release_channel::AppVersion::global(cx);
135    let auto_updater = cx.new_model(|cx| {
136        let updater = AutoUpdater::new(version, http_client);
137
138        let mut update_subscription = AutoUpdateSetting::get_global(cx)
139            .0
140            .then(|| updater.start_polling(cx));
141
142        cx.observe_global::<SettingsStore>(move |updater, cx| {
143            if AutoUpdateSetting::get_global(cx).0 {
144                if update_subscription.is_none() {
145                    update_subscription = Some(updater.start_polling(cx))
146                }
147            } else {
148                update_subscription.take();
149            }
150        })
151        .detach();
152
153        updater
154    });
155    cx.set_global(GlobalAutoUpdate(Some(auto_updater)));
156}
157
158pub fn check(_: &Check, cx: &mut WindowContext) {
159    if let Some(updater) = AutoUpdater::get(cx) {
160        updater.update(cx, |updater, cx| updater.poll(cx));
161    } else {
162        drop(cx.prompt(
163            gpui::PromptLevel::Info,
164            "Could not check for updates",
165            Some("Auto-updates disabled for non-bundled app."),
166            &["Ok"],
167        ));
168    }
169}
170
171pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut AppContext) -> Option<()> {
172    let auto_updater = AutoUpdater::get(cx)?;
173    let release_channel = ReleaseChannel::try_global(cx)?;
174
175    if matches!(
176        release_channel,
177        ReleaseChannel::Stable | ReleaseChannel::Preview
178    ) {
179        let auto_updater = auto_updater.read(cx);
180        let release_channel = release_channel.dev_name();
181        let current_version = auto_updater.current_version;
182        let url = &auto_updater
183            .http_client
184            .build_url(&format!("/releases/{release_channel}/{current_version}"));
185        cx.open_url(&url);
186    }
187
188    None
189}
190
191fn view_release_notes_locally(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
192    let release_channel = ReleaseChannel::global(cx);
193    let version = AppVersion::global(cx).to_string();
194
195    let client = client::Client::global(cx).http_client();
196    let url = client.build_url(&format!(
197        "/api/release_notes/{}/{}",
198        release_channel.dev_name(),
199        version
200    ));
201
202    let markdown = workspace
203        .app_state()
204        .languages
205        .language_for_name("Markdown");
206
207    workspace
208        .with_local_workspace(cx, move |_, cx| {
209            cx.spawn(|workspace, mut cx| async move {
210                let markdown = markdown.await.log_err();
211                let response = client.get(&url, Default::default(), true).await;
212                let Some(mut response) = response.log_err() else {
213                    return;
214                };
215
216                let mut body = Vec::new();
217                response.body_mut().read_to_end(&mut body).await.ok();
218
219                let body: serde_json::Result<ReleaseNotesBody> =
220                    serde_json::from_slice(body.as_slice());
221
222                if let Ok(body) = body {
223                    workspace
224                        .update(&mut cx, |workspace, cx| {
225                            let project = workspace.project().clone();
226                            let buffer = project
227                                .update(cx, |project, cx| project.create_buffer("", markdown, cx))
228                                .expect("creating buffers on a local workspace always succeeds");
229                            buffer.update(cx, |buffer, cx| {
230                                buffer.edit([(0..0, body.release_notes)], None, cx)
231                            });
232                            let language_registry = project.read(cx).languages().clone();
233
234                            let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
235
236                            let tab_description = SharedString::from(body.title.to_string());
237                            let editor = cx
238                                .new_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx));
239                            let workspace_handle = workspace.weak_handle();
240                            let view: View<MarkdownPreviewView> = MarkdownPreviewView::new(
241                                editor,
242                                workspace_handle,
243                                Some(tab_description),
244                                language_registry,
245                                cx,
246                            );
247                            workspace.add_item_to_active_pane(Box::new(view.clone()), cx);
248                            cx.notify();
249                        })
250                        .log_err();
251                }
252            })
253            .detach();
254        })
255        .detach();
256}
257
258pub fn notify_of_any_new_update(cx: &mut ViewContext<Workspace>) -> Option<()> {
259    let updater = AutoUpdater::get(cx)?;
260    let version = updater.read(cx).current_version;
261    let should_show_notification = updater.read(cx).should_show_update_notification(cx);
262
263    cx.spawn(|workspace, mut cx| async move {
264        let should_show_notification = should_show_notification.await?;
265        if should_show_notification {
266            workspace.update(&mut cx, |workspace, cx| {
267                workspace.show_notification(0, cx, |cx| {
268                    cx.new_view(|_| UpdateNotification::new(version))
269                });
270                updater
271                    .read(cx)
272                    .set_should_show_update_notification(false, cx)
273                    .detach_and_log_err(cx);
274            })?;
275        }
276        anyhow::Ok(())
277    })
278    .detach();
279
280    None
281}
282
283impl AutoUpdater {
284    pub fn get(cx: &mut AppContext) -> Option<Model<Self>> {
285        cx.default_global::<GlobalAutoUpdate>().0.clone()
286    }
287
288    fn new(current_version: SemanticVersion, http_client: Arc<HttpClientWithUrl>) -> Self {
289        Self {
290            status: AutoUpdateStatus::Idle,
291            current_version,
292            http_client,
293            pending_poll: None,
294        }
295    }
296
297    pub fn start_polling(&self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
298        cx.spawn(|this, mut cx| async move {
299            loop {
300                this.update(&mut cx, |this, cx| this.poll(cx))?;
301                cx.background_executor().timer(POLL_INTERVAL).await;
302            }
303        })
304    }
305
306    pub fn poll(&mut self, cx: &mut ModelContext<Self>) {
307        if self.pending_poll.is_some() || self.status == AutoUpdateStatus::Updated {
308            return;
309        }
310
311        self.status = AutoUpdateStatus::Checking;
312        cx.notify();
313
314        self.pending_poll = Some(cx.spawn(|this, mut cx| async move {
315            let result = Self::update(this.upgrade()?, cx.clone()).await;
316            this.update(&mut cx, |this, cx| {
317                this.pending_poll = None;
318                if let Err(error) = result {
319                    log::error!("auto-update failed: error:{:?}", error);
320                    this.status = AutoUpdateStatus::Errored;
321                    cx.notify();
322                }
323            })
324            .ok()
325        }));
326    }
327
328    pub fn status(&self) -> AutoUpdateStatus {
329        self.status
330    }
331
332    pub fn dismiss_error(&mut self, cx: &mut ModelContext<Self>) {
333        self.status = AutoUpdateStatus::Idle;
334        cx.notify();
335    }
336
337    async fn update(this: Model<Self>, mut cx: AsyncAppContext) -> Result<()> {
338        let (client, current_version) = this.read_with(&cx, |this, _| {
339            (this.http_client.clone(), this.current_version)
340        })?;
341
342        let mut url_string = client.build_url(&format!(
343            "/api/releases/latest?asset=Zed.dmg&os={}&arch={}",
344            OS, ARCH
345        ));
346        cx.update(|cx| {
347            if let Some(param) = ReleaseChannel::try_global(cx)
348                .and_then(|release_channel| release_channel.release_query_param())
349            {
350                url_string += "&";
351                url_string += param;
352            }
353        })?;
354
355        let mut response = client.get(&url_string, Default::default(), true).await?;
356
357        let mut body = Vec::new();
358        response
359            .body_mut()
360            .read_to_end(&mut body)
361            .await
362            .context("error reading release")?;
363        let release: JsonRelease =
364            serde_json::from_slice(body.as_slice()).context("error deserializing release")?;
365
366        let should_download = match *RELEASE_CHANNEL {
367            ReleaseChannel::Nightly => cx
368                .update(|cx| AppCommitSha::try_global(cx).map(|sha| release.version != sha.0))
369                .ok()
370                .flatten()
371                .unwrap_or(true),
372            _ => release.version.parse::<SemanticVersion>()? > current_version,
373        };
374
375        if !should_download {
376            this.update(&mut cx, |this, cx| {
377                this.status = AutoUpdateStatus::Idle;
378                cx.notify();
379            })?;
380            return Ok(());
381        }
382
383        this.update(&mut cx, |this, cx| {
384            this.status = AutoUpdateStatus::Downloading;
385            cx.notify();
386        })?;
387
388        let temp_dir = tempfile::Builder::new()
389            .prefix("zed-auto-update")
390            .tempdir()?;
391        let dmg_path = temp_dir.path().join("Zed.dmg");
392        let mount_path = temp_dir.path().join("Zed");
393        let running_app_path = ZED_APP_PATH
394            .clone()
395            .map_or_else(|| cx.update(|cx| cx.app_path())?, Ok)?;
396        let running_app_filename = running_app_path
397            .file_name()
398            .ok_or_else(|| anyhow!("invalid running app path"))?;
399        let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
400        mounted_app_path.push("/");
401
402        let mut dmg_file = File::create(&dmg_path).await?;
403
404        let (installation_id, release_channel, telemetry) = cx.update(|cx| {
405            let installation_id = Client::global(cx).telemetry().installation_id();
406            let release_channel = ReleaseChannel::try_global(cx)
407                .map(|release_channel| release_channel.display_name());
408            let telemetry = TelemetrySettings::get_global(cx).metrics;
409
410            (installation_id, release_channel, telemetry)
411        })?;
412
413        let request_body = AsyncBody::from(serde_json::to_string(&UpdateRequestBody {
414            installation_id,
415            release_channel,
416            telemetry,
417        })?);
418
419        let mut response = client.get(&release.url, request_body, true).await?;
420        smol::io::copy(response.body_mut(), &mut dmg_file).await?;
421        log::info!("downloaded update. path:{:?}", dmg_path);
422
423        this.update(&mut cx, |this, cx| {
424            this.status = AutoUpdateStatus::Installing;
425            cx.notify();
426        })?;
427
428        let output = Command::new("hdiutil")
429            .args(&["attach", "-nobrowse"])
430            .arg(&dmg_path)
431            .arg("-mountroot")
432            .arg(&temp_dir.path())
433            .output()
434            .await?;
435        if !output.status.success() {
436            Err(anyhow!(
437                "failed to mount: {:?}",
438                String::from_utf8_lossy(&output.stderr)
439            ))?;
440        }
441
442        let output = Command::new("rsync")
443            .args(&["-av", "--delete"])
444            .arg(&mounted_app_path)
445            .arg(&running_app_path)
446            .output()
447            .await?;
448        if !output.status.success() {
449            Err(anyhow!(
450                "failed to copy app: {:?}",
451                String::from_utf8_lossy(&output.stderr)
452            ))?;
453        }
454
455        let output = Command::new("hdiutil")
456            .args(&["detach"])
457            .arg(&mount_path)
458            .output()
459            .await?;
460        if !output.status.success() {
461            Err(anyhow!(
462                "failed to unmount: {:?}",
463                String::from_utf8_lossy(&output.stderr)
464            ))?;
465        }
466
467        this.update(&mut cx, |this, cx| {
468            this.set_should_show_update_notification(true, cx)
469                .detach_and_log_err(cx);
470            this.status = AutoUpdateStatus::Updated;
471            cx.notify();
472        })?;
473        Ok(())
474    }
475
476    fn set_should_show_update_notification(
477        &self,
478        should_show: bool,
479        cx: &AppContext,
480    ) -> Task<Result<()>> {
481        cx.background_executor().spawn(async move {
482            if should_show {
483                KEY_VALUE_STORE
484                    .write_kvp(
485                        SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string(),
486                        "".to_string(),
487                    )
488                    .await?;
489            } else {
490                KEY_VALUE_STORE
491                    .delete_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string())
492                    .await?;
493            }
494            Ok(())
495        })
496    }
497
498    fn should_show_update_notification(&self, cx: &AppContext) -> Task<Result<bool>> {
499        cx.background_executor().spawn(async move {
500            Ok(KEY_VALUE_STORE
501                .read_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)?
502                .is_some())
503        })
504    }
505}