// 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. /// Returns the path of the replaced executable (captured before rename so it /// remains valid even after the old inode is marked "(deleted)" by the kernel). 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. // current_exe is captured BEFORE this rename — after rename, /proc/self/exe // on Linux returns the path with " (deleted)" appended, but the PathBuf we // hold still refers to the correct filesystem path of the new binary. std::fs::rename(&tmp_path, ¤t_exe).context("failed to replace current exe")?; Ok(current_exe) } // ── Update-target file ─────────────────────────────────────────────────────── // Written just before exec. On next startup we compare the expected version // with CARGO_PKG_VERSION. A mismatch means the replacement didn't work // (wrong asset, failed rename, etc.) and we show an error instead of looping. fn update_target_path() -> std::path::PathBuf { crate::config::config_path() .parent() .expect("config dir has parent") .join(".update_target") } /// Write the expected version to disk before exec'ing the new binary. pub fn write_update_target(new_version: &str) { let _ = std::fs::write(update_target_path(), new_version); } /// Read and delete the update-target file. /// Returns `Some(expected_version)` if a previous run attempted an update. /// The caller should compare it with `env!("CARGO_PKG_VERSION")` and show /// an error if they differ. pub fn take_update_target() -> Option { let path = update_target_path(); if !path.exists() { return None; } let version = std::fs::read_to_string(&path).ok()?; let _ = std::fs::remove_file(&path); Some(version.trim().to_string()) } /// Replaces current process via execv (Unix). Never returns on success. /// `exe_path` must be the path captured *before* the binary was replaced — /// do NOT call std::env::current_exe() here, it returns "(deleted)" on Linux. #[cfg(unix)] pub fn exec_updated(exe_path: &std::path::Path) -> ! { use std::os::unix::process::CommandExt; let args: Vec = std::env::args().collect(); let err = std::process::Command::new(exe_path) .args(&args[1..]) .exec(); eprintln!("Failed to exec updated binary: {}", err); std::process::exit(1); }