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}