mod package_ui;
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::{env, fs};
const PLACEHOLDER_MARKER: &str = "hennery-web-ui-not-embedded";
fn main() {
println!("cargo::rerun-if-changed=build.rs");
println!("cargo::rerun-if-env-changed=HENNERY_WEB_DIST");
println!("cargo::rerun-if-env-changed=HENNERY_WEB_REQUIRE");
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"));
let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("cargo sets OUT_DIR"));
let require = match env::var("HENNERY_WEB_REQUIRE").as_deref() {
Err(_) | Ok("") | Ok("0") => false,
Ok("1") => true,
Ok(other) => panic!("HENNERY_WEB_REQUIRE must be 1, 0 or empty, not {other:?}"),
};
let web_dir = manifest_dir.join("../../web");
let marker = manifest_dir.join("release-ui.json");
println!("cargo::rerun-if-changed={}", manifest_dir.display());
let dist = match env::var_os("HENNERY_WEB_DIST").filter(|d| !d.is_empty()) {
Some(given) => {
let given = PathBuf::from(given);
assert!(
given.is_absolute(),
"HENNERY_WEB_DIST must be an absolute path, not {}",
given.display()
);
assert!(
given.join("index.html").is_file(),
"HENNERY_WEB_DIST ({}) holds no index.html: build the web UI first",
given.display()
);
Some(given)
}
None if marker.exists() || manifest_dir.join("Cargo.toml.orig").exists() => {
package_ui::validate(&manifest_dir).unwrap_or_else(|e| panic!("invalid packaged release UI: {e}"));
Some(manifest_dir.join("web-dist"))
}
None => Some(web_dir.join("dist")).filter(|d| d.join("index.html").is_file()),
};
match &dist {
Some(dist) => println!("cargo::rerun-if-changed={}", dist.display()),
None if web_dir.is_dir() => println!("cargo::rerun-if-changed={}", web_dir.display()),
None => {}
}
let mut table = String::new();
match &dist {
Some(dist) => {
let mut files = Vec::new();
collect(dist, dist, &mut files);
files.sort();
writeln!(table, "/// The real UI is embedded.\npub const EMBEDDED: bool = true;").unwrap();
writeln!(table, "static FILES: &[Asset] = &[").unwrap();
for (path, file) in &files {
entry(&mut table, path, &file.display().to_string(), &fs::read(file).unwrap());
}
writeln!(table, "];").unwrap();
}
None => {
assert!(
!require,
"HENNERY_WEB_REQUIRE=1, but there is no web build at {}: run `pnpm install && pnpm build` in web/",
web_dir.join("dist").display()
);
let page = out_dir.join("placeholder.html");
fs::write(&page, placeholder()).unwrap();
writeln!(
table,
"/// A placeholder is embedded, not the UI.\npub const EMBEDDED: bool = false;"
)
.unwrap();
writeln!(table, "static FILES: &[Asset] = &[").unwrap();
entry(
&mut table,
"/index.html",
&page.display().to_string(),
placeholder().as_bytes(),
);
writeln!(table, "];").unwrap();
}
}
fs::write(out_dir.join("web_assets.rs"), table).unwrap();
}
fn collect(root: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) {
for entry in fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
let name = path.file_name().unwrap().to_string_lossy().into_owned();
if name.starts_with('.') || name.ends_with(".map") {
continue;
}
if path.is_dir() {
collect(root, &path, out);
} else {
let relative = path.strip_prefix(root).unwrap();
let url: Vec<String> = relative
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
out.push((format!("/{}", url.join("/")), path));
}
}
}
fn entry(table: &mut String, url: &str, file: &str, bytes: &[u8]) {
let digest = Sha256::digest(bytes);
let etag = format!("\"{}\"", hex(&digest[..8]));
writeln!(
table,
" Asset {{ path: {url:?}, body: include_bytes!({file:?}), content_type: {:?}, etag: {etag:?} }},",
content_type(url)
)
.unwrap();
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn content_type(path: &str) -> &'static str {
match path.rsplit_once('.').map(|(_, ext)| ext) {
Some("html") => "text/html; charset=utf-8",
Some("js" | "mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("json") => "application/json",
Some("webmanifest") => "application/manifest+json",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
Some("txt") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn placeholder() -> String {
format!(
r#"<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="{PLACEHOLDER_MARKER}">
<title>hennery: no web UI in this build</title>
<h1>This build of hennery has no web UI</h1>
<p>It was built without the web UI. The API works, so hennery can still be set up:</p>
<ol>
<li>Print the one-time setup link: <code>hennery admin setup-url</code>. The token is the part after <code>#</code>.</li>
<li>Send it with the owner's password and this page's address as the public URL:
The JSON goes on stdin, so the password stays out of <code>ps</code>, and the quoted <code>'EOF'</code> keeps the shell from expanding it. Write the public URL into it yourself, and escape <code>"</code> and <code>\</code> as JSON does:
<pre>curl -X POST "$PUBLIC_URL/api/setup" -H "Origin: $PUBLIC_URL" -H "Content-Type: application/json" -d @- <<'EOF'
{{"token": "…", "password": "…", "public_url": "https://…", "default_hat_name": "Personal"}}
EOF</pre></li>
</ol>
<p>For the web UI, build it (<code>pnpm install && pnpm build</code> in <code>web/</code>) and rebuild hennery.</p>
</html>
"#
)
}