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