#[cfg(desktop)]
use std::path::Path;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use web_time::Instant;
const WATCH_ENV: &str = "FRUS_WATCH";
const STATE_ENV: &str = "FRUS_HOT_STATE";
const POLL_INTERVAL: Duration = Duration::from_millis(700);
pub(crate) struct ReloadWatcher {
exe: PathBuf,
mtime: SystemTime,
next_poll: Instant,
}
impl ReloadWatcher {
pub(crate) fn new() -> Option<Self> {
if !cfg!(debug_assertions) || std::env::var_os(WATCH_ENV).is_none_or(|v| v != "1") {
return None;
}
let exe = std::env::current_exe().ok()?;
let mtime = std::fs::metadata(&exe).ok()?.modified().ok()?;
Some(Self {
exe,
mtime,
next_poll: Instant::now() + POLL_INTERVAL,
})
}
pub(crate) fn deadline(&self) -> Instant {
self.next_poll
}
pub(crate) fn binary_changed(&mut self) -> bool {
let now = Instant::now();
if now < self.next_poll {
return false;
}
self.next_poll = now + POLL_INTERVAL;
match std::fs::metadata(&self.exe).and_then(|m| m.modified()) {
Ok(mtime) if mtime != self.mtime => {
self.mtime = mtime;
true
}
_ => false,
}
}
pub(crate) fn handoff(&self, state: Option<Vec<u8>>) -> ! {
let mut command = std::process::Command::new(&self.exe);
if let Some(bytes) = state {
let path = state_file_path();
if std::fs::write(&path, bytes).is_ok() {
command.env(STATE_ENV, &path);
}
}
match command.spawn() {
Ok(_) => log::info!(
target: "frus::reload",
"binary recompiled: relaunching ({})",
self.exe.display()
),
Err(err) => log::error!(target: "frus::reload", "cannot relaunch: {err}"),
}
std::process::exit(0);
}
}
fn state_file_path() -> PathBuf {
std::env::temp_dir().join(format!("frus-hot-{}.state", std::process::id()))
}
#[cfg(desktop)]
pub(crate) fn restore_from_env<A: crate::Application>(app: &mut A) {
let Some(path) = std::env::var_os(STATE_ENV) else {
return;
};
let path = Path::new(&path);
if let Ok(bytes) = std::fs::read(path) {
app.restore_state(&bytes);
log::info!(target: "frus::reload", "state rehydrated ({} bytes)", bytes.len());
}
let _ = std::fs::remove_file(path);
}
#[cfg(test)]
mod tests {
use super::*;
use frus_widgets::{Theme, Widget};
struct Probe {
restored: Option<Vec<u8>>,
}
impl crate::Application for Probe {
type Message = ();
fn update(&mut self, _: ()) -> crate::Command<()> {
crate::Command::none()
}
fn view(&self, _: &Theme) -> Box<dyn Widget<()>> {
unimplemented!("never rendered in this test")
}
fn restore_state(&mut self, bytes: &[u8]) {
self.restored = Some(bytes.to_vec());
}
}
#[test]
fn restore_reads_and_consumes_the_snapshot() {
let path = std::env::temp_dir().join("frus-hot-test.state");
std::fs::write(&path, b"snapshot").unwrap();
std::env::set_var(STATE_ENV, &path);
let mut app = Probe { restored: None };
restore_from_env(&mut app);
std::env::remove_var(STATE_ENV);
assert_eq!(app.restored.as_deref(), Some(&b"snapshot"[..]));
assert!(!path.exists(), "the snapshot is consumed");
let mut fresh = Probe { restored: None };
restore_from_env(&mut fresh);
assert!(fresh.restored.is_none(), "no variable: a fresh start");
}
#[test]
fn watcher_requires_opt_in_and_tracks_mtime() {
std::env::remove_var(WATCH_ENV);
assert!(ReloadWatcher::new().is_none(), "opting in is mandatory");
std::env::set_var(WATCH_ENV, "1");
let watcher = ReloadWatcher::new();
std::env::remove_var(WATCH_ENV);
if !cfg!(debug_assertions) {
assert!(watcher.is_none(), "a release build never watches itself");
return;
}
let Some(mut watcher) = watcher else {
panic!("a watcher was expected in debug under FRUS_WATCH=1");
};
assert!(!watcher.binary_changed());
watcher.next_poll = Instant::now() - Duration::from_millis(1);
assert!(!watcher.binary_changed(), "mtime unchanged → no relaunch");
}
}