use anyhow::{Context, Result, bail};
use crossbeam_channel::Sender;
use regex::Regex;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
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,
}
#[cfg(any(test, not(target_os = "windows")))]
fn parse_vpx_version_output(s: &str) -> Option<String> {
static VPX_VERSION_RE: OnceLock<Regex> = OnceLock::new();
let re = VPX_VERSION_RE.get_or_init(|| {
Regex::new(r"(?x)\b(?P<ver>v\d+\.\d+\.\d+)\b(?:.*?\bRev\.\s*(?P<rev>\d+)\s*\((?P<sha>[0-9A-Fa-f]{7})\))?")
.expect("valid VPX version regex")
});
let caps = re.captures(s)?;
let base = caps.name("ver")?.as_str();
match (caps.name("rev"), caps.name("sha")) {
(Some(rev), Some(sha)) => Some(format!("{}-{}-{}", base, rev.as_str(), sha.as_str())),
_ => Some(base.to_string()),
}
}
#[cfg(any(test, target_os = "windows"))]
fn parse_vpx_product_version(s: &str) -> Option<String> {
let cleaned = s.trim_matches('\0').trim();
if cleaned.is_empty() {
return None;
}
static PRODUCT_VERSION_RE: OnceLock<Regex> = OnceLock::new();
let re = PRODUCT_VERSION_RE.get_or_init(|| {
Regex::new(r"^(?P<base>\d+\.\d+\.\d+)\.(?P<rev>\d+)\.(?P<sha>[0-9a-fA-F]+)$")
.expect("valid product version regex")
});
let caps = re.captures(cleaned)?;
let base = caps.name("base")?.as_str();
let rev = caps.name("rev")?.as_str();
let sha = caps.name("sha")?.as_str().to_ascii_lowercase();
Some(format!("v{base}-{rev}-{sha}"))
}
#[cfg(target_os = "windows")]
fn query_windows_file_version(exe_path: &Path) -> Option<String> {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::ptr;
use winapi::shared::minwindef::{DWORD, LPVOID, UINT};
use winapi::um::winver::{GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW};
fn query_string_value(buffer: &[u8], sub_block: &str) -> Option<String> {
let mut value_ptr: LPVOID = ptr::null_mut();
let mut value_len: UINT = 0;
let sub_block_wide: Vec<u16> = sub_block.encode_utf16().chain(Some(0)).collect();
let queried = unsafe {
VerQueryValueW(
buffer.as_ptr() as *const _,
sub_block_wide.as_ptr(),
&mut value_ptr,
&mut value_len,
)
};
if queried == 0 || value_ptr.is_null() || value_len == 0 {
return None;
}
let raw =
unsafe { std::slice::from_raw_parts(value_ptr as *const u16, value_len as usize) };
let text = String::from_utf16_lossy(raw);
Some(text.trim_matches('\0').trim().to_string())
}
let wide_path: Vec<u16> = OsStr::new(exe_path).encode_wide().chain(Some(0)).collect();
let mut handle: DWORD = 0;
let info_size = unsafe { GetFileVersionInfoSizeW(wide_path.as_ptr(), &mut handle) };
if info_size == 0 {
return None;
}
let mut buffer = vec![0u8; info_size as usize];
let got_info = unsafe {
GetFileVersionInfoW(
wide_path.as_ptr(),
0,
info_size,
buffer.as_mut_ptr() as LPVOID,
)
};
if got_info == 0 {
return None;
}
query_string_value(&buffer, "\\StringFileInfo\\040904B0\\ProductVersion")
.and_then(|product| parse_vpx_product_version(&product))
}
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?per_page=1");
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 releases: serde_json::Value =
serde_json::from_str(&body).context("Failed to parse GitHub release JSON")?;
let json = releases
.as_array()
.and_then(|arr| arr.first())
.context("No releases found")?;
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("Visual_Pinball")
}
pub fn resolve_vpx_exe(path: &Path) -> PathBuf {
let p = PathBuf::from(path);
if cfg!(target_os = "macos")
&& let Some(ext) = p.extension()
&& ext == "app"
&& p.is_dir()
{
let macos_dir = p.join("Contents/MacOS");
if let Ok(entries) = std::fs::read_dir(&macos_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with("VPinballX") {
return entry.path();
}
}
}
}
p
}
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"
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallFailure {
Denied,
NotFound,
}
pub fn classify_install_error(err: &anyhow::Error) -> Option<InstallFailure> {
err.chain()
.filter_map(|e| e.downcast_ref::<std::io::Error>())
.find_map(|io| match io.kind() {
std::io::ErrorKind::PermissionDenied => Some(InstallFailure::Denied),
std::io::ErrorKind::NotFound => Some(InstallFailure::NotFound),
_ => None,
})
}
pub fn download_and_install(
release: &ReleaseInfo,
install_dir: &Path,
progress_tx: Sender<UpdateProgress>,
) -> Result<()> {
let tmp_path =
std::env::temp_dir().join(format!(".pinready_vpx_download_{}.zip", std::process::id()));
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(())
}
pub(crate) 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(())
}
pub(crate) 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()?.into_owned();
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(())
}
pub fn query_vpx_version(exe_path: &str) -> Option<String> {
let exe_path = resolve_vpx_exe(Path::new(exe_path));
#[cfg(target_os = "windows")]
{
query_windows_file_version(&exe_path)
}
#[cfg(not(target_os = "windows"))]
{
use std::process::Command;
let output = Command::new(&exe_path).arg("--version").output().ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}{}", stdout, stderr);
parse_vpx_version_output(&combined)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchBlock {
WindowsInternetMark,
MacQuarantine,
}
pub fn quarantine_candidates(exe: &Path) -> Vec<PathBuf> {
let mut out = vec![exe.to_path_buf()];
for ancestor in exe.ancestors().skip(1) {
if ancestor.extension().is_some_and(|e| e == "app") {
out.push(ancestor.to_path_buf());
break;
}
}
out
}
pub fn launch_block(exe: &Path) -> Option<LaunchBlock> {
if cfg!(windows) && has_internet_mark(exe) {
return Some(LaunchBlock::WindowsInternetMark);
}
if cfg!(target_os = "macos")
&& quarantine_candidates(exe)
.iter()
.any(|path| has_quarantine_mark(path))
{
return Some(LaunchBlock::MacQuarantine);
}
None
}
#[cfg(windows)]
fn has_internet_mark(exe: &Path) -> bool {
let mut stream = exe.as_os_str().to_os_string();
stream.push(":Zone.Identifier");
std::fs::File::open(PathBuf::from(stream)).is_ok()
}
#[cfg(not(windows))]
fn has_internet_mark(_exe: &Path) -> bool {
false
}
#[cfg(target_os = "macos")]
fn has_quarantine_mark(path: &Path) -> bool {
std::process::Command::new("xattr")
.arg("-p")
.arg("com.apple.quarantine")
.arg(path)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(not(target_os = "macos"))]
fn has_quarantine_mark(_path: &Path) -> bool {
false
}
pub const PINREADY_REPO: &str = "Le-Syl21/PinReady";
pub const CURRENT_PINREADY_VERSION: &str = env!("CARGO_PKG_VERSION");
fn pinready_asset_name() -> String {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"unknown"
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"unknown"
};
let ext = if cfg!(target_os = "windows") {
"zip"
} else {
"tar.gz"
};
format!("pinready-{os}-{arch}.{ext}")
}
fn pinready_binary_name() -> &'static str {
if cfg!(target_os = "windows") {
"pinready.exe"
} else {
"pinready"
}
}
pub fn check_pinready_release() -> Result<ReleaseInfo> {
let url = format!("https://api.github.com/repos/{PINREADY_REPO}/releases?per_page=1");
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 releases: serde_json::Value =
serde_json::from_str(&body).context("Failed to parse GitHub release JSON")?;
let json = releases
.as_array()
.and_then(|arr| arr.first())
.context("No PinReady releases found")?;
let tag = json["tag_name"]
.as_str()
.context("Missing tag_name in release")?
.to_string();
let target = pinready_asset_name();
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 == target {
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 PinReady asset named {target}. Available: {}",
assets
.iter()
.filter_map(|a| a["name"].as_str())
.collect::<Vec<_>>()
.join(", ")
)
}
pub fn is_pinready_update_available(release: &ReleaseInfo) -> bool {
is_newer_version(
release.tag.trim_start_matches('v'),
CURRENT_PINREADY_VERSION,
)
}
fn is_newer_version(remote: &str, current: &str) -> bool {
fn parts(v: &str) -> Option<Vec<u64>> {
v.split(['-', '+'])
.next()?
.split('.')
.map(|n| n.parse().ok())
.collect()
}
match (parts(remote), parts(current)) {
(Some(r), Some(c)) => r > c,
_ => remote != current,
}
}
pub fn download_pinready_and_replace(
release: &ReleaseInfo,
progress_tx: Sender<UpdateProgress>,
) -> Result<()> {
let tmp_dir = std::env::temp_dir();
let archive_path = tmp_dir.join(&release.asset_name);
let new_binary_path = tmp_dir.join(format!("pinready_new_{}", std::process::id()));
let response = ureq::get(&release.asset_url)
.header("User-Agent", "PinReady")
.header("Accept", "application/octet-stream")
.call()
.context("Failed to download PinReady release")?;
let total = release.asset_size;
let mut downloaded: u64 = 0;
let mut file = std::fs::File::create(&archive_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);
log::info!(
"PinReady update: downloaded {} bytes to {}",
downloaded,
archive_path.display()
);
let _ = progress_tx.send(UpdateProgress::Extracting);
extract_pinready_binary(&archive_path, &new_binary_path, pinready_binary_name())?;
log::info!(
"PinReady update: extracted binary to {}",
new_binary_path.display()
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&new_binary_path, std::fs::Permissions::from_mode(0o755))?;
}
let exe = std::env::current_exe().context("Failed to resolve current exe")?;
self_replace::self_replace(&new_binary_path)
.context("Failed to swap the running PinReady binary")?;
log::info!(
"PinReady update: self_replace done, relaunching from {}",
exe.display()
);
let child = std::process::Command::new(&exe)
.arg("--from-update")
.spawn()
.context("Failed to relaunch PinReady")?;
log::info!(
"PinReady update: spawned child PID {} via {} --from-update",
child.id(),
exe.display()
);
let _ = std::fs::remove_file(&archive_path);
let _ = std::fs::remove_file(&new_binary_path);
let _ = progress_tx.send(UpdateProgress::Done(std::path::PathBuf::new()));
Ok(())
}
fn extract_pinready_binary(archive: &Path, dest: &Path, binary_name: &str) -> Result<()> {
let ext = archive.extension().and_then(|s| s.to_str()).unwrap_or("");
if ext == "zip" {
let file = std::fs::File::open(archive)?;
let mut zip = zip::ZipArchive::new(file)?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
let name = entry.name()?.into_owned();
if name == binary_name || name.ends_with(&format!("/{binary_name}")) {
let mut out = std::fs::File::create(dest)?;
std::io::copy(&mut entry, &mut out)?;
return Ok(());
}
}
} else {
let file = std::fs::File::open(archive)?;
let gz = flate2::read::GzDecoder::new(file);
let mut tar = tar::Archive::new(gz);
for entry in tar.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_path_buf();
let matches = path
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n == binary_name);
if matches {
let mut out = std::fs::File::create(dest)?;
std::io::copy(&mut entry, &mut out)?;
return Ok(());
}
}
}
bail!("Binary {binary_name} not found in {}", archive.display())
}
#[cfg(test)]
mod version_tests {
use super::*;
#[test]
fn full_vpx_output_produces_artifact_tag_format() {
assert_eq!(
parse_vpx_version_output(
"Starting VPX - v10.8.1 Beta (Rev. 4955 (da4e2db), macos BGFX 64bits)"
),
Some("v10.8.1-4955-da4e2db".to_string())
);
}
#[test]
fn version_only_fallback_when_no_rev_sha() {
assert_eq!(
parse_vpx_version_output("v10.8.1"),
Some("v10.8.1".to_string())
);
}
#[test]
fn returns_none_for_unparseable_output() {
assert_eq!(parse_vpx_version_output("invalid"), None);
assert_eq!(parse_vpx_version_output("4955"), None);
}
#[test]
fn product_version_dot_format_is_normalized() {
assert_eq!(
parse_vpx_product_version("10.8.1.4957.db2a00"),
Some("v10.8.1-4957-db2a00".to_string())
);
assert_eq!(
parse_vpx_product_version("Visual Pinball BGFX 10.8.1"),
None
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn default_fork_repo_is_set() {
assert!(!DEFAULT_FORK_REPO.is_empty());
assert!(DEFAULT_FORK_REPO.contains('/'));
}
#[test]
fn artifact_extension_linux() {
let ext = artifact_extension();
assert!(
ext == "tar.gz" || ext == "zip" || ext == "dmg",
"unexpected extension: {ext}"
);
}
#[test]
fn platform_arch_returns_known_values() {
let (platform, arch) = platform_arch();
assert!(
["linux", "windows", "macos", "unknown"].contains(&platform),
"unexpected platform: {platform}"
);
assert!(!arch.is_empty(), "arch should not be empty");
}
#[test]
fn vpx_executable_name_not_empty() {
let name = vpx_executable_name();
assert!(!name.is_empty());
assert!(
name.starts_with("VPinballX"),
"expected VPinballX prefix: {name}"
);
}
#[test]
fn install_errors_are_classified() {
use std::io::{Error, ErrorKind};
let denied = anyhow::Error::new(Error::from(ErrorKind::PermissionDenied));
assert_eq!(
classify_install_error(&denied),
Some(InstallFailure::Denied)
);
let missing = anyhow::Error::new(Error::from(ErrorKind::NotFound)).context("extract");
assert_eq!(
classify_install_error(&missing),
Some(InstallFailure::NotFound)
);
let other = anyhow::anyhow!("network down");
assert_eq!(classify_install_error(&other), None);
}
#[test]
fn default_install_dir_contains_visual_pinball() {
let dir = default_install_dir();
let s = dir.to_string_lossy();
assert!(
s.contains("Visual_Pinball"),
"expected Visual_Pinball in: {s}"
);
}
#[test]
fn resolve_vpx_exe_returns_same_on_linux() {
let path = Path::new("/usr/local/bin/VPinballX_BGFX");
let resolved = resolve_vpx_exe(path);
assert_eq!(resolved, path.to_path_buf());
}
#[test]
fn resolve_vpx_exe_nonexistent_path() {
let path = Path::new("/nonexistent/path/VPinballX_BGFX");
let resolved = resolve_vpx_exe(path);
assert_eq!(resolved, path.to_path_buf());
}
#[test]
fn extract_tar_gz_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("test.tar.gz");
let dest = dir.path().join("extracted");
{
let file = std::fs::File::create(&archive_path).unwrap();
let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
let mut tar = tar::Builder::new(gz);
let content = b"hello pinball";
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, "test.txt", &content[..])
.unwrap();
tar.finish().unwrap();
}
extract_tar_gz(&archive_path, &dest).unwrap();
let extracted = std::fs::read_to_string(dest.join("test.txt")).unwrap();
assert_eq!(extracted, "hello pinball");
}
#[test]
fn extract_zip_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("test.zip");
let dest = dir.path().join("extracted");
{
let file = std::fs::File::create(&archive_path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
zip.start_file("test.txt", options).unwrap();
zip.write_all(b"hello vpx").unwrap();
zip.finish().unwrap();
}
extract_zip(&archive_path, &dest).unwrap();
let extracted = std::fs::read_to_string(dest.join("test.txt")).unwrap();
assert_eq!(extracted, "hello vpx");
}
}
#[cfg(test)]
mod launch_block_tests {
use super::*;
#[test]
fn a_mark_on_an_app_bundle_is_looked_for_on_the_bundle() {
let exe = Path::new("/Applications/VPinballX_BGFX.app/Contents/MacOS/VPinballX_BGFX");
assert_eq!(
quarantine_candidates(exe),
vec![
PathBuf::from(exe),
PathBuf::from("/Applications/VPinballX_BGFX.app")
]
);
let plain = Path::new("/opt/vpx/VPinballX_BGFX");
assert_eq!(quarantine_candidates(plain), vec![PathBuf::from(plain)]);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_never_reports_a_blocked_vpx() {
assert_eq!(launch_block(Path::new("/bin/ls")), None);
}
}
#[cfg(test)]
mod newer_version_tests {
use super::is_newer_version;
#[test]
fn only_a_later_version_is_offered() {
assert!(is_newer_version("0.27.8", "0.27.7"));
assert!(is_newer_version("0.27.10", "0.27.9"));
assert!(is_newer_version("1.0.0", "0.99.99"));
assert!(!is_newer_version("0.27.7", "0.27.7"));
assert!(!is_newer_version("0.27.7", "0.27.8"));
assert!(!is_newer_version("0.27.8-rc1", "0.27.8"));
assert!(is_newer_version("nightly", "0.27.8"));
}
}