use std::path::Path;
use std::time::{Duration, Instant, SystemTime};
const TIMEOUT: Duration = Duration::from_secs(20);
const POLL: Duration = Duration::from_millis(150);
const SETTLED_POLLS: u32 = 2;
pub fn ini_version_digits(tag: &str) -> Option<String> {
let tag = tag.trim();
let tag = tag.strip_prefix('v').unwrap_or(tag);
let mut parts = tag.split('-');
let base = parts.next()?;
let rev = parts.next()?;
let numbers: Vec<&str> = base.split('.').collect();
let all_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if numbers.len() != 3 || !numbers.iter().all(|n| all_digits(n)) || !all_digits(rev) {
return None;
}
Some(format!("{}{}{}{rev}", numbers[0], numbers[1], numbers[2]))
}
pub fn already_current(ini_version: Option<&str>, exe_tag: Option<&str>) -> bool {
match (
ini_version.map(str::trim),
exe_tag.and_then(ini_version_digits),
) {
(Some(saved), Some(digits)) => saved == digits,
_ => false,
}
}
type FileStamp = (SystemTime, u64);
fn stamp(path: &Path) -> Option<FileStamp> {
let meta = std::fs::metadata(path).ok()?;
Some((meta.modified().ok()?, meta.len()))
}
struct RewriteWatch {
before: Option<FileStamp>,
last: Option<FileStamp>,
still: u32,
}
impl RewriteWatch {
fn new(before: Option<FileStamp>) -> Self {
Self {
before,
last: before,
still: 0,
}
}
fn observe(&mut self, now: Option<FileStamp>) -> bool {
if now.is_none() || now == self.before {
self.last = now;
self.still = 0;
return false;
}
if now == self.last {
self.still += 1;
} else {
self.last = now;
self.still = 0;
}
self.still >= SETTLED_POLLS
}
}
trait Writer {
fn exited(&mut self) -> Option<String>;
fn stop(&mut self);
}
#[cfg(not(target_os = "windows"))]
impl Writer for std::process::Child {
fn exited(&mut self) -> Option<String> {
match self.try_wait() {
Ok(Some(status)) => Some(status.to_string()),
_ => None,
}
}
fn stop(&mut self) {
let _ = self.kill();
let _ = self.wait();
}
}
#[cfg(not(target_os = "windows"))]
fn spawn(exe: &Path) -> std::io::Result<std::process::Child> {
std::process::Command::new(exe)
.arg("-h")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
}
#[cfg(target_os = "windows")]
fn spawn(exe: &Path) -> std::io::Result<hidden_desktop::HiddenProcess> {
hidden_desktop::HiddenProcess::spawn(exe, "-h")
}
#[cfg(target_os = "windows")]
mod hidden_desktop {
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use windows::Win32::Foundation::{CloseHandle, GENERIC_ALL, WAIT_OBJECT_0};
use windows::Win32::System::StationsAndDesktops::{
CloseDesktop, CreateDesktopW, DESKTOP_CONTROL_FLAGS, HDESK,
};
use windows::Win32::System::Threading::{
CreateProcessW, GetExitCodeProcess, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION,
STARTUPINFOW, TerminateProcess, WaitForSingleObject,
};
use windows::core::{PCWSTR, PWSTR};
fn wide(s: &std::ffi::OsStr) -> Vec<u16> {
s.encode_wide().chain(Some(0)).collect()
}
pub struct HiddenProcess {
desktop: HDESK,
info: PROCESS_INFORMATION,
}
impl HiddenProcess {
pub fn spawn(exe: &Path, args: &str) -> std::io::Result<Self> {
let name = format!("PinReady-vpx-ini-{}", std::process::id());
let mut desktop_name = wide(std::ffi::OsStr::new(&name));
let exe_w = wide(exe.as_os_str());
let mut command_line = wide(std::ffi::OsStr::new(&format!(
"\"{}\" {args}",
exe.display()
)));
unsafe {
let desktop = CreateDesktopW(
PCWSTR(desktop_name.as_ptr()),
PCWSTR::null(),
None,
DESKTOP_CONTROL_FLAGS(0),
GENERIC_ALL.0,
None,
)
.map_err(std::io::Error::other)?;
let startup = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
lpDesktop: PWSTR(desktop_name.as_mut_ptr()),
..Default::default()
};
let mut info = PROCESS_INFORMATION::default();
if let Err(e) = CreateProcessW(
PCWSTR(exe_w.as_ptr()),
Some(PWSTR(command_line.as_mut_ptr())),
None,
None,
false,
PROCESS_CREATION_FLAGS(0),
None,
PCWSTR::null(),
&startup,
&mut info,
) {
let _ = CloseDesktop(desktop);
return Err(std::io::Error::other(e));
}
Ok(Self { desktop, info })
}
}
}
impl super::Writer for HiddenProcess {
fn exited(&mut self) -> Option<String> {
unsafe {
if WaitForSingleObject(self.info.hProcess, 0) != WAIT_OBJECT_0 {
return None;
}
let mut code = 0u32;
let _ = GetExitCodeProcess(self.info.hProcess, &mut code);
Some(format!("exit code: {code}"))
}
}
fn stop(&mut self) {
unsafe {
if WaitForSingleObject(self.info.hProcess, 0) != WAIT_OBJECT_0 {
let _ = TerminateProcess(self.info.hProcess, 1);
let _ = WaitForSingleObject(self.info.hProcess, 5000);
}
}
}
}
impl Drop for HiddenProcess {
fn drop(&mut self) {
unsafe {
let _ = CloseHandle(self.info.hThread);
let _ = CloseHandle(self.info.hProcess);
let _ = CloseDesktop(self.desktop);
}
}
}
}
pub fn run(exe: &Path, ini: &Path) -> bool {
let exe_tag = crate::updater::query_vpx_version(&exe.to_string_lossy());
let saved_by = crate::config::VpxConfig::load(Some(ini))
.ok()
.and_then(|c| c.get("Version", "VPinball"));
if ini.is_file() && already_current(saved_by.as_deref(), exe_tag.as_deref()) {
log::info!(
"{} was last saved by this VPX build ({}): schema already current, not running `-h`",
ini.display(),
exe_tag.as_deref().unwrap_or_default()
);
return false;
}
log::info!(
"Running `{} -h` to (re)write {} with VPX's current schema (ini saved by {}, binary is {})",
exe.display(),
ini.display(),
saved_by.as_deref().unwrap_or("nothing"),
exe_tag.as_deref().unwrap_or("unknown")
);
let before = stamp(ini);
let mut writer = match spawn(exe) {
Ok(w) => w,
Err(e) => {
log::warn!("Could not launch VPX for ini rewrite: {e}");
return false;
}
};
let mut watch = RewriteWatch::new(before);
let deadline = Instant::now() + TIMEOUT;
loop {
if let Some(status) = writer.exited() {
log::info!("VPX ini rewrite: process exited ({status})");
break;
}
if watch.observe(stamp(ini)) {
log::info!("VPX ini rewrite: file written and settled, stopping VPX");
break;
}
if Instant::now() > deadline {
log::warn!(
"VPX ini rewrite timed out after {}s, stopping VPX",
TIMEOUT.as_secs()
);
break;
}
std::thread::sleep(POLL);
}
writer.stop();
let ok = ini.exists();
log::info!(
"VPX ini rewrite {}",
if ok { "succeeded" } else { "failed" }
);
ok
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_digits_follow_vpx_stamp() {
assert_eq!(
ini_version_digits("v10.8.1-5430-ac957dacb").as_deref(),
Some("10815430")
);
assert_eq!(
ini_version_digits("10.8.1-5436").as_deref(),
Some("10815436")
);
}
#[test]
fn version_digits_need_a_git_revision() {
assert_eq!(ini_version_digits("v10.8.1"), None);
assert_eq!(ini_version_digits(""), None);
assert_eq!(ini_version_digits("v10.8-5430-abc"), None);
assert_eq!(ini_version_digits("vX.8.1-5430"), None);
assert_eq!(ini_version_digits("v10.8.1-abc"), None);
}
#[test]
fn current_only_when_saved_by_the_same_build() {
let tag = Some("v10.8.1-5430-ac957dacb");
assert!(already_current(Some("10815430"), tag));
assert!(already_current(Some(" 10815430 "), tag));
assert!(!already_current(Some("10815414"), tag));
assert!(!already_current(Some(""), tag));
assert!(!already_current(None, tag));
assert!(!already_current(Some("10815430"), None));
assert!(!already_current(Some("10815430"), Some("v10.8.1")));
}
fn at(secs: u64, len: u64) -> Option<FileStamp> {
Some((SystemTime::UNIX_EPOCH + Duration::from_secs(secs), len))
}
#[test]
fn watch_waits_for_a_change_then_for_it_to_settle() {
let mut w = RewriteWatch::new(at(1, 100));
assert!(!w.observe(at(1, 100)), "untouched file");
assert!(!w.observe(at(2, 50)), "write in progress");
assert!(!w.observe(at(2, 90)), "still growing");
assert!(!w.observe(at(2, 90)), "one still poll is not enough");
assert!(w.observe(at(2, 90)), "settled");
}
#[test]
fn watch_sees_a_fresh_file_appear() {
let mut w = RewriteWatch::new(None);
assert!(!w.observe(None));
assert!(!w.observe(at(5, 4000)));
assert!(!w.observe(at(5, 4000)));
assert!(w.observe(at(5, 4000)));
}
#[test]
fn watch_ignores_a_file_that_vanishes_mid_run() {
let mut w = RewriteWatch::new(at(1, 100));
assert!(!w.observe(at(2, 100)));
assert!(!w.observe(None));
assert!(!w.observe(at(2, 100)));
assert!(!w.observe(at(2, 100)));
assert!(w.observe(at(2, 100)));
}
}