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