use std::io::Write as _;
use std::path::{Path, PathBuf};
const BUNDLE_DIR: &str = "web-dist";
const PINS: [(&str, &str); 3] = [
("RUST_TOOLCHAIN_FILE", "rust-toolchain.toml"),
("LEAN_TOOLCHAIN_FILE", "lean-toolchain"),
("ZIG_VERSION_FILE", "crates/desktop/ffi/zig-version"),
];
const PLACEHOLDER: &str = r#"<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8"><title>sprawling</title></head>
<body><p>This binary was built without the client bundle. Run <code>just build-web</code> and rebuild.</p></body></html>
"#;
fn main() {
println!("cargo::rerun-if-env-changed=SPRAWLING_RELEASE_TAG");
if let Err(msg) = embed() {
println!("cargo::error=binary resource generation failed: {msg}");
}
}
fn embed() -> Result<(), String> {
if std::env::var("CARGO_CFG_TARGET_OS")
.map_err(|err| format!("read resource target OS: {err}"))?
== "windows"
{
winresource::WindowsResource::new()
.compile()
.map_err(|err| {
format!(
"compile Windows product resources: {err}; check the target resource toolchain"
)
})?;
}
let manifest = std::env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?;
let out_dir = std::env::var("OUT_DIR").map_err(|e| e.to_string())?;
let dist = BUNDLE_DIR
.split('/')
.fold(PathBuf::from(&manifest), |at, segment| at.join(segment));
println!("cargo::rerun-if-changed={}", dist.display());
let mut files: Vec<(String, PathBuf)> = Vec::new();
let assets = dist.join("assets");
let built = dist.join("index.html").is_file()
&& std::fs::read_dir(&assets).is_ok_and(|mut held| held.next().is_some());
let complete = built;
if built {
collect(&dist, &dist, &mut files)?;
} else {
println!(
"cargo::warning=client bundle not found in {}; the binary will carry a placeholder \
page only - run `just build-web`, then rebuild",
dist.display()
);
let placeholder = PathBuf::from(&out_dir).join("placeholder.html");
std::fs::write(&placeholder, PLACEHOLDER).map_err(|err| err.to_string())?;
files.push(("index.html".to_owned(), placeholder));
}
files.sort_by(|a, b| a.0.cmp(&b.0));
let web_out = PathBuf::from(&out_dir).join("web");
let mut table = String::new();
for (rel, source) in &files {
println!("cargo::rerun-if-changed={}", source.display());
let bytes = std::fs::read(source).map_err(|e| format!("{}: {e}", source.display()))?;
let gz_path = web_out.join(format!("{}.gz", rel.replace('/', "_")));
if let Some(parent) = gz_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let gz = gzip(&bytes)?;
std::fs::write(&gz_path, gz).map_err(|e| format!("{}: {e}", gz_path.display()))?;
table.push_str(&format!(
" wire::EmbeddedFile {{ path: {rel:?}, gz: include_bytes!({:?}) }},\n",
gz_path.display().to_string().replace('\\', "/")
));
}
let root = checkout_root(Path::new(&manifest))?;
embed_dependency_list(&root, &out_dir)?;
embed_pins(&root, &out_dir)?;
let generated = format!(
"// Generated by build.rs; the authority is the file table it read.\n\
/// Whether the wasm client is inside this binary, or only the shell.\n\
pub const CLIENT_COMPLETE: bool = {complete};\n\
/// Where `just build-web` writes the client, relative to the sprawling package.\n\
pub const CLIENT_BUNDLE_DIR: &str = {BUNDLE_DIR:?};\n\
/// Every file the browser may request, gzipped at build time.\n\
pub static CLIENT_FILES: &[wire::EmbeddedFile] = &[\n{table}];\n"
);
std::fs::write(PathBuf::from(&out_dir).join("client_embed.rs"), generated)
.map_err(|e| e.to_string())?;
Ok(())
}
fn checkout_root(manifest: &Path) -> Result<PathBuf, String> {
manifest
.ancestors()
.find(|dir| dir.join("Cargo.lock").is_file())
.map(Path::to_path_buf)
.ok_or_else(|| {
format!(
"no Cargo.lock in {} or any directory above it; a build that cannot name \
what it is made of is refused",
manifest.display()
)
})
}
fn embed_dependency_list(root: &Path, out_dir: &str) -> Result<(), String> {
let lock = root.join("Cargo.lock");
println!("cargo::rerun-if-changed={}", lock.display());
let text = std::fs::read_to_string(&lock).map_err(|e| format!("{}: {e}", lock.display()))?;
let mut deps = Vec::new();
let mut name: Option<String> = None;
for line in text.lines() {
if line == "[[package]]" {
name = None;
} else if let Some(raw) = line.strip_prefix("name = \"") {
name = raw.strip_suffix('"').map(str::to_owned);
} else if let Some(raw) = line.strip_prefix("version = \"")
&& let (Some(package), Some(version)) = (name.take(), raw.strip_suffix('"'))
{
deps.push(format!("{package} {version}"));
}
}
deps.sort();
std::fs::write(
PathBuf::from(out_dir).join("deps.txt"),
format!("{}\n", deps.join("\n")),
)
.map_err(|e| e.to_string())
}
fn embed_pins(root: &Path, out_dir: &str) -> Result<(), String> {
let mut generated = String::from(
"// Generated by build.rs from the pin files beside the lockfile; an empty string is a \
file this build did not find.\n",
);
for (name, rel) in PINS {
let path = rel
.split('/')
.fold(root.to_path_buf(), |at, segment| at.join(segment));
println!("cargo::rerun-if-changed={}", path.display());
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(format!("{}: {err}", path.display())),
};
generated.push_str(&format!("pub(crate) const {name}: &str = {text:?};\n"));
}
std::fs::write(PathBuf::from(out_dir).join("pins.rs"), generated).map_err(|e| e.to_string())
}
fn collect(root: &Path, dir: &Path, files: &mut Vec<(String, PathBuf)>) -> Result<(), String> {
let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_dir() {
collect(root, &path, files)?;
continue;
}
let rel = path
.strip_prefix(root)
.map_err(|e| e.to_string())?
.to_string_lossy()
.replace('\\', "/");
files.push((rel, path));
}
Ok(())
}
fn gzip(bytes: &[u8]) -> Result<Vec<u8>, String> {
let mut encoder = flate2::GzBuilder::new()
.mtime(0)
.write(Vec::new(), flate2::Compression::best());
encoder.write_all(bytes).map_err(|e| e.to_string())?;
encoder.finish().map_err(|e| e.to_string())
}