use anyhow::{Context, Result}; use std::io::Write; const GITEA_BASE: &str = "https://gitea.whitlocktech.com"; const REPO_OWNER: &str = "whitlocktech"; const REPO_NAME: &str = "tickr"; const ASSET_NAME: &str = "tickr.exe"; /// Check whether a newer release exists on Gitea. /// Returns `Some((tag_name, download_url))` when the remote version is strictly /// greater than the running binary, `None` when already up-to-date. pub async fn check_for_update() -> Result> { let url = format!( "{GITEA_BASE}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest" ); let resp = reqwest::Client::new() .get(&url) .header("User-Agent", format!("tickr/{}", crate::VERSION)) .send() .await .context("Failed to reach Gitea")?; if !resp.status().is_success() { // 404 = no releases yet; treat as up-to-date rather than an error return Ok(None); } let body: serde_json::Value = resp.json().await.context("Invalid JSON from Gitea")?; let tag = body["tag_name"] .as_str() .context("Missing tag_name in release")? .to_string(); let download_url = body["assets"] .as_array() .and_then(|assets| { assets .iter() .find(|a| a["name"].as_str() == Some(ASSET_NAME)) .and_then(|a| a["browser_download_url"].as_str()) }) .context("Asset not found in release")? .to_string(); // Strip leading 'v' and compare semver let remote_str = tag.trim_start_matches('v'); let remote_ver = semver::Version::parse(remote_str) .with_context(|| format!("Bad remote version: {remote_str}"))?; let local_ver = semver::Version::parse(crate::VERSION) .with_context(|| format!("Bad local version: {}", crate::VERSION))?; if remote_ver > local_ver { Ok(Some((tag, download_url))) } else { Ok(None) } } /// Download the new binary and schedule a swap on next launch. /// /// The download is streamed in chunks to avoid loading the whole binary into /// memory. A small bat script is written alongside it; the bat waits 2 seconds /// for the current process to exit, moves the new exe into place, relaunches /// it, then deletes itself. pub async fn download_and_swap(download_url: &str) -> Result<()> { let exe_path = std::env::current_exe().context("Cannot resolve current exe path")?; let exe_dir = exe_path.parent().context("Cannot resolve exe directory")?; let update_path = exe_dir.join("tickr_update.exe"); let bat_path = exe_dir.join("tickr_swap.bat"); // ── Stream download ─────────────────────────────────────────────────── let mut resp = reqwest::Client::new() .get(download_url) .header("User-Agent", format!("tickr/{}", crate::VERSION)) .send() .await .context("Download request failed")?; if !resp.status().is_success() { anyhow::bail!("Download returned HTTP {}", resp.status()); } let mut file = std::fs::File::create(&update_path) .with_context(|| format!("Cannot create {}", update_path.display()))?; while let Some(chunk) = resp.chunk().await.context("Download interrupted")? { file.write_all(&chunk).context("Write error during download")?; } drop(file); // ── Write swap bat ──────────────────────────────────────────────────── let bat = "@echo off\r\ntimeout /t 2 /nobreak >nul\r\nmove /y tickr_update.exe tickr.exe\r\nstart \"\" tickr.exe\r\ndel \"%~f0\"\r\n"; std::fs::write(&bat_path, bat) .with_context(|| format!("Cannot write {}", bat_path.display()))?; // ── Launch bat detached and exit ────────────────────────────────────── std::process::Command::new("cmd") .args(["/c", "tickr_swap.bat"]) .current_dir(exe_dir) .spawn() .context("Failed to launch swap bat")?; std::process::exit(0); }