Files
ostiary/src/updater.rs
2026-05-27 23:06:12 +07:00

214 lines
6.9 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();
// Only offer update if the remote version is strictly newer
if !is_newer(&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<u64>,
) -> Result<std::path::PathBuf> {
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, &current_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<String> {
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())
}
/// Returns true if `candidate` is strictly greater than `current` by semver rules.
/// Parses `MAJOR.MINOR.PATCH`; any unparseable component is treated as 0.
fn is_newer(candidate: &str, current: &str) -> bool {
fn parse(v: &str) -> (u64, u64, u64) {
let mut parts = v.splitn(3, '.').map(|s| s.parse::<u64>().unwrap_or(0));
(parts.next().unwrap_or(0), parts.next().unwrap_or(0), parts.next().unwrap_or(0))
}
parse(candidate) > parse(current)
}
/// 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<String> = 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);
}