//! Update system: check for updates, download deltas, apply with rollback. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; use tokio::fs; use tracing::{debug, info}; const UPDATE_MANIFEST_URL: &str = "https://raw.githubusercontent.com/archipelago-os/releases/main/manifest.json"; const UPDATE_STATE_FILE: &str = "update_state.json"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateManifest { pub version: String, pub release_date: String, pub changelog: Vec, pub components: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ComponentUpdate { pub name: String, pub current_version: String, pub new_version: String, pub download_url: String, pub sha256: String, pub size_bytes: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateState { pub current_version: String, pub last_check: Option, pub available_update: Option, pub update_in_progress: bool, pub rollback_available: bool, } impl Default for UpdateState { fn default() -> Self { Self { current_version: env!("CARGO_PKG_VERSION").to_string(), last_check: None, available_update: None, update_in_progress: false, rollback_available: false, } } } pub async fn load_state(data_dir: &Path) -> Result { let path = data_dir.join(UPDATE_STATE_FILE); if !path.exists() { let state = UpdateState::default(); save_state(data_dir, &state).await?; return Ok(state); } let data = fs::read_to_string(&path) .await .context("Reading update state")?; serde_json::from_str(&data).context("Parsing update state") } pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> { let path = data_dir.join(UPDATE_STATE_FILE); let data = serde_json::to_string_pretty(state)?; fs::write(&path, data) .await .context("Writing update state") } /// Check for available updates by fetching the release manifest. pub async fn check_for_updates(data_dir: &Path) -> Result { let mut state = load_state(data_dir).await?; info!("Checking for updates..."); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(15)) .build() .context("Failed to create HTTP client")?; match client.get(UPDATE_MANIFEST_URL).send().await { Ok(resp) if resp.status().is_success() => { let manifest: UpdateManifest = resp .json() .await .context("Failed to parse update manifest")?; if manifest.version != state.current_version { info!( current = %state.current_version, available = %manifest.version, "Update available" ); state.available_update = Some(manifest); } else { debug!("Already on latest version: {}", state.current_version); state.available_update = None; } } Ok(resp) => { debug!("Update check returned status: {}", resp.status()); } Err(e) => { debug!("Update check failed (offline?): {}", e); } } state.last_check = Some(chrono::Utc::now().to_rfc3339()); save_state(data_dir, &state).await?; Ok(state) } /// Get current update status without checking remote. pub async fn get_status(data_dir: &Path) -> Result { load_state(data_dir).await } /// Dismiss the available update notification. pub async fn dismiss_update(data_dir: &Path) -> Result<()> { let mut state = load_state(data_dir).await?; state.available_update = None; save_state(data_dir, &state).await }