1use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::{Arc, Mutex};
21use std::time::Duration;
22
23use gpui::prelude::*;
24use gpui::{
25 div, px, App, Context, EventEmitter, FocusHandle, Focusable, FontWeight, IntoElement,
26 KeyDownEvent, MouseButton, SharedString, Window, WindowControlArea,
27};
28
29use super::{InstallKind, Relaunch, Release, UpdateStage, Updater};
30use crate::devtools::Probed;
31use crate::theme::{theme, ColorName, Size};
32use crate::{Alert, Button, Progress, Variant};
33
34const TITLEBAR: f32 = 34.0;
37
38const PROGRESS_TICK: Duration = Duration::from_millis(80);
40
41#[derive(Debug, Clone)]
43pub enum UpdatePromptEvent {
44 Started,
46 Stage(UpdateStage),
48 Installed(Relaunch),
51 Failed(String),
54 Dismissed,
58}
59
60enum Phase {
64 Idle,
65 Working(UpdateStage),
66 Failed(String),
67}
68
69#[derive(Default)]
77struct Installing(bool);
78impl gpui::Global for Installing {}
79
80pub fn is_installing(cx: &App) -> bool {
83 cx.try_global::<Installing>().is_some_and(|i| i.0)
84}
85
86fn set_installing(active: bool, cx: &mut App) {
87 cx.set_global(Installing(active));
88}
89
90fn percent(stage: &UpdateStage) -> f32 {
94 match stage {
95 UpdateStage::Downloading { done, total } if *total > 0 => {
96 85.0 * (*done as f32 / *total as f32).clamp(0.0, 1.0)
97 }
98 UpdateStage::Downloading { .. } => 0.0,
100 UpdateStage::Preparing => 88.0,
101 UpdateStage::Installing => 94.0,
102 UpdateStage::Verifying => 98.0,
103 }
104}
105
106fn busy(phase: &Phase) -> bool {
109 matches!(phase, Phase::Working(_))
110}
111
112fn action_label(phase: &Phase, installable: bool) -> &'static str {
116 match phase {
117 Phase::Working(_) => "Updating…",
118 Phase::Failed(_) => "Try Again",
119 Phase::Idle if installable => "Update & Restart",
120 Phase::Idle => "Open Download",
121 }
122}
123
124fn detail(stage: &UpdateStage) -> String {
126 match stage {
127 UpdateStage::Downloading { done, total } if *total > 0 => {
128 format!("{} of {}", megabytes(*done), megabytes(*total))
129 }
130 _ => String::new(),
131 }
132}
133
134fn megabytes(bytes: u64) -> String {
138 format!("{:.1} MB", bytes as f64 / 1_000_000.0)
139}
140
141pub struct UpdatePrompt {
143 updater: Updater,
144 release: Release,
145 kind: InstallKind,
146 installable: bool,
150 phase: Phase,
151 auto_restart: bool,
152 window_root: bool,
153 focus: FocusHandle,
154}
155
156impl UpdatePrompt {
157 pub fn new(updater: Updater, release: Release, cx: &mut Context<Self>) -> Self {
159 let kind = updater.config().install_kind();
160 let installable = updater.config().can_install(&release, &kind);
161 UpdatePrompt {
162 updater,
163 release,
164 kind,
165 installable,
166 phase: Phase::Idle,
167 auto_restart: true,
168 window_root: false,
169 focus: cx.focus_handle(),
170 }
171 }
172
173 pub fn auto_restart(mut self, auto_restart: bool) -> Self {
178 self.auto_restart = auto_restart;
179 self
180 }
181
182 pub fn window_root(mut self, window_root: bool) -> Self {
186 self.window_root = window_root;
187 self
188 }
189
190 pub fn release(&self) -> &Release {
192 &self.release
193 }
194
195 pub fn busy(&self) -> bool {
197 busy(&self.phase)
198 }
199
200 pub fn stage(&self) -> Option<&UpdateStage> {
202 match &self.phase {
203 Phase::Working(stage) => Some(stage),
204 _ => None,
205 }
206 }
207
208 pub fn error(&self) -> Option<&str> {
210 match &self.phase {
211 Phase::Failed(reason) => Some(reason),
212 _ => None,
213 }
214 }
215
216 pub fn accept(&mut self, cx: &mut Context<Self>) {
219 if self.busy() || is_installing(cx) {
222 return;
223 }
224 if self.installable {
225 self.phase = Phase::Working(UpdateStage::Downloading { done: 0, total: 0 });
226 set_installing(true, cx);
227 cx.emit(UpdatePromptEvent::Started);
228 cx.notify();
229 self.install(cx);
230 } else {
231 cx.open_url(&self.release.url);
232 cx.emit(UpdatePromptEvent::Dismissed);
233 }
234 }
235
236 pub fn dismiss(&mut self, cx: &mut Context<Self>) {
240 if self.busy() {
241 return;
242 }
243 cx.emit(UpdatePromptEvent::Dismissed);
244 }
245
246 pub fn set_stage(&mut self, stage: UpdateStage, cx: &mut Context<Self>) {
249 self.phase = Phase::Working(stage);
250 cx.notify();
251 }
252
253 pub fn set_failed(&mut self, reason: impl Into<String>, cx: &mut Context<Self>) {
256 self.phase = Phase::Failed(reason.into());
257 cx.notify();
258 }
259
260 pub fn reset(&mut self, cx: &mut Context<Self>) {
262 self.phase = Phase::Idle;
263 cx.notify();
264 }
265
266 fn install(&mut self, cx: &mut Context<Self>) {
274 self.updater.notify(
275 self.updater.app(),
276 &format!(
277 "Downloading {} {}…",
278 self.updater.app(),
279 self.release.version
280 ),
281 );
282 let updater = self.updater.clone();
283 let config = self.updater.config().clone();
284 let release = self.release.clone();
285 let kind = self.kind.clone();
286 let executor = cx.background_executor().clone();
287 let latest: Arc<Mutex<Option<UpdateStage>>> = Arc::new(Mutex::new(None));
288 let finished = Arc::new(AtomicBool::new(false));
293
294 let drained = latest.clone();
295 let done = finished.clone();
296 let ticker = executor.clone();
297 cx.spawn(async move |this, cx| loop {
298 let stage = drained.lock().ok().and_then(|mut slot| slot.take());
299 let running = this.update(cx, |view, cx| {
302 let running = view.busy();
303 if running {
304 if let Some(stage) = stage {
305 view.phase = Phase::Working(stage.clone());
306 cx.emit(UpdatePromptEvent::Stage(stage));
307 cx.notify();
308 }
309 }
310 running
311 });
312 if !matches!(running, Ok(true)) || done.load(Ordering::Relaxed) {
315 break;
316 }
317 ticker.timer(PROGRESS_TICK).await;
318 })
319 .detach();
320
321 let reported = latest.clone();
322 cx.spawn(async move |this, cx| {
323 let staged = executor
324 .spawn(async move {
325 config.install(&release, &kind, &|stage| {
326 if let Ok(mut slot) = reported.lock() {
327 *slot = Some(stage);
328 }
329 })
330 })
331 .await;
332 finished.store(true, Ordering::Relaxed);
333 match staged {
334 Ok(relaunch) => {
335 let dismissed = this.update(cx, |_, _| ()).is_err();
341 let _ = cx.update(|cx| set_installing(false, cx));
342 if dismissed {
343 updater.notify(
344 "Update installed",
345 &format!(
346 "{} will finish updating the next time you open it.",
347 updater.app()
348 ),
349 );
350 return;
351 }
352 let restart = this
353 .update(cx, |view, cx| {
354 cx.emit(UpdatePromptEvent::Installed(relaunch.clone()));
355 view.auto_restart
356 })
357 .unwrap_or(false);
358 if restart {
359 let _ = cx.update(|cx| {
360 updater.run_before_restart(cx);
361 if let Relaunch::Binary(path) = relaunch {
367 cx.set_restart_path(path);
368 }
369 cx.restart();
370 });
371 }
372 }
373 Err(e) => {
374 let _ = cx.update(|cx| set_installing(false, cx));
375 updater.notify("Update failed", &e);
376 let _ = this.update(cx, |view, cx| {
380 view.phase = Phase::Failed(e.clone());
381 cx.emit(UpdatePromptEvent::Failed(e));
382 cx.notify();
383 });
384 }
385 }
386 })
387 .detach();
388 }
389
390 fn key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
391 if event.keystroke.key == "escape" {
392 self.dismiss(cx);
393 }
394 }
395
396 fn status(&self, dim: gpui::Hsla, small: f32, gap: f32) -> gpui::AnyElement {
399 match &self.phase {
400 Phase::Working(stage) => div()
401 .flex()
402 .flex_col()
403 .gap(px(gap))
404 .child(Progress::new(percent(stage)).size(Size::Sm))
405 .child(
406 div()
407 .flex()
408 .justify_between()
409 .text_size(px(small))
410 .text_color(dim)
411 .child(SharedString::from(stage.label()))
412 .child(SharedString::from(detail(stage))),
413 )
414 .into_any_element(),
415 Phase::Failed(reason) => Alert::new(SharedString::from(reason.clone()))
416 .title("Update failed")
417 .variant(Variant::Light)
418 .color(ColorName::Red)
419 .into_any_element(),
420 Phase::Idle => div()
421 .text_size(px(small))
422 .child(SharedString::from(if self.installable {
423 format!(
424 "{} will download the update, install it, and restart.",
425 self.updater.app()
426 )
427 } else {
428 "Open the download page to update.".to_string()
429 }))
430 .into_any_element(),
431 }
432 }
433}
434
435impl Focusable for UpdatePrompt {
436 fn focus_handle(&self, _cx: &App) -> FocusHandle {
437 self.focus.clone()
438 }
439}
440
441impl EventEmitter<UpdatePromptEvent> for UpdatePrompt {}
442
443impl Render for UpdatePrompt {
444 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
445 let t = theme(cx);
446 let bg = t.body().hsla();
447 let text = t.text().hsla();
448 let dim = t.dimmed().hsla();
449 let pad = t.spacing(Size::Lg);
450 let gap = t.spacing(Size::Xs);
451 let headline = t.font_size(Size::Md);
452 let body = t.font_size(Size::Sm);
453 let small = t.font_size(Size::Xs);
454 let status = t.spacing(Size::Xl) + small * 2.0;
457 let busy = self.busy();
458 let label = action_label(&self.phase, self.installable);
459 let title = format!(
460 "{} {} is available",
461 self.updater.app(),
462 self.release.version
463 );
464 let have = format!("You have {}.", self.updater.version());
465 let notes = self.release.url.clone();
466
467 div()
468 .size_full()
469 .flex()
470 .flex_col()
471 .track_focus(&self.focus)
472 .on_key_down(cx.listener(Self::key_down))
473 .bg(bg)
474 .text_color(text)
475 .pt(px(if self.window_root { TITLEBAR } else { pad }))
476 .px(px(pad))
477 .pb(px(pad))
478 .gap(px(gap))
479 .when(self.window_root, |this| this.child(drag_strip()))
480 .child(
481 div()
482 .text_size(px(headline))
483 .font_weight(FontWeight::BOLD)
484 .child(SharedString::from(title)),
485 )
486 .child(
487 div()
488 .text_size(px(small))
489 .text_color(dim)
490 .child(SharedString::from(have)),
491 )
492 .child(
493 div()
494 .min_h(px(status))
495 .text_size(px(body))
496 .child(self.status(dim, small, gap)),
497 )
498 .child(div().flex_1())
499 .child(
500 div()
501 .flex()
502 .items_center()
503 .justify_end()
504 .gap(px(gap))
505 .child(
506 Button::new("guise-update-notes", "Release Notes")
507 .variant(Variant::Subtle)
508 .disabled(busy || notes.is_empty())
509 .on_click(move |_, _, cx| cx.open_url(¬es)),
510 )
511 .child(
512 Button::new("guise-update-later", "Later")
513 .variant(Variant::Default)
514 .disabled(busy)
515 .on_click(cx.listener(|this, _, _, cx| this.dismiss(cx))),
516 )
517 .child(
518 Button::new("guise-update-go", label)
519 .variant(Variant::Filled)
520 .disabled(busy)
521 .on_click(cx.listener(|this, _, _, cx| this.accept(cx))),
522 ),
523 )
524 .probe("UpdatePrompt")
525 }
526}
527
528fn drag_strip() -> impl IntoElement {
531 let lead = if cfg!(target_os = "macos") { 70.0 } else { 0.0 };
532 div()
533 .absolute()
534 .top_0()
535 .left(px(lead))
536 .right_0()
537 .h(px(TITLEBAR - 6.0))
538 .window_control_area(WindowControlArea::Drag)
539 .on_mouse_down(MouseButton::Left, |_, window, _| window.start_window_move())
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
549 fn download_progress_is_a_percentage_across_most_of_the_bar() {
550 assert_eq!(
551 percent(&UpdateStage::Downloading {
552 done: 0,
553 total: 100
554 }),
555 0.0
556 );
557 assert!(
558 (percent(&UpdateStage::Downloading {
559 done: 50,
560 total: 100
561 }) - 42.5)
562 .abs()
563 < 0.01
564 );
565 assert!(
566 (percent(&UpdateStage::Downloading {
567 done: 100,
568 total: 100
569 }) - 85.0)
570 .abs()
571 < 0.01
572 );
573 }
574
575 #[test]
576 fn every_stage_stays_in_percentage_range() {
577 for stage in [
578 UpdateStage::Downloading { done: 1, total: 2 },
579 UpdateStage::Preparing,
580 UpdateStage::Installing,
581 UpdateStage::Verifying,
582 ] {
583 let value = percent(&stage);
584 assert!((0.0..=100.0).contains(&value), "{stage:?} -> {value}");
585 }
586 }
587
588 #[test]
589 fn stages_after_the_download_only_move_forward() {
590 let done = percent(&UpdateStage::Downloading {
591 done: 100,
592 total: 100,
593 });
594 assert!(done < percent(&UpdateStage::Preparing));
595 assert!(percent(&UpdateStage::Preparing) < percent(&UpdateStage::Installing));
596 assert!(percent(&UpdateStage::Installing) < percent(&UpdateStage::Verifying));
597 }
598
599 #[test]
600 fn an_unknown_total_holds_the_bar_at_zero() {
601 assert_eq!(
603 percent(&UpdateStage::Downloading {
604 done: 900,
605 total: 0
606 }),
607 0.0
608 );
609 }
610
611 #[test]
612 fn overlong_downloads_cannot_overflow_the_bar() {
613 assert!(
614 percent(&UpdateStage::Downloading {
615 done: 500,
616 total: 100
617 }) <= 85.0
618 );
619 }
620
621 #[test]
622 fn only_the_download_reports_byte_counts() {
623 assert_eq!(
624 detail(&UpdateStage::Downloading {
625 done: 5_000_000,
626 total: 20_000_000
627 }),
628 "5.0 MB of 20.0 MB"
629 );
630 assert_eq!(detail(&UpdateStage::Downloading { done: 5, total: 0 }), "");
631 assert_eq!(detail(&UpdateStage::Preparing), "");
632 assert_eq!(detail(&UpdateStage::Verifying), "");
633 }
634
635 #[test]
636 fn sizes_are_decimal_mb_to_match_what_release_pages_report() {
637 assert_eq!(
641 detail(&UpdateStage::Downloading {
642 done: 0,
643 total: 20_314_688
644 }),
645 "0.0 MB of 20.3 MB"
646 );
647 }
648
649 #[test]
650 fn only_the_working_phase_is_busy() {
651 assert!(busy(&Phase::Working(UpdateStage::Preparing)));
656 assert!(!busy(&Phase::Idle));
657 assert!(!busy(&Phase::Failed("boom".into())));
658 }
659
660 #[test]
661 fn the_action_label_tracks_phase_and_installability() {
662 assert_eq!(action_label(&Phase::Idle, true), "Update & Restart");
663 assert_eq!(action_label(&Phase::Idle, false), "Open Download");
665 assert_eq!(
666 action_label(&Phase::Working(UpdateStage::Installing), true),
667 "Updating…"
668 );
669 assert_eq!(
671 action_label(&Phase::Failed("boom".into()), true),
672 "Try Again"
673 );
674 }
675}