169 lines
4.7 KiB
Rust
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, ¤t_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);
|
|
}
|