108 lines
4.2 KiB
Rust
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);
|
|
}
|