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}