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