// src/updater.rs use anyhow::{Context, Result}; use futures::StreamExt; use serde::Deserialize; use tokio::io::AsyncWriteExt; use tokio::sync::mpsc::UnboundedSender; #[derive(Debug, Clone)] pub struct UpdateInfo { pub current_version: String, pub new_version: String, pub download_url: String, pub size: u64, } #[derive(Deserialize)] struct Release { tag_name: String, prerelease: bool, assets: Vec, } #[derive(Deserialize)] struct ReleaseAsset { name: String, browser_download_url: String, size: u64, } /// Checks Gitea releases API for latest stable (non-prerelease) release. /// Returns Ok(None) if already up to date or no matching asset found. pub async fn check(api_base: &str, timeout_sec: u64) -> Result> { let url = format!("{}/releases?limit=10&page=1", api_base); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(timeout_sec)) .build() .context("failed to build HTTP client")?; let releases: Vec = client .get(&url) .send() .await .context("failed to fetch releases")? .json() .await .context("failed to parse releases JSON")?; let current_version = env!("CARGO_PKG_VERSION").to_string(); // Find the latest non-prerelease release let latest = releases.into_iter().find(|r| !r.prerelease); let release = match latest { Some(r) => r, None => return Ok(None), }; // Strip "v" prefix and anything after "@" (e.g. "v1.0.0@master" → "1.0.0") let tag = release.tag_name.as_str(); let tag = tag.strip_prefix('v').unwrap_or(tag); let tag = tag.split('@').next().unwrap_or(tag).trim(); let new_version = tag.to_string(); // Already up to date if new_version == current_version { return Ok(None); } // Find a matching asset let asset_prefix = format!( "{}-{}-{}", env!("CARGO_PKG_NAME"), std::env::consts::OS, std::env::consts::ARCH ); let asset = release .assets .into_iter() .find(|a| a.name.starts_with(&asset_prefix)); let asset = match asset { Some(a) => a, None => return Ok(None), }; Ok(Some(UpdateInfo { current_version, new_version, download_url: asset.browser_download_url, size: asset.size, })) } /// Downloads binary to temp file with streaming progress, atomically replaces /// current exe, sets chmod 755. /// progress_tx receives bytes downloaded so far. pub async fn download_and_apply( info: &UpdateInfo, progress_tx: UnboundedSender, ) -> Result<()> { let client = reqwest::Client::new(); let response = client .get(&info.download_url) .send() .await .context("failed to start download")? .error_for_status() .context("download request failed with error status")?; // Write to a temp file next to the current exe so rename is atomic let current_exe = std::env::current_exe().context("failed to get current exe path")?; let exe_dir = current_exe .parent() .context("current exe has no parent directory")?; let tmp_path = exe_dir.join(format!(".{}.tmp", env!("CARGO_PKG_NAME"))); let mut file = tokio::fs::File::create(&tmp_path) .await .context("failed to create temp file")?; let mut stream = response.bytes_stream(); let mut downloaded: u64 = 0; while let Some(chunk) = stream.next().await { let chunk = chunk.context("error reading download chunk")?; downloaded += chunk.len() as u64; file.write_all(&chunk) .await .context("failed to write to temp file")?; let _ = progress_tx.send(downloaded); } // Flush and close drop(file); // chmod 755 #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o755); std::fs::set_permissions(&tmp_path, perms).context("failed to set permissions")?; } // Atomically replace current exe std::fs::rename(&tmp_path, ¤t_exe).context("failed to replace current exe")?; Ok(()) } /// Replaces current process via execv (Unix). Never returns on success. #[cfg(unix)] pub fn exec_updated() -> ! { use std::os::unix::process::CommandExt; let exe = std::env::current_exe().expect("failed to get current exe path"); let args: Vec = std::env::args().collect(); let err = std::process::Command::new(&exe) .args(&args[1..]) .exec(); // exec only returns if it failed eprintln!("Failed to exec updated binary: {}", err); std::process::exit(1); }