use anyhow::{bail, Context, Result};
use crossbeam_channel::Sender;
use std::io::Read;
use std::path::{Path, PathBuf};
pub const DEFAULT_FORK_REPO: &str = "Le-Syl21/vpinball";
#[derive(Debug, Clone)]
pub struct ReleaseInfo {
pub tag: String,
pub asset_name: String,
pub asset_url: String,
pub asset_size: u64,
}
pub enum UpdateProgress {
Downloading(u64, u64), Extracting,
Done(PathBuf), Error(String),
}
fn artifact_extension() -> &'static str {
if cfg!(target_os = "windows") {
"zip"
} else if cfg!(target_os = "macos") {
"dmg"
} else {
"tar.gz"
}
}
fn platform_arch() -> (&'static str, &'static str) {
let platform = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "x86") {
"x86"
} else if cfg!(target_arch = "aarch64") {
detect_arm_board()
} else {
"unknown"
};
(platform, arch)
}
#[cfg(target_arch = "aarch64")]
fn detect_arm_board() -> &'static str {
if cfg!(target_os = "linux") {
if let Ok(model) = std::fs::read_to_string("/proc/device-tree/model") {
let lower = model.to_lowercase();
if lower.contains("raspberry") {
return "rpi-linux-aarch64";
}
if lower.contains("rk3588") || lower.contains("rock") || lower.contains("orange pi 5") {
return "rk3588-linux-aarch64";
}
}
"rpi-linux-aarch64"
} else if cfg!(target_os = "macos") {
"arm64"
} else {
"aarch64"
}
}
#[cfg(not(target_arch = "aarch64"))]
fn detect_arm_board() -> &'static str {
"unknown"
}
pub fn check_latest_release(repo: &str) -> Result<ReleaseInfo> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
let response = ureq::get(&url)
.header("User-Agent", "PinReady")
.header("Accept", "application/vnd.github.v3+json")
.call()
.context("Failed to query GitHub releases")?;
let body = response.into_body().read_to_string()?;
let json: serde_json::Value =
serde_json::from_str(&body).context("Failed to parse GitHub release JSON")?;
let tag = json["tag_name"]
.as_str()
.context("Missing tag_name in release")?
.to_string();
let (platform, arch) = platform_arch();
let ext = artifact_extension();
let suffix = format!("-{platform}-{arch}-Release.{ext}");
let prefix = "VPinballX_BGFX-";
let assets = json["assets"]
.as_array()
.context("Missing assets in release")?;
for asset in assets {
let name = asset["name"].as_str().unwrap_or_default();
if name.starts_with(prefix) && name.ends_with(&suffix) {
let url = asset["browser_download_url"]
.as_str()
.context("Missing download URL")?
.to_string();
let size = asset["size"].as_u64().unwrap_or(0);
return Ok(ReleaseInfo {
tag,
asset_name: name.to_string(),
asset_url: url,
asset_size: size,
});
}
}
bail!(
"No matching asset for this platform (expected: {prefix}*{suffix}). \
Available: {}",
assets
.iter()
.filter_map(|a| a["name"].as_str())
.collect::<Vec<_>>()
.join(", ")
)
}
pub fn default_install_dir() -> PathBuf {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".into());
PathBuf::from(home).join("VPinballX")
}
pub fn vpx_executable_name() -> &'static str {
if cfg!(target_os = "windows") {
if cfg!(target_arch = "x86_64") {
"VPinballX_BGFX64.exe"
} else {
"VPinballX_BGFX.exe"
}
} else {
"VPinballX_BGFX"
}
}
pub fn download_and_install(
release: &ReleaseInfo,
install_dir: &Path,
progress_tx: Sender<UpdateProgress>,
) -> Result<()> {
let tmp_path = install_dir
.parent()
.unwrap_or(install_dir)
.join(".vpx_download.zip");
if let Some(parent) = tmp_path.parent() {
std::fs::create_dir_all(parent)?;
}
let result = download_and_install_inner(release, install_dir, &tmp_path, &progress_tx);
let _ = std::fs::remove_file(&tmp_path);
result
}
fn download_and_install_inner(
release: &ReleaseInfo,
install_dir: &Path,
tmp_path: &Path,
progress_tx: &Sender<UpdateProgress>,
) -> Result<()> {
let response = ureq::get(&release.asset_url)
.header("User-Agent", "PinReady")
.header("Accept", "application/octet-stream")
.call()
.context("Failed to download release asset")?;
let total = release.asset_size;
let mut downloaded: u64 = 0;
let mut file = std::fs::File::create(tmp_path)?;
let mut buf = [0u8; 32768];
let mut reader = response.into_body().into_reader();
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
std::io::Write::write_all(&mut file, &buf[..n])?;
downloaded += n as u64;
let _ = progress_tx.send(UpdateProgress::Downloading(downloaded, total));
}
drop(file);
let _ = progress_tx.send(UpdateProgress::Extracting);
std::fs::create_dir_all(install_dir)?;
let ext = artifact_extension();
if ext == "tar.gz" {
extract_tar_gz(tmp_path, install_dir)?;
} else if ext == "zip" {
extract_zip(tmp_path, install_dir)?;
} else {
let dest = install_dir.join(tmp_path.file_name().unwrap_or_default());
std::fs::copy(tmp_path, &dest)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let exe_path = install_dir.join(vpx_executable_name());
if exe_path.exists() {
std::fs::set_permissions(&exe_path, std::fs::Permissions::from_mode(0o755))?;
}
}
let exe_path = install_dir.join(vpx_executable_name());
let _ = progress_tx.send(UpdateProgress::Done(exe_path));
Ok(())
}
fn extract_tar_gz(archive_path: &Path, dest: &Path) -> Result<()> {
let file = std::fs::File::open(archive_path)?;
let gz = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(gz);
archive.set_preserve_permissions(true);
archive.unpack(dest).context("Failed to extract tar.gz")?;
Ok(())
}
fn extract_zip(archive_path: &Path, dest: &Path) -> Result<()> {
let file = std::fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file).context("Failed to open zip archive")?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let name = entry.name().to_string();
let out_path = dest.join(&name);
if entry.is_dir() {
std::fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out_file = std::fs::File::create(&out_path)?;
std::io::copy(&mut entry, &mut out_file)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = entry.unix_mode() {
std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
}
}
}
}
Ok(())
}