use std::sync::{Arc, Mutex};
use std::time::Duration;
use egui::{Align2, Order, RichText};
use flatland_client_lib::{
apply_update, check_for_update, check_updates_enabled, cleanup_old_binaries, local_version,
ClientConfig, UpdateAvailable,
};
use flatland_gfx_engine::ui::card_frame;
use flatland_gfx_engine::GfxTheme;
#[derive(Debug, Clone)]
pub enum UpdaterPhase {
Checking,
Available(UpdateAvailable),
Downloading { version: String },
ReadyRestart { version: String },
UpToDate,
Error(String),
Hidden,
}
#[derive(Debug, Clone)]
pub struct UpdaterState {
pub phase: UpdaterPhase,
}
impl Default for UpdaterState {
fn default() -> Self {
Self {
phase: UpdaterPhase::Hidden,
}
}
}
pub type SharedUpdater = Arc<Mutex<UpdaterState>>;
pub fn new_shared() -> SharedUpdater {
Arc::new(Mutex::new(UpdaterState::default()))
}
pub fn spawn_check(shared: SharedUpdater) {
cleanup_old_binaries();
let cfg = ClientConfig::load();
if !check_updates_enabled(&cfg) {
return;
}
{
let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner());
guard.phase = UpdaterPhase::Checking;
}
let dismissed = cfg.update_dismissed_version.clone();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(err) => {
set_error(&shared, format!("update runtime: {err:#}"));
return;
}
};
rt.block_on(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(12))
.build()
{
Ok(c) => c,
Err(err) => {
set_error(&shared, format!("http client: {err:#}"));
return;
}
};
match check_for_update(&client, local_version(), None).await {
Ok(Some(offer)) => {
if dismissed
.as_deref()
.is_some_and(|v| v == offer.remote_version)
{
let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner());
guard.phase = UpdaterPhase::Hidden;
return;
}
let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner());
guard.phase = UpdaterPhase::Available(offer);
}
Ok(None) => {
let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner());
guard.phase = UpdaterPhase::UpToDate;
}
Err(err) => set_error(&shared, format!("{err:#}")),
}
});
});
}
fn set_error(shared: &SharedUpdater, msg: String) {
tracing::warn!(error = %msg, "client update check failed");
let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner());
guard.phase = UpdaterPhase::Error(msg);
}
pub fn spawn_apply(shared: SharedUpdater, offer: UpdateAvailable) {
{
let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner());
guard.phase = UpdaterPhase::Downloading {
version: offer.remote_version.clone(),
};
}
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(err) => {
set_error(&shared, format!("update runtime: {err:#}"));
return;
}
};
rt.block_on(async move {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.build()
{
Ok(c) => c,
Err(err) => {
set_error(&shared, format!("http client: {err:#}"));
return;
}
};
match apply_update(&client, &offer).await {
Ok(applied) => {
let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner());
guard.phase = UpdaterPhase::ReadyRestart {
version: applied.version,
};
}
Err(err) => set_error(&shared, format!("{err:#}")),
}
});
});
}
pub fn dismiss(shared: &SharedUpdater, version: &str) {
let mut cfg = ClientConfig::load();
let _ = cfg.save_update_dismissed_version(version);
let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner());
guard.phase = UpdaterPhase::Hidden;
}
pub fn draw_banner(ctx: &egui::Context, shared: &SharedUpdater) -> bool {
let phase = {
let guard = shared.lock().unwrap_or_else(|e| e.into_inner());
guard.phase.clone()
};
match phase {
UpdaterPhase::Checking | UpdaterPhase::UpToDate | UpdaterPhase::Hidden => return false,
_ => {}
}
let theme = GfxTheme::default();
let mut restart = false;
let mut download_offer: Option<UpdateAvailable> = None;
let mut dismiss_version: Option<String> = None;
egui::Area::new(egui::Id::new("flatland_client_update_banner"))
.order(Order::Foreground)
.anchor(Align2::CENTER_TOP, egui::vec2(0.0, 10.0))
.show(ctx, |ui| {
card_frame(&theme).show(ui, |ui| {
ui.set_max_width(520.0);
match &phase {
UpdaterPhase::Available(offer) => {
ui.horizontal(|ui| {
ui.label(
RichText::new("UPDATE")
.monospace()
.size(11.0)
.color(theme.section_header),
);
ui.label(
RichText::new(format!(
"v{} available (you have v{})",
offer.remote_version, offer.local_version
))
.monospace()
.size(13.0)
.color(theme.text)
.strong(),
);
});
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button("Download").clicked() {
download_offer = Some(offer.clone());
}
if ui.button("Remind later").clicked() {
dismiss_version = Some(offer.remote_version.clone());
}
});
}
UpdaterPhase::Downloading { version } => {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0));
ui.label(
RichText::new(format!("Downloading v{version}…"))
.monospace()
.size(13.0)
.color(theme.text),
);
});
}
UpdaterPhase::ReadyRestart { version } => {
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("v{version} ready"))
.monospace()
.size(13.0)
.color(theme.text)
.strong(),
);
if ui.button("Restart to apply").clicked() {
restart = true;
}
});
}
UpdaterPhase::Error(msg) => {
ui.label(
RichText::new("Update failed")
.monospace()
.size(12.0)
.color(theme.danger)
.strong(),
);
ui.label(
RichText::new(msg)
.monospace()
.size(11.0)
.color(theme.text_muted),
);
if ui.button("Dismiss").clicked() {
let mut guard = shared.lock().unwrap_or_else(|e| e.into_inner());
guard.phase = UpdaterPhase::Hidden;
}
}
_ => {}
}
});
});
if let Some(offer) = download_offer {
spawn_apply(shared.clone(), offer);
}
if let Some(version) = dismiss_version {
dismiss(shared, &version);
}
restart
}
pub async fn run_check_update_cli() -> anyhow::Result<()> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.build()?;
match check_for_update(&client, local_version(), None).await? {
Some(offer) => {
println!(
"update available: {} → {}",
offer.local_version, offer.remote_version
);
}
None => println!("up to date: v{}", local_version()),
}
Ok(())
}