Files
ostiary/src/updater.rs
2026-05-21 23:02:14 +07:00

169 lines
4.7 KiB
Rust

// 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<ReleaseAsset>,
}
#[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<Option<UpdateInfo>> {
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<Release> = 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<u64>,
) -> 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, &current_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<String> = 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);
}