use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use gpui::prelude::*;
use gpui::{
div, px, App, Context, EventEmitter, FocusHandle, Focusable, FontWeight, IntoElement,
KeyDownEvent, MouseButton, SharedString, Window, WindowControlArea,
};
use super::{InstallKind, Relaunch, Release, UpdateStage, Updater};
use crate::devtools::Probed;
use crate::theme::{theme, ColorName, Size};
use crate::{Alert, Button, Progress, Variant};
const TITLEBAR: f32 = 34.0;
const PROGRESS_TICK: Duration = Duration::from_millis(80);
#[derive(Debug, Clone)]
pub enum UpdatePromptEvent {
Started,
Stage(UpdateStage),
Installed(Relaunch),
Failed(String),
Dismissed,
}
enum Phase {
Idle,
Working(UpdateStage),
Failed(String),
}
#[derive(Default)]
struct Installing(bool);
impl gpui::Global for Installing {}
pub fn is_installing(cx: &App) -> bool {
cx.try_global::<Installing>().is_some_and(|i| i.0)
}
fn set_installing(active: bool, cx: &mut App) {
cx.set_global(Installing(active));
}
fn percent(stage: &UpdateStage) -> f32 {
match stage {
UpdateStage::Downloading { done, total } if *total > 0 => {
85.0 * (*done as f32 / *total as f32).clamp(0.0, 1.0)
}
UpdateStage::Downloading { .. } => 0.0,
UpdateStage::Preparing => 88.0,
UpdateStage::Installing => 94.0,
UpdateStage::Verifying => 98.0,
}
}
fn busy(phase: &Phase) -> bool {
matches!(phase, Phase::Working(_))
}
fn action_label(phase: &Phase, installable: bool) -> &'static str {
match phase {
Phase::Working(_) => "Updating…",
Phase::Failed(_) => "Try Again",
Phase::Idle if installable => "Update & Restart",
Phase::Idle => "Open Download",
}
}
fn detail(stage: &UpdateStage) -> String {
match stage {
UpdateStage::Downloading { done, total } if *total > 0 => {
format!("{} of {}", megabytes(*done), megabytes(*total))
}
_ => String::new(),
}
}
fn megabytes(bytes: u64) -> String {
format!("{:.1} MB", bytes as f64 / 1_000_000.0)
}
pub struct UpdatePrompt {
updater: Updater,
release: Release,
kind: InstallKind,
installable: bool,
phase: Phase,
auto_restart: bool,
window_root: bool,
focus: FocusHandle,
}
impl UpdatePrompt {
pub fn new(updater: Updater, release: Release, cx: &mut Context<Self>) -> Self {
let kind = updater.config().install_kind();
let installable = updater.config().can_install(&release, &kind);
UpdatePrompt {
updater,
release,
kind,
installable,
phase: Phase::Idle,
auto_restart: true,
window_root: false,
focus: cx.focus_handle(),
}
}
pub fn auto_restart(mut self, auto_restart: bool) -> Self {
self.auto_restart = auto_restart;
self
}
pub fn window_root(mut self, window_root: bool) -> Self {
self.window_root = window_root;
self
}
pub fn release(&self) -> &Release {
&self.release
}
pub fn busy(&self) -> bool {
busy(&self.phase)
}
pub fn stage(&self) -> Option<&UpdateStage> {
match &self.phase {
Phase::Working(stage) => Some(stage),
_ => None,
}
}
pub fn error(&self) -> Option<&str> {
match &self.phase {
Phase::Failed(reason) => Some(reason),
_ => None,
}
}
pub fn accept(&mut self, cx: &mut Context<Self>) {
if self.busy() || is_installing(cx) {
return;
}
if self.installable {
self.phase = Phase::Working(UpdateStage::Downloading { done: 0, total: 0 });
set_installing(true, cx);
cx.emit(UpdatePromptEvent::Started);
cx.notify();
self.install(cx);
} else {
cx.open_url(&self.release.url);
cx.emit(UpdatePromptEvent::Dismissed);
}
}
pub fn dismiss(&mut self, cx: &mut Context<Self>) {
if self.busy() {
return;
}
cx.emit(UpdatePromptEvent::Dismissed);
}
pub fn set_stage(&mut self, stage: UpdateStage, cx: &mut Context<Self>) {
self.phase = Phase::Working(stage);
cx.notify();
}
pub fn set_failed(&mut self, reason: impl Into<String>, cx: &mut Context<Self>) {
self.phase = Phase::Failed(reason.into());
cx.notify();
}
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.phase = Phase::Idle;
cx.notify();
}
fn install(&mut self, cx: &mut Context<Self>) {
self.updater.notify(
self.updater.app(),
&format!(
"Downloading {} {}…",
self.updater.app(),
self.release.version
),
);
let updater = self.updater.clone();
let config = self.updater.config().clone();
let release = self.release.clone();
let kind = self.kind.clone();
let executor = cx.background_executor().clone();
let latest: Arc<Mutex<Option<UpdateStage>>> = Arc::new(Mutex::new(None));
let finished = Arc::new(AtomicBool::new(false));
let drained = latest.clone();
let done = finished.clone();
let ticker = executor.clone();
cx.spawn(async move |this, cx| loop {
let stage = drained.lock().ok().and_then(|mut slot| slot.take());
let running = this.update(cx, |view, cx| {
let running = view.busy();
if running {
if let Some(stage) = stage {
view.phase = Phase::Working(stage.clone());
cx.emit(UpdatePromptEvent::Stage(stage));
cx.notify();
}
}
running
});
if !matches!(running, Ok(true)) || done.load(Ordering::Relaxed) {
break;
}
ticker.timer(PROGRESS_TICK).await;
})
.detach();
let reported = latest.clone();
cx.spawn(async move |this, cx| {
let staged = executor
.spawn(async move {
config.install(&release, &kind, &|stage| {
if let Ok(mut slot) = reported.lock() {
*slot = Some(stage);
}
})
})
.await;
finished.store(true, Ordering::Relaxed);
match staged {
Ok(relaunch) => {
let dismissed = this.update(cx, |_, _| ()).is_err();
let _ = cx.update(|cx| set_installing(false, cx));
if dismissed {
updater.notify(
"Update installed",
&format!(
"{} will finish updating the next time you open it.",
updater.app()
),
);
return;
}
let restart = this
.update(cx, |view, cx| {
cx.emit(UpdatePromptEvent::Installed(relaunch.clone()));
view.auto_restart
})
.unwrap_or(false);
if restart {
let _ = cx.update(|cx| {
updater.run_before_restart(cx);
if let Relaunch::Binary(path) = relaunch {
cx.set_restart_path(path);
}
cx.restart();
});
}
}
Err(e) => {
let _ = cx.update(|cx| set_installing(false, cx));
updater.notify("Update failed", &e);
let _ = this.update(cx, |view, cx| {
view.phase = Phase::Failed(e.clone());
cx.emit(UpdatePromptEvent::Failed(e));
cx.notify();
});
}
}
})
.detach();
}
fn key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
if event.keystroke.key == "escape" {
self.dismiss(cx);
}
}
fn status(&self, dim: gpui::Hsla, small: f32, gap: f32) -> gpui::AnyElement {
match &self.phase {
Phase::Working(stage) => div()
.flex()
.flex_col()
.gap(px(gap))
.child(Progress::new(percent(stage)).size(Size::Sm))
.child(
div()
.flex()
.justify_between()
.text_size(px(small))
.text_color(dim)
.child(SharedString::from(stage.label()))
.child(SharedString::from(detail(stage))),
)
.into_any_element(),
Phase::Failed(reason) => Alert::new(SharedString::from(reason.clone()))
.title("Update failed")
.variant(Variant::Light)
.color(ColorName::Red)
.into_any_element(),
Phase::Idle => div()
.text_size(px(small))
.child(SharedString::from(if self.installable {
format!(
"{} will download the update, install it, and restart.",
self.updater.app()
)
} else {
"Open the download page to update.".to_string()
}))
.into_any_element(),
}
}
}
impl Focusable for UpdatePrompt {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus.clone()
}
}
impl EventEmitter<UpdatePromptEvent> for UpdatePrompt {}
impl Render for UpdatePrompt {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let t = theme(cx);
let bg = t.body().hsla();
let text = t.text().hsla();
let dim = t.dimmed().hsla();
let pad = t.spacing(Size::Lg);
let gap = t.spacing(Size::Xs);
let headline = t.font_size(Size::Md);
let body = t.font_size(Size::Sm);
let small = t.font_size(Size::Xs);
let status = t.spacing(Size::Xl) + small * 2.0;
let busy = self.busy();
let label = action_label(&self.phase, self.installable);
let title = format!(
"{} {} is available",
self.updater.app(),
self.release.version
);
let have = format!("You have {}.", self.updater.version());
let notes = self.release.url.clone();
div()
.size_full()
.flex()
.flex_col()
.track_focus(&self.focus)
.on_key_down(cx.listener(Self::key_down))
.bg(bg)
.text_color(text)
.pt(px(if self.window_root { TITLEBAR } else { pad }))
.px(px(pad))
.pb(px(pad))
.gap(px(gap))
.when(self.window_root, |this| this.child(drag_strip()))
.child(
div()
.text_size(px(headline))
.font_weight(FontWeight::BOLD)
.child(SharedString::from(title)),
)
.child(
div()
.text_size(px(small))
.text_color(dim)
.child(SharedString::from(have)),
)
.child(
div()
.min_h(px(status))
.text_size(px(body))
.child(self.status(dim, small, gap)),
)
.child(div().flex_1())
.child(
div()
.flex()
.items_center()
.justify_end()
.gap(px(gap))
.child(
Button::new("guise-update-notes", "Release Notes")
.variant(Variant::Subtle)
.disabled(busy || notes.is_empty())
.on_click(move |_, _, cx| cx.open_url(¬es)),
)
.child(
Button::new("guise-update-later", "Later")
.variant(Variant::Default)
.disabled(busy)
.on_click(cx.listener(|this, _, _, cx| this.dismiss(cx))),
)
.child(
Button::new("guise-update-go", label)
.variant(Variant::Filled)
.disabled(busy)
.on_click(cx.listener(|this, _, _, cx| this.accept(cx))),
),
)
.probe("UpdatePrompt")
}
}
fn drag_strip() -> impl IntoElement {
let lead = if cfg!(target_os = "macos") { 70.0 } else { 0.0 };
div()
.absolute()
.top_0()
.left(px(lead))
.right_0()
.h(px(TITLEBAR - 6.0))
.window_control_area(WindowControlArea::Drag)
.on_mouse_down(MouseButton::Left, |_, window, _| window.start_window_move())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn download_progress_is_a_percentage_across_most_of_the_bar() {
assert_eq!(
percent(&UpdateStage::Downloading {
done: 0,
total: 100
}),
0.0
);
assert!(
(percent(&UpdateStage::Downloading {
done: 50,
total: 100
}) - 42.5)
.abs()
< 0.01
);
assert!(
(percent(&UpdateStage::Downloading {
done: 100,
total: 100
}) - 85.0)
.abs()
< 0.01
);
}
#[test]
fn every_stage_stays_in_percentage_range() {
for stage in [
UpdateStage::Downloading { done: 1, total: 2 },
UpdateStage::Preparing,
UpdateStage::Installing,
UpdateStage::Verifying,
] {
let value = percent(&stage);
assert!((0.0..=100.0).contains(&value), "{stage:?} -> {value}");
}
}
#[test]
fn stages_after_the_download_only_move_forward() {
let done = percent(&UpdateStage::Downloading {
done: 100,
total: 100,
});
assert!(done < percent(&UpdateStage::Preparing));
assert!(percent(&UpdateStage::Preparing) < percent(&UpdateStage::Installing));
assert!(percent(&UpdateStage::Installing) < percent(&UpdateStage::Verifying));
}
#[test]
fn an_unknown_total_holds_the_bar_at_zero() {
assert_eq!(
percent(&UpdateStage::Downloading {
done: 900,
total: 0
}),
0.0
);
}
#[test]
fn overlong_downloads_cannot_overflow_the_bar() {
assert!(
percent(&UpdateStage::Downloading {
done: 500,
total: 100
}) <= 85.0
);
}
#[test]
fn only_the_download_reports_byte_counts() {
assert_eq!(
detail(&UpdateStage::Downloading {
done: 5_000_000,
total: 20_000_000
}),
"5.0 MB of 20.0 MB"
);
assert_eq!(detail(&UpdateStage::Downloading { done: 5, total: 0 }), "");
assert_eq!(detail(&UpdateStage::Preparing), "");
assert_eq!(detail(&UpdateStage::Verifying), "");
}
#[test]
fn sizes_are_decimal_mb_to_match_what_release_pages_report() {
assert_eq!(
detail(&UpdateStage::Downloading {
done: 0,
total: 20_314_688
}),
"0.0 MB of 20.3 MB"
);
}
#[test]
fn only_the_working_phase_is_busy() {
assert!(busy(&Phase::Working(UpdateStage::Preparing)));
assert!(!busy(&Phase::Idle));
assert!(!busy(&Phase::Failed("boom".into())));
}
#[test]
fn the_action_label_tracks_phase_and_installability() {
assert_eq!(action_label(&Phase::Idle, true), "Update & Restart");
assert_eq!(action_label(&Phase::Idle, false), "Open Download");
assert_eq!(
action_label(&Phase::Working(UpdateStage::Installing), true),
"Updating…"
);
assert_eq!(
action_label(&Phase::Failed("boom".into()), true),
"Try Again"
);
}
}