use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, SystemTime};
const MAX_WATCH_DEPTH: u32 = 32;
const CLIENT_SOURCES: &[&str] = &[
"lib",
"web",
"assets",
"fonts",
"pubspec.yaml",
"pubspec.lock",
];
fn main() {
println!("cargo:rustc-check-cfg=cfg(embed_real_client)");
println!("cargo:rustc-check-cfg=cfg(embed_packaged_client)");
println!("cargo:rerun-if-env-changed=TRIAGE_SKIP_FLUTTER_BUILD");
println!("cargo:rerun-if-changed=.");
if Path::new("dist").exists() {
println!("cargo:rerun-if-changed=dist");
}
let packaged_client_path = Path::new("dist/index.html");
let client_dir = Path::new("../../flutter/triage_client");
let dev_client_path = client_dir.join("build/web/index.html");
if packaged_client_path.exists() {
println!("cargo:rustc-cfg=embed_packaged_client");
return;
}
ensure_dev_client(client_dir, &dev_client_path);
if dev_client_path.exists() {
println!("cargo:rerun-if-changed={}", dev_client_path.display());
println!("cargo:rustc-cfg=embed_real_client");
} else {
warn(
"no Flutter web bundle found; embedding the placeholder client from web_fallback/. \
Run `flutter build web --release` in flutter/triage_client to get the real UI.",
);
}
}
fn ensure_dev_client(client_dir: &Path, dev_client_path: &Path) {
let mut newest_source: Option<SystemTime> = None;
for entry in CLIENT_SOURCES {
let path = client_dir.join(entry);
watch(&path, &mut newest_source, MAX_WATCH_DEPTH);
}
let Some(newest_source) = newest_source else {
return;
};
if std::env::var_os("TRIAGE_SKIP_FLUTTER_BUILD")
.is_some_and(|value| !matches!(value.to_str(), None | Some("") | Some("0") | Some("false")))
{
return;
}
if bundle_is_current(client_dir, dev_client_path, newest_source) {
return;
}
let Some(flutter) = flutter_command() else {
let reason = staleness_reason(dev_client_path);
panic!(
"Flutter web bundle is {reason} and the `flutter` command was not found on PATH. \
Install the Flutter SDK or add it to PATH so the real client is built. To build \
without it on purpose, set TRIAGE_SKIP_FLUTTER_BUILD=1 — that embeds the existing \
bundle, or the web_fallback/ placeholder when there is none."
);
};
let _lock = BuildLock::acquire(&client_dir.join("build/.triage-flutter-build.lock"));
if bundle_is_current(client_dir, dev_client_path, newest_source) {
return;
}
let reason = staleness_reason(dev_client_path);
println!(
"cargo:warning=Flutter web bundle is {reason}; running `flutter build web --release` (this can take a minute)"
);
let status = Command::new(flutter)
.args(["build", "web", "--release"])
.current_dir(client_dir)
.status();
match status {
Ok(status) if status.success() => {
let stamp = build_stamp_path(client_dir);
if let Err(err) = fs::write(&stamp, b"") {
warn(&format!(
"could not write the build stamp {}: {err}. The client will rebuild on \
every cargo build until this is fixed.",
stamp.display()
));
}
}
Ok(status) => panic!(
"`flutter build web --release` failed with {status}. Fix the client build, or set \
TRIAGE_SKIP_FLUTTER_BUILD=1 to build the daemon against the existing bundle."
),
Err(err) => panic!(
"could not run `flutter build web --release`: {err}. Set \
TRIAGE_SKIP_FLUTTER_BUILD=1 to build the daemon against the existing bundle."
),
}
}
fn staleness_reason(dev_client_path: &Path) -> &'static str {
if dev_client_path.exists() {
"out of date"
} else {
"missing"
}
}
fn bundle_is_current(client_dir: &Path, dev_client_path: &Path, newest_source: SystemTime) -> bool {
dev_client_path.exists()
&& build_stamp_path(client_dir)
.metadata()
.and_then(|meta| meta.modified())
.is_ok_and(|built_at| built_at > newest_source)
}
fn build_stamp_path(client_dir: &Path) -> PathBuf {
client_dir.join("build/.triage-client-stamp")
}
struct BuildLock {
path: PathBuf,
token: String,
stop: Arc<AtomicBool>,
heartbeat: Option<thread::JoinHandle<()>>,
}
impl BuildLock {
const TICK: Duration = Duration::from_millis(250);
const MAX_WAIT: Duration = Duration::from_secs(15 * 60);
const HEARTBEAT: Duration = Duration::from_secs(30);
const STALE_AFTER: Duration = Duration::from_secs(2 * 60);
fn acquire(path: &Path) -> Option<Self> {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let mut waited = Duration::ZERO;
loop {
match fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(path)
{
Ok(mut file) => {
let token = format!(
"{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|since| since.as_nanos())
.unwrap_or_default()
);
let _ = file.write_all(token.as_bytes());
return Some(Self::holding(path, token));
}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
Err(_) => return None,
}
let idle_for = path
.metadata()
.and_then(|meta| meta.modified())
.ok()
.and_then(|touched_at| SystemTime::now().duration_since(touched_at).ok());
if idle_for.is_some_and(|idle| idle > Self::STALE_AFTER) {
warn(&format!(
"reclaiming the Flutter build lock {} — no heartbeat for over {}s, so the \
process holding it is gone.",
path.display(),
Self::STALE_AFTER.as_secs()
));
let _ = fs::remove_file(path);
continue;
}
if waited.is_zero() {
warn(&format!(
"waiting for another Flutter build to finish (lock: {}). Delete that file if \
no build is running.",
path.display()
));
}
if waited >= Self::MAX_WAIT {
warn("gave up waiting for the Flutter build lock; building unserialized.");
return None;
}
thread::sleep(Self::TICK);
waited += Self::TICK;
}
}
fn holding(path: &Path, token: String) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let beat_path = path.to_path_buf();
let beat_stop = Arc::clone(&stop);
let beat_token = token.clone();
let heartbeat = thread::spawn(move || {
let mut since_touch = Duration::ZERO;
while !beat_stop.load(Ordering::Relaxed) {
thread::sleep(Self::TICK);
since_touch += Self::TICK;
if since_touch < Self::HEARTBEAT {
continue;
}
since_touch = Duration::ZERO;
if !Self::owns(&beat_path, &beat_token) {
break;
}
if let Ok(file) = fs::OpenOptions::new().write(true).open(&beat_path) {
let _ = file.set_modified(SystemTime::now());
}
}
});
Self {
path: path.to_path_buf(),
token,
stop,
heartbeat: Some(heartbeat),
}
}
fn owns(path: &Path, token: &str) -> bool {
fs::read_to_string(path).is_ok_and(|content| content == token)
}
}
impl Drop for BuildLock {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(heartbeat) = self.heartbeat.take() {
let _ = heartbeat.join();
}
if Self::owns(&self.path, &self.token) {
let _ = fs::remove_file(&self.path);
}
}
}
fn watch(path: &Path, newest: &mut Option<SystemTime>, depth: u32) {
let Ok(metadata) = path.metadata() else {
return;
};
println!("cargo:rerun-if-changed={}", path.display());
if let Ok(modified) = metadata.modified()
&& newest.is_none_or(|current| modified > current)
{
*newest = Some(modified);
}
if !metadata.is_dir() || depth == 0 {
return;
}
let Ok(entries) = path.read_dir() else {
return;
};
for entry in entries.flatten() {
watch(&entry.path(), newest, depth - 1);
}
}
fn flutter_command() -> Option<&'static str> {
let candidates: &[&str] = if cfg!(windows) {
&["flutter.bat", "flutter.exe"]
} else {
&["flutter"]
};
candidates.iter().copied().find(|name| which(name))
}
fn which(name: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| is_executable(&dir.join(name)))
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
path.metadata()
.is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
path.is_file()
}
fn warn(message: &str) {
println!("cargo:warning={message}");
}