Skip to main content

guise/update/
prompt.rs

1//! `UpdatePrompt` — the "an update is available" panel (gpui entity).
2//!
3//! A state machine with exactly one action in flight. Once an install starts it
4//! reports every stage it moves through, and it stays put until it either
5//! restarts the app or fails with the reason on screen — never a button whose
6//! only feedback is a notification you might not see.
7//!
8//! ```ignore
9//! let prompt = cx.new(|cx| UpdatePrompt::new(updater, release, cx));
10//! cx.subscribe(&prompt, |_this, _prompt, event: &UpdatePromptEvent, _cx| {
11//!     if let UpdatePromptEvent::Failed(why) = event { /* log it */ }
12//! })
13//! .detach();
14//! ```
15//!
16//! [`super::open`] wraps one in its own window; rendered directly it is an
17//! ordinary panel, so it can live in a modal or a settings pane instead.
18
19use 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
34/// Height of the strip that drags the window, and the padding that clears a
35/// transparent titlebar. A platform metric, not a themed one.
36const TITLEBAR: f32 = 34.0;
37
38/// How often the foreground task drains installer progress into the view.
39const PROGRESS_TICK: Duration = Duration::from_millis(80);
40
41/// Emitted as the prompt works.
42#[derive(Debug, Clone)]
43pub enum UpdatePromptEvent {
44  /// The user accepted and the install began.
45  Started,
46  /// The installer moved to a new stage.
47  Stage(UpdateStage),
48  /// The new version is on disk. Carries how to relaunch into it; the prompt
49  /// restarts the app itself unless [`UpdatePrompt::auto_restart`] is off.
50  Installed(Relaunch),
51  /// The install failed, with the reason already on screen and the action
52  /// button offering to retry.
53  Failed(String),
54  /// The user is done with the prompt — "Later", Escape, or the download page
55  /// having opened for an install that can't be rewritten. Whoever owns the
56  /// window closes it; the prompt never closes a window it doesn't own.
57  Dismissed,
58}
59
60/// Where the prompt is in its one-shot lifecycle. Only `Idle` and `Failed`
61/// accept the action, which is what keeps the action button from starting a
62/// second install over the first.
63enum Phase {
64  Idle,
65  Working(UpdateStage),
66  Failed(String),
67}
68
69/// Set while an install is in flight, anywhere in the process.
70///
71/// `Phase::Working` only serializes installs within a single prompt, and there
72/// can be more than one: a manual "Check for Updates…" opens a prompt
73/// unconditionally. Two installs would race two downloads over the same staging
74/// path, two `hdiutil attach` calls on the same mountpoint, and two rsyncs into
75/// the live bundle — each one's unmount tearing down the other's mount mid-copy.
76#[derive(Default)]
77struct Installing(bool);
78impl gpui::Global for Installing {}
79
80/// Whether an update is installing right now. Worth checking before offering a
81/// "Check for Updates…" menu item that would open a second prompt.
82pub 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
90/// How far along the bar each stage sits, as the percentage [`Progress`] wants
91/// (0..=100, *not* a 0..1 fraction). The download dominates the wall clock, so it
92/// owns most of the bar and the later stages are checkpoints past it.
93fn 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    // Total unknown: hold at the start rather than pretending to advance.
99    UpdateStage::Downloading { .. } => 0.0,
100    UpdateStage::Preparing => 88.0,
101    UpdateStage::Installing => 94.0,
102    UpdateStage::Verifying => 98.0,
103  }
104}
105
106/// Whether an install is in flight in this prompt, and so whether the action
107/// button and Escape are inert. Free of the view so it can be tested directly.
108fn busy(phase: &Phase) -> bool {
109  matches!(phase, Phase::Working(_))
110}
111
112/// The action button's label for a given phase. `installable` is false when this
113/// install can't be rewritten, or when the release hasn't published the asset to
114/// do it with — either way the button must not promise an install.
115fn 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
124/// `Downloading` renders its byte counts; the rest are just their label.
125fn 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
134/// Decimal megabytes, matching what a release page and the OS file browser report
135/// for the same file. Dividing by 1 MiB instead would render an 87.4 MB download
136/// as "83.3 MB" and read as a stalled or wrong transfer.
137fn megabytes(bytes: u64) -> String {
138  format!("{:.1} MB", bytes as f64 / 1_000_000.0)
139}
140
141/// The update prompt: what is available, what will happen, and one action.
142pub struct UpdatePrompt {
143  updater: Updater,
144  release: Release,
145  kind: InstallKind,
146  /// Whether this release can be rewritten into this install — see
147  /// [`super::UpdateConfig::can_install`]. Resolved once at construction so the
148  /// button's promise can't drift from what the installer will do.
149  installable: bool,
150  phase: Phase,
151  auto_restart: bool,
152  window_root: bool,
153  focus: FocusHandle,
154}
155
156impl UpdatePrompt {
157  /// A prompt offering `release`.
158  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  /// Whether a successful install restarts the app itself (default `true`).
174  /// Turn it off to handle [`UpdatePromptEvent::Installed`] yourself — the
175  /// prompt then stays in its installing state, since only the host knows what
176  /// comes next.
177  pub fn auto_restart(mut self, auto_restart: bool) -> Self {
178    self.auto_restart = auto_restart;
179    self
180  }
181
182  /// Whether this prompt is the root view of its own update window: draws the
183  /// titlebar drag strip and pads for a transparent titlebar. [`super::open`]
184  /// sets it; leave it off when embedding the prompt in a window of your own.
185  pub fn window_root(mut self, window_root: bool) -> Self {
186    self.window_root = window_root;
187    self
188  }
189
190  /// The release being offered.
191  pub fn release(&self) -> &Release {
192    &self.release
193  }
194
195  /// Whether an install is in flight in this prompt.
196  pub fn busy(&self) -> bool {
197    busy(&self.phase)
198  }
199
200  /// The stage the installer is on, if one is running.
201  pub fn stage(&self) -> Option<&UpdateStage> {
202    match &self.phase {
203      Phase::Working(stage) => Some(stage),
204      _ => None,
205    }
206  }
207
208  /// Why the last attempt failed, if it did.
209  pub fn error(&self) -> Option<&str> {
210    match &self.phase {
211      Phase::Failed(reason) => Some(reason),
212      _ => None,
213    }
214  }
215
216  /// Take the action the button offers: install in place and restart, or — when
217  /// this install can't be rewritten — open the release page and dismiss.
218  pub fn accept(&mut self, cx: &mut Context<Self>) {
219    // `busy` covers this prompt; the global covers a second prompt whose
220    // install is already running.
221    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  /// Give up on the prompt (the "Later" button and Escape). Inert while an
237  /// install is running, so the progress can't be dismissed out from under
238  /// itself.
239  pub fn dismiss(&mut self, cx: &mut Context<Self>) {
240    if self.busy() {
241      return;
242    }
243    cx.emit(UpdatePromptEvent::Dismissed);
244  }
245
246  /// Show a stage without running the installer — for a host driving its own
247  /// install, and for previewing the states.
248  pub fn set_stage(&mut self, stage: UpdateStage, cx: &mut Context<Self>) {
249    self.phase = Phase::Working(stage);
250    cx.notify();
251  }
252
253  /// Show a failure without running the installer. The action button becomes
254  /// "Try Again".
255  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  /// Return to the offer, whatever state the prompt was in.
261  pub fn reset(&mut self, cx: &mut Context<Self>) {
262    self.phase = Phase::Idle;
263    cx.notify();
264  }
265
266  /// Download and install off the UI thread, then relaunch into it.
267  ///
268  /// The installer reports its stages from a background thread, and gpui's
269  /// background executor only carries `Send` work — so progress crosses back
270  /// through a shared cell that a foreground task drains into the view.
271  /// Without that, the window shows nothing at all until the whole install
272  /// resolves.
273  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    // The installer finishing is its own signal. Watching `busy()` alone
289    // would leave this loop ticking forever on the one path that finishes
290    // without changing the phase: a success under `auto_restart(false)`,
291    // where the host — not the prompt — decides what happens next.
292    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      // Stop as soon as the prompt is no longer installing (or is gone):
300      // the install task owns the terminal states.
301      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      // Checked after the drain, so the last stage reported before the
313      // installer returned still reaches the view.
314      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          // A prompt in its own window disables its buttons while
336          // installing, but the titlebar's close control stays live.
337          // Closing it withdraws consent to be restarted, so leave the
338          // new version on disk for the next launch instead. A dead
339          // entity is how that close reaches us here.
340          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              // `Relaunch::Current` restarts with no explicit path
362              // on purpose: gpui reopens the running bundle via
363              // NSBundle. Handing `open` an explicit path right
364              // after an in-place install is what relaunches the
365              // bare Mach-O in a terminal.
366              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          // Show the reason in the prompt and let the user retry it. A
377          // failure that lands only in a notification leaves the prompt
378          // looking like the click did nothing.
379          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  /// The status area: a live progress bar while installing, the reason when it
397  /// failed, and what the button will do when idle.
398  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    // Reserve the status area so the panel doesn't jump between states; the
455    // progress bar over its byte counts is the tallest arrangement.
456    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(&notes)),
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
528/// The strip along the top of an update window that drags it, kept clear of the
529/// macOS traffic lights.
530fn 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  /// [`Progress`] takes a percentage, not a 0..1 fraction — feeding it a
547  /// fraction renders a bar that never visibly leaves the left edge.
548  #[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    // Better a bar that hasn't moved than one inventing progress it can't know.
602    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    // A real 20,314,688-byte dmg: a release page and the OS file browser both
638    // call this 20.3 MB. Dividing by 1 MiB would render "19.4 MB" for the same
639    // file and read as a stalled or mismatched download.
640    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    // What stops the action button from starting a second install over the
652    // first: every other phase accepts the click, Working never does. Calls
653    // the real predicate — asserting on `Phase` literals instead would pass
654    // even with `busy` inverted.
655    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    // No installable asset: the button must not promise an install it can't do.
664    assert_eq!(action_label(&Phase::Idle, false), "Open Download");
665    assert_eq!(
666      action_label(&Phase::Working(UpdateStage::Installing), true),
667      "Updating…"
668    );
669    // A failure has to stay retryable rather than dead-ending the prompt.
670    assert_eq!(
671      action_label(&Phase::Failed("boom".into()), true),
672      "Try Again"
673    );
674  }
675}