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