1use anyhow::{Context as _, Result, anyhow};
2use client::{Client, TelemetrySettings};
3use db::RELEASE_CHANNEL;
4use db::kvp::KEY_VALUE_STORE;
5use gpui::{
6 App, AppContext as _, AsyncApp, Context, Entity, Global, SemanticVersion, Task, Window, actions,
7};
8use http_client::{AsyncBody, HttpClient, HttpClientWithUrl};
9use paths::remote_servers_dir;
10use release_channel::{AppCommitSha, ReleaseChannel};
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use settings::{Settings, SettingsSources, SettingsStore};
14use smol::{fs, io::AsyncReadExt};
15use smol::{fs::File, process::Command};
16use std::{
17 env::{
18 self,
19 consts::{ARCH, OS},
20 },
21 ffi::OsString,
22 path::{Path, PathBuf},
23 sync::Arc,
24 time::Duration,
25};
26use which::which;
27use workspace::Workspace;
28
29const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification";
30const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
31
32actions!(auto_update, [Check, DismissErrorMessage, ViewReleaseNotes,]);
33
34#[derive(Serialize)]
35struct UpdateRequestBody {
36 installation_id: Option<Arc<str>>,
37 release_channel: Option<&'static str>,
38 telemetry: bool,
39 is_staff: Option<bool>,
40 destination: &'static str,
41}
42
43#[derive(Clone, PartialEq, Eq)]
44pub enum AutoUpdateStatus {
45 Idle,
46 Checking,
47 Downloading,
48 Installing,
49 Updated { binary_path: PathBuf },
50 Errored,
51}
52
53impl AutoUpdateStatus {
54 pub fn is_updated(&self) -> bool {
55 matches!(self, Self::Updated { .. })
56 }
57}
58
59pub struct AutoUpdater {
60 status: AutoUpdateStatus,
61 current_version: SemanticVersion,
62 http_client: Arc<HttpClientWithUrl>,
63 pending_poll: Option<Task<Option<()>>>,
64}
65
66#[derive(Deserialize)]
67pub struct JsonRelease {
68 pub version: String,
69 pub url: String,
70}
71
72struct MacOsUnmounter {
73 mount_path: PathBuf,
74}
75
76impl Drop for MacOsUnmounter {
77 fn drop(&mut self) {
78 let unmount_output = std::process::Command::new("hdiutil")
79 .args(["detach", "-force"])
80 .arg(&self.mount_path)
81 .output();
82
83 match unmount_output {
84 Ok(output) if output.status.success() => {
85 log::info!("Successfully unmounted the disk image");
86 }
87 Ok(output) => {
88 log::error!(
89 "Failed to unmount disk image: {:?}",
90 String::from_utf8_lossy(&output.stderr)
91 );
92 }
93 Err(error) => {
94 log::error!("Error while trying to unmount disk image: {:?}", error);
95 }
96 }
97 }
98}
99
100struct AutoUpdateSetting(bool);
101
102/// Whether or not to automatically check for updates.
103///
104/// Default: true
105#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize)]
106#[serde(transparent)]
107struct AutoUpdateSettingContent(bool);
108
109impl Settings for AutoUpdateSetting {
110 const KEY: Option<&'static str> = Some("auto_update");
111
112 type FileContent = Option<AutoUpdateSettingContent>;
113
114 fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
115 let auto_update = [sources.server, sources.release_channel, sources.user]
116 .into_iter()
117 .find_map(|value| value.copied().flatten())
118 .unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
119
120 Ok(Self(auto_update.0))
121 }
122}
123
124#[derive(Default)]
125struct GlobalAutoUpdate(Option<Entity<AutoUpdater>>);
126
127impl Global for GlobalAutoUpdate {}
128
129pub fn init(http_client: Arc<HttpClientWithUrl>, cx: &mut App) {
130 AutoUpdateSetting::register(cx);
131
132 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
133 workspace.register_action(|_, action: &Check, window, cx| check(action, window, cx));
134
135 workspace.register_action(|_, action, _, cx| {
136 view_release_notes(action, cx);
137 });
138 })
139 .detach();
140
141 let version = release_channel::AppVersion::global(cx);
142 let auto_updater = cx.new(|cx| {
143 let updater = AutoUpdater::new(version, http_client);
144
145 let poll_for_updates = ReleaseChannel::try_global(cx)
146 .map(|channel| channel.poll_for_updates())
147 .unwrap_or(false);
148
149 if option_env!("ZED_UPDATE_EXPLANATION").is_none()
150 && env::var("ZED_UPDATE_EXPLANATION").is_err()
151 && poll_for_updates
152 {
153 let mut update_subscription = AutoUpdateSetting::get_global(cx)
154 .0
155 .then(|| updater.start_polling(cx));
156
157 cx.observe_global::<SettingsStore>(move |updater: &mut AutoUpdater, cx| {
158 if AutoUpdateSetting::get_global(cx).0 {
159 if update_subscription.is_none() {
160 update_subscription = Some(updater.start_polling(cx))
161 }
162 } else {
163 update_subscription.take();
164 }
165 })
166 .detach();
167 }
168
169 updater
170 });
171 cx.set_global(GlobalAutoUpdate(Some(auto_updater)));
172}
173
174pub fn check(_: &Check, window: &mut Window, cx: &mut App) {
175 if let Some(message) = option_env!("ZED_UPDATE_EXPLANATION") {
176 drop(window.prompt(
177 gpui::PromptLevel::Info,
178 "Zed was installed via a package manager.",
179 Some(message),
180 &["Ok"],
181 cx,
182 ));
183 return;
184 }
185
186 if let Ok(message) = env::var("ZED_UPDATE_EXPLANATION") {
187 drop(window.prompt(
188 gpui::PromptLevel::Info,
189 "Zed was installed via a package manager.",
190 Some(&message),
191 &["Ok"],
192 cx,
193 ));
194 return;
195 }
196
197 if !ReleaseChannel::try_global(cx)
198 .map(|channel| channel.poll_for_updates())
199 .unwrap_or(false)
200 {
201 return;
202 }
203
204 if let Some(updater) = AutoUpdater::get(cx) {
205 updater.update(cx, |updater, cx| updater.poll(cx));
206 } else {
207 drop(window.prompt(
208 gpui::PromptLevel::Info,
209 "Could not check for updates",
210 Some("Auto-updates disabled for non-bundled app."),
211 &["Ok"],
212 cx,
213 ));
214 }
215}
216
217pub fn view_release_notes(_: &ViewReleaseNotes, cx: &mut App) -> Option<()> {
218 let auto_updater = AutoUpdater::get(cx)?;
219 let release_channel = ReleaseChannel::try_global(cx)?;
220
221 match release_channel {
222 ReleaseChannel::Stable | ReleaseChannel::Preview => {
223 let auto_updater = auto_updater.read(cx);
224 let current_version = auto_updater.current_version;
225 let release_channel = release_channel.dev_name();
226 let path = format!("/releases/{release_channel}/{current_version}");
227 let url = &auto_updater.http_client.build_url(&path);
228 cx.open_url(url);
229 }
230 ReleaseChannel::Nightly => {
231 cx.open_url("https://github.com/zed-industries/zed/commits/nightly/");
232 }
233 ReleaseChannel::Dev => {
234 cx.open_url("https://github.com/zed-industries/zed/commits/main/");
235 }
236 }
237 None
238}
239
240impl AutoUpdater {
241 pub fn get(cx: &mut App) -> Option<Entity<Self>> {
242 cx.default_global::<GlobalAutoUpdate>().0.clone()
243 }
244
245 fn new(current_version: SemanticVersion, http_client: Arc<HttpClientWithUrl>) -> Self {
246 Self {
247 status: AutoUpdateStatus::Idle,
248 current_version,
249 http_client,
250 pending_poll: None,
251 }
252 }
253
254 pub fn start_polling(&self, cx: &mut Context<Self>) -> Task<Result<()>> {
255 cx.spawn(async move |this, cx| {
256 loop {
257 this.update(cx, |this, cx| this.poll(cx))?;
258 cx.background_executor().timer(POLL_INTERVAL).await;
259 }
260 })
261 }
262
263 pub fn poll(&mut self, cx: &mut Context<Self>) {
264 if self.pending_poll.is_some() || self.status.is_updated() {
265 return;
266 }
267
268 cx.notify();
269
270 self.pending_poll = Some(cx.spawn(async move |this, cx| {
271 let result = Self::update(this.upgrade()?, cx.clone()).await;
272 this.update(cx, |this, cx| {
273 this.pending_poll = None;
274 if let Err(error) = result {
275 log::error!("auto-update failed: error:{:?}", error);
276 this.status = AutoUpdateStatus::Errored;
277 cx.notify();
278 }
279 })
280 .ok()
281 }));
282 }
283
284 pub fn current_version(&self) -> SemanticVersion {
285 self.current_version
286 }
287
288 pub fn status(&self) -> AutoUpdateStatus {
289 self.status.clone()
290 }
291
292 pub fn dismiss_error(&mut self, cx: &mut Context<Self>) {
293 self.status = AutoUpdateStatus::Idle;
294 cx.notify();
295 }
296
297 // If you are packaging Zed and need to override the place it downloads SSH remotes from,
298 // you can override this function. You should also update get_remote_server_release_url to return
299 // Ok(None).
300 pub async fn download_remote_server_release(
301 os: &str,
302 arch: &str,
303 release_channel: ReleaseChannel,
304 version: Option<SemanticVersion>,
305 cx: &mut AsyncApp,
306 ) -> Result<PathBuf> {
307 let this = cx.update(|cx| {
308 cx.default_global::<GlobalAutoUpdate>()
309 .0
310 .clone()
311 .ok_or_else(|| anyhow!("auto-update not initialized"))
312 })??;
313
314 let release = Self::get_release(
315 &this,
316 "zed-remote-server",
317 os,
318 arch,
319 version,
320 Some(release_channel),
321 cx,
322 )
323 .await?;
324
325 let servers_dir = paths::remote_servers_dir();
326 let channel_dir = servers_dir.join(release_channel.dev_name());
327 let platform_dir = channel_dir.join(format!("{}-{}", os, arch));
328 let version_path = platform_dir.join(format!("{}.gz", release.version));
329 smol::fs::create_dir_all(&platform_dir).await.ok();
330
331 let client = this.read_with(cx, |this, _| this.http_client.clone())?;
332
333 if smol::fs::metadata(&version_path).await.is_err() {
334 log::info!(
335 "downloading zed-remote-server {os} {arch} version {}",
336 release.version
337 );
338 download_remote_server_binary(&version_path, release, client, cx).await?;
339 }
340
341 Ok(version_path)
342 }
343
344 pub async fn get_remote_server_release_url(
345 os: &str,
346 arch: &str,
347 release_channel: ReleaseChannel,
348 version: Option<SemanticVersion>,
349 cx: &mut AsyncApp,
350 ) -> Result<Option<(String, String)>> {
351 let this = cx.update(|cx| {
352 cx.default_global::<GlobalAutoUpdate>()
353 .0
354 .clone()
355 .ok_or_else(|| anyhow!("auto-update not initialized"))
356 })??;
357
358 let release = Self::get_release(
359 &this,
360 "zed-remote-server",
361 os,
362 arch,
363 version,
364 Some(release_channel),
365 cx,
366 )
367 .await?;
368
369 let update_request_body = build_remote_server_update_request_body(cx)?;
370 let body = serde_json::to_string(&update_request_body)?;
371
372 Ok(Some((release.url, body)))
373 }
374
375 async fn get_release(
376 this: &Entity<Self>,
377 asset: &str,
378 os: &str,
379 arch: &str,
380 version: Option<SemanticVersion>,
381 release_channel: Option<ReleaseChannel>,
382 cx: &mut AsyncApp,
383 ) -> Result<JsonRelease> {
384 let client = this.read_with(cx, |this, _| this.http_client.clone())?;
385
386 if let Some(version) = version {
387 let channel = release_channel.map(|c| c.dev_name()).unwrap_or("stable");
388
389 let url = format!("/api/releases/{channel}/{version}/{asset}-{os}-{arch}.gz?update=1",);
390
391 Ok(JsonRelease {
392 version: version.to_string(),
393 url: client.build_url(&url),
394 })
395 } else {
396 let mut url_string = client.build_url(&format!(
397 "/api/releases/latest?asset={}&os={}&arch={}",
398 asset, os, arch
399 ));
400 if let Some(param) = release_channel.and_then(|c| c.release_query_param()) {
401 url_string += "&";
402 url_string += param;
403 }
404
405 let mut response = client.get(&url_string, Default::default(), true).await?;
406 let mut body = Vec::new();
407 response.body_mut().read_to_end(&mut body).await?;
408
409 if !response.status().is_success() {
410 return Err(anyhow!(
411 "failed to fetch release: {:?}",
412 String::from_utf8_lossy(&body),
413 ));
414 }
415
416 serde_json::from_slice(body.as_slice()).with_context(|| {
417 format!(
418 "error deserializing release {:?}",
419 String::from_utf8_lossy(&body),
420 )
421 })
422 }
423 }
424
425 async fn get_latest_release(
426 this: &Entity<Self>,
427 asset: &str,
428 os: &str,
429 arch: &str,
430 release_channel: Option<ReleaseChannel>,
431 cx: &mut AsyncApp,
432 ) -> Result<JsonRelease> {
433 Self::get_release(this, asset, os, arch, None, release_channel, cx).await
434 }
435
436 async fn update(this: Entity<Self>, mut cx: AsyncApp) -> Result<()> {
437 let (client, current_version, release_channel) = this.update(&mut cx, |this, cx| {
438 this.status = AutoUpdateStatus::Checking;
439 cx.notify();
440 (
441 this.http_client.clone(),
442 this.current_version,
443 ReleaseChannel::try_global(cx),
444 )
445 })?;
446
447 let release =
448 Self::get_latest_release(&this, "zed", OS, ARCH, release_channel, &mut cx).await?;
449
450 let should_download = match *RELEASE_CHANNEL {
451 ReleaseChannel::Nightly => cx
452 .update(|cx| AppCommitSha::try_global(cx).map(|sha| release.version != sha.0))
453 .ok()
454 .flatten()
455 .unwrap_or(true),
456 _ => release.version.parse::<SemanticVersion>()? > current_version,
457 };
458
459 if !should_download {
460 this.update(&mut cx, |this, cx| {
461 this.status = AutoUpdateStatus::Idle;
462 cx.notify();
463 })?;
464 return Ok(());
465 }
466
467 this.update(&mut cx, |this, cx| {
468 this.status = AutoUpdateStatus::Downloading;
469 cx.notify();
470 })?;
471
472 let temp_dir = tempfile::Builder::new()
473 .prefix("zed-auto-update")
474 .tempdir()?;
475
476 let filename = match OS {
477 "macos" => Ok("Zed.dmg"),
478 "linux" => Ok("zed.tar.gz"),
479 _ => Err(anyhow!("not supported: {:?}", OS)),
480 }?;
481
482 anyhow::ensure!(
483 which("rsync").is_ok(),
484 "Aborting. Could not find rsync which is required for auto-updates."
485 );
486
487 let downloaded_asset = temp_dir.path().join(filename);
488 download_release(&downloaded_asset, release, client, &cx).await?;
489
490 this.update(&mut cx, |this, cx| {
491 this.status = AutoUpdateStatus::Installing;
492 cx.notify();
493 })?;
494
495 let binary_path = match OS {
496 "macos" => install_release_macos(&temp_dir, downloaded_asset, &cx).await,
497 "linux" => install_release_linux(&temp_dir, downloaded_asset, &cx).await,
498 _ => Err(anyhow!("not supported: {:?}", OS)),
499 }?;
500
501 this.update(&mut cx, |this, cx| {
502 this.set_should_show_update_notification(true, cx)
503 .detach_and_log_err(cx);
504 this.status = AutoUpdateStatus::Updated { binary_path };
505 cx.notify();
506 })?;
507
508 Ok(())
509 }
510
511 pub fn set_should_show_update_notification(
512 &self,
513 should_show: bool,
514 cx: &App,
515 ) -> Task<Result<()>> {
516 cx.background_spawn(async move {
517 if should_show {
518 KEY_VALUE_STORE
519 .write_kvp(
520 SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string(),
521 "".to_string(),
522 )
523 .await?;
524 } else {
525 KEY_VALUE_STORE
526 .delete_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY.to_string())
527 .await?;
528 }
529 Ok(())
530 })
531 }
532
533 pub fn should_show_update_notification(&self, cx: &App) -> Task<Result<bool>> {
534 cx.background_spawn(async move {
535 Ok(KEY_VALUE_STORE
536 .read_kvp(SHOULD_SHOW_UPDATE_NOTIFICATION_KEY)?
537 .is_some())
538 })
539 }
540}
541
542async fn download_remote_server_binary(
543 target_path: &PathBuf,
544 release: JsonRelease,
545 client: Arc<HttpClientWithUrl>,
546 cx: &AsyncApp,
547) -> Result<()> {
548 let temp = tempfile::Builder::new().tempfile_in(remote_servers_dir())?;
549 let mut temp_file = File::create(&temp).await?;
550 let update_request_body = build_remote_server_update_request_body(cx)?;
551 let request_body = AsyncBody::from(serde_json::to_string(&update_request_body)?);
552
553 let mut response = client.get(&release.url, request_body, true).await?;
554 if !response.status().is_success() {
555 return Err(anyhow!(
556 "failed to download remote server release: {:?}",
557 response.status()
558 ));
559 }
560 smol::io::copy(response.body_mut(), &mut temp_file).await?;
561 smol::fs::rename(&temp, &target_path).await?;
562
563 Ok(())
564}
565
566fn build_remote_server_update_request_body(cx: &AsyncApp) -> Result<UpdateRequestBody> {
567 let (installation_id, release_channel, telemetry_enabled, is_staff) = cx.update(|cx| {
568 let telemetry = Client::global(cx).telemetry().clone();
569 let is_staff = telemetry.is_staff();
570 let installation_id = telemetry.installation_id();
571 let release_channel =
572 ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
573 let telemetry_enabled = TelemetrySettings::get_global(cx).metrics;
574
575 (
576 installation_id,
577 release_channel,
578 telemetry_enabled,
579 is_staff,
580 )
581 })?;
582
583 Ok(UpdateRequestBody {
584 installation_id,
585 release_channel,
586 telemetry: telemetry_enabled,
587 is_staff,
588 destination: "remote",
589 })
590}
591
592async fn download_release(
593 target_path: &Path,
594 release: JsonRelease,
595 client: Arc<HttpClientWithUrl>,
596 cx: &AsyncApp,
597) -> Result<()> {
598 let mut target_file = File::create(&target_path).await?;
599
600 let (installation_id, release_channel, telemetry_enabled, is_staff) = cx.update(|cx| {
601 let telemetry = Client::global(cx).telemetry().clone();
602 let is_staff = telemetry.is_staff();
603 let installation_id = telemetry.installation_id();
604 let release_channel =
605 ReleaseChannel::try_global(cx).map(|release_channel| release_channel.display_name());
606 let telemetry_enabled = TelemetrySettings::get_global(cx).metrics;
607
608 (
609 installation_id,
610 release_channel,
611 telemetry_enabled,
612 is_staff,
613 )
614 })?;
615
616 let request_body = AsyncBody::from(serde_json::to_string(&UpdateRequestBody {
617 installation_id,
618 release_channel,
619 telemetry: telemetry_enabled,
620 is_staff,
621 destination: "local",
622 })?);
623
624 let mut response = client.get(&release.url, request_body, true).await?;
625 smol::io::copy(response.body_mut(), &mut target_file).await?;
626 log::info!("downloaded update. path:{:?}", target_path);
627
628 Ok(())
629}
630
631async fn install_release_linux(
632 temp_dir: &tempfile::TempDir,
633 downloaded_tar_gz: PathBuf,
634 cx: &AsyncApp,
635) -> Result<PathBuf> {
636 let channel = cx.update(|cx| ReleaseChannel::global(cx).dev_name())?;
637 let home_dir = PathBuf::from(env::var("HOME").context("no HOME env var set")?);
638 let running_app_path = cx.update(|cx| cx.app_path())??;
639
640 let extracted = temp_dir.path().join("zed");
641 fs::create_dir_all(&extracted)
642 .await
643 .context("failed to create directory into which to extract update")?;
644
645 let output = Command::new("tar")
646 .arg("-xzf")
647 .arg(&downloaded_tar_gz)
648 .arg("-C")
649 .arg(&extracted)
650 .output()
651 .await?;
652
653 anyhow::ensure!(
654 output.status.success(),
655 "failed to extract {:?} to {:?}: {:?}",
656 downloaded_tar_gz,
657 extracted,
658 String::from_utf8_lossy(&output.stderr)
659 );
660
661 let suffix = if channel != "stable" {
662 format!("-{}", channel)
663 } else {
664 String::default()
665 };
666 let app_folder_name = format!("zed{}.app", suffix);
667
668 let from = extracted.join(&app_folder_name);
669 let mut to = home_dir.join(".local");
670
671 let expected_suffix = format!("{}/libexec/zed-editor", app_folder_name);
672
673 if let Some(prefix) = running_app_path
674 .to_str()
675 .and_then(|str| str.strip_suffix(&expected_suffix))
676 {
677 to = PathBuf::from(prefix);
678 }
679
680 let output = Command::new("rsync")
681 .args(["-av", "--delete"])
682 .arg(&from)
683 .arg(&to)
684 .output()
685 .await?;
686
687 anyhow::ensure!(
688 output.status.success(),
689 "failed to copy Zed update from {:?} to {:?}: {:?}",
690 from,
691 to,
692 String::from_utf8_lossy(&output.stderr)
693 );
694
695 Ok(to.join(expected_suffix))
696}
697
698async fn install_release_macos(
699 temp_dir: &tempfile::TempDir,
700 downloaded_dmg: PathBuf,
701 cx: &AsyncApp,
702) -> Result<PathBuf> {
703 let running_app_path = cx.update(|cx| cx.app_path())??;
704 let running_app_filename = running_app_path
705 .file_name()
706 .ok_or_else(|| anyhow!("invalid running app path"))?;
707
708 let mount_path = temp_dir.path().join("Zed");
709 let mut mounted_app_path: OsString = mount_path.join(running_app_filename).into();
710
711 mounted_app_path.push("/");
712 let output = Command::new("hdiutil")
713 .args(["attach", "-nobrowse"])
714 .arg(&downloaded_dmg)
715 .arg("-mountroot")
716 .arg(temp_dir.path())
717 .output()
718 .await?;
719
720 anyhow::ensure!(
721 output.status.success(),
722 "failed to mount: {:?}",
723 String::from_utf8_lossy(&output.stderr)
724 );
725
726 // Create an MacOsUnmounter that will be dropped (and thus unmount the disk) when this function exits
727 let _unmounter = MacOsUnmounter {
728 mount_path: mount_path.clone(),
729 };
730
731 let output = Command::new("rsync")
732 .args(["-av", "--delete"])
733 .arg(&mounted_app_path)
734 .arg(&running_app_path)
735 .output()
736 .await?;
737
738 anyhow::ensure!(
739 output.status.success(),
740 "failed to copy app: {:?}",
741 String::from_utf8_lossy(&output.stderr)
742 );
743
744 Ok(running_app_path)
745}