Files
tickr/src/updater.rs
whitlocktech bb14b125a2
Some checks failed
Build and Release / build (push) Has been cancelled
v0.4.0
2026-05-17 01:23:37 -05:00

108 lines
4.2 KiB
Rust

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<Option<(String, String)>> {
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);
}