flatland3-gfx 0.2.32

Flatland3 Macroquad + egui graphical play client
//! Background client self-update check + top egui banner for flatland3-gfx.

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()))
}

/// Spawn a background check (no-op when disabled or dismissed).
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;
}

/// Draw the top-center update banner; returns true when the player chose Restart.
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
}

/// Headless diagnostic: print check result and exit.
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(())
}