build.rs
Rama main · réplica de lectura del repositorio original.
// build.rs — runs at compile time to locate platform-specific native .node binaries
// and expose their paths to the Rust source via environment variables.
//
// This is only meaningful when the `embed-web` feature is enabled.
// When disabled the script exits immediately so it doesn't slow down dev builds.
use std::path::PathBuf;
fn main() {
// 1. Embed Windows executable icon (.ico) into .exe binary header
#[cfg(target_os = "windows")]
{
let mut res = winres::WindowsResource::new();
res.set_icon("assets/icon.ico");
res.set("ProductName", "XianScan");
res.set("FileDescription", "XianScan Native Comic Translation Server");
res.set("LegalCopyright", "Copyright (c) 2026 XianScan");
if let Err(e) = res.compile() {
println!("cargo:warning=Failed to compile Windows resource icon: {}", e);
}
}
println!("cargo:rerun-if-changed=assets/icon.ico");
// 2. EMBEDDED MODELS MUST BE THE PINNED ONES (FEAT-008 PHASE 3)
if std::env::var("CARGO_FEATURE_EMBED_MODELS").is_ok() {
verify_model_manifest();
}
// Only do discovery work when embed-web is requested.
if std::env::var("CARGO_FEATURE_EMBED_WEB").is_err() {
return;
}
// Emit a unique build timestamp so the VERSION stamp changes every compile.
// Without this, every build writes "0.1.0" and stale extractions are never refreshed.
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
println!("cargo:rustc-env=BUILD_TIMESTAMP={}", ts);
// Always rerun — we want a fresh stamp on every cargo build.
println!("cargo:rerun-if-changed=build.rs");
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let node_modules = manifest_dir.join("web").join("node_modules");
// -----------------------------------------------------------------------
// 1. better-sqlite3
// -----------------------------------------------------------------------
let bs3_node = node_modules
.join("better-sqlite3")
.join("build")
.join("Release")
.join("better_sqlite3.node");
if bs3_node.exists() {
// Canonicalize to an absolute path so include_bytes! is unambiguous.
// On Windows, canonicalize() returns \\?\C:\... UNC extended paths which
// include_bytes! rejects — strip the prefix to get a plain C:\... path.
let abs = bs3_node.canonicalize().unwrap_or(bs3_node.clone());
let abs_str = strip_unc_prefix(abs.display().to_string());
println!("cargo:rustc-env=BETTER_SQLITE3_NODE_PATH={}", abs_str);
println!("cargo:rerun-if-changed={}", abs_str);
} else {
// Emit a non-fatal warning; the compile will still fail at include_bytes!
// which gives a clear error message.
println!(
"cargo:warning=better-sqlite3 .node not found at {}. Run `yarn install` in web/.",
bs3_node.display()
);
// Emit a dummy value so the env! macro in web_assets.rs doesn't cause a
// "env var not set" panic during the macro expansion phase — the
// missing file error from include_bytes! is clearer.
println!("cargo:rustc-env=BETTER_SQLITE3_NODE_PATH=MISSING_BETTER_SQLITE3");
}
// -----------------------------------------------------------------------
// 2. @napi-rs/canvas (Skia)
// -----------------------------------------------------------------------
// package manager installs exactly one platform-specific subpackage, e.g.:
// @napi-rs/canvas-win32-x64-msvc/ (Windows x64)
// @napi-rs/canvas-linux-x64-gnu/ (Linux x64 glibc)
// @napi-rs/canvas-darwin-universal/ (macOS universal)
// @napi-rs/canvas-darwin-arm64/ (macOS ARM64)
//
// We scan for any `canvas-*` directory containing a `skia.*.node` file.
// The filename tells js-binding.js which require() call will succeed.
let napi_dir = node_modules.join("@napi-rs");
// TARGET CFG VARS + A SHARED SCORER USED BY BOTH THE SKIA AND IMAGE ADDON
// SELECTION BELOW, SO THE CORRECT PLATFORM BINDING IS CHOSEN (GLIBC VS MUSL,
// OS, ARCH) INSTEAD OF THE FIRST DIRECTORY FOUND.
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default();
let match_score = |dir_name: &str| -> i32 {
let lower = dir_name.to_lowercase();
let mut s = 0;
if !target_env.is_empty() && lower.contains(&target_env) {
s += 3;
}
if target_arch == "aarch64" && lower.contains("arm64") {
s += 2;
}
if target_arch == "x86_64" && lower.contains("x64") {
s += 2;
}
// APPLE PACKAGES ARE NAMED `*-darwin-*` WHILE CARGO'S OS TOKEN IS `macos`.
let os_token = if target_os == "macos" { "darwin" } else { &target_os };
if !os_token.is_empty() && lower.contains(os_token) {
s += 1;
}
s
};
let mut skia_candidates: Vec<(String, PathBuf, String, Option<PathBuf>)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&napi_dir) {
for entry in entries.flatten() {
let dir_name = entry.file_name();
let dir_name_str = dir_name.to_string_lossy().to_string();
if !dir_name_str.starts_with("canvas-") {
continue;
}
let mut skia_path: Option<PathBuf> = None;
let mut skia_filename: Option<String> = None;
let mut icu_path: Option<PathBuf> = None;
if let Ok(files) = std::fs::read_dir(entry.path()) {
for file in files.flatten() {
let fname = file.file_name();
let fname_str = fname.to_string_lossy();
if fname_str.starts_with("skia.") && fname_str.ends_with(".node") {
skia_path = Some(file.path());
skia_filename = Some(fname_str.to_string());
} else if fname_str == "icudtl.dat" {
icu_path = Some(file.path());
}
}
}
if let (Some(path), Some(filename)) = (skia_path, skia_filename) {
skia_candidates.push((dir_name_str, path, filename, icu_path));
}
}
}
// PICK THE SKIA BINDING THAT MATCHES THE BUILD TARGET. BOTH GNU AND MUSL
// SUBPACKAGES GET INSTALLED ON LINUX, AND THE MUSL .node DOES NOT LOAD UNDER
// GLIBC — GlobalFonts ENDS UP UNDEFINED AND TYPESETTING FAILS AT RUNTIME.
let best_skia = skia_candidates
.iter()
.max_by_key(|(dir_name, _, _, _)| match_score(dir_name));
let mut skia_path: Option<PathBuf> = None;
let mut skia_filename: Option<String> = None;
let mut icu_path: Option<PathBuf> = None;
if let Some((_, path, filename, icu)) = best_skia {
skia_path = Some(path.clone());
skia_filename = Some(filename.clone());
icu_path = icu.clone();
}
if let (Some(path), Some(filename)) = (skia_path, skia_filename) {
let abs = path.canonicalize().unwrap_or(path.clone());
let abs_str = strip_unc_prefix(abs.display().to_string());
println!("cargo:rustc-env=SKIA_NODE_PATH={}", abs_str);
println!("cargo:rustc-env=SKIA_NODE_FILENAME={}", filename);
println!("cargo:rerun-if-changed={}", abs_str);
} else {
println!(
"cargo:warning=@napi-rs/canvas skia .node not found under {}. Run `yarn install` in web/.",
napi_dir.display()
);
println!("cargo:rustc-env=SKIA_NODE_PATH=MISSING_SKIA_NODE");
println!("cargo:rustc-env=SKIA_NODE_FILENAME=skia.missing.node");
}
// -----------------------------------------------------------------------
// 2b. @napi-rs/image (AVIF / HEIC -> WebP conversion for uploads)
// -----------------------------------------------------------------------
// The @napi-rs/image package ships its native binding in a platform-scoped
// subpackage (e.g. @napi-rs/image-win32-x64-msvc). We scan for any `image-*`
// directory containing an `image.*.node` file and embed it so the standalone
// binary can convert AVIF/HEIC uploads to WebP at runtime (without it, only
// the @napi-rs/canvas fallback remains, and some AVIF variants cannot be
// decoded, leaving raw .avif files that the Rust ML sidecar then rejects).
let mut image_candidates: Vec<(String, PathBuf, String)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&napi_dir) {
for entry in entries.flatten() {
let dir_name = entry.file_name();
let dir_name_str = dir_name.to_string_lossy().to_string();
if !dir_name_str.starts_with("image-") {
continue;
}
if let Ok(files) = std::fs::read_dir(entry.path()) {
for file in files.flatten() {
let fname = file.file_name();
let fname_str = fname.to_string_lossy();
if fname_str.starts_with("image.") && fname_str.ends_with(".node") {
image_candidates.push((dir_name_str.clone(), file.path(), fname_str.to_string()));
break;
}
}
}
}
}
// PICK THE BINDING THAT MATCHES THE BUILD TARGET — SEE THE SKIA SELECTION
// ABOVE FOR THE SAME GNU / MUSL PROBLEM AND THE SHARED match_score RULES.
let best_image = image_candidates
.iter()
.max_by_key(|(dir_name, _, _)| match_score(dir_name));
if let Some((_, path, filename)) = best_image {
let abs = path.canonicalize().unwrap_or(path.clone());
let abs_str = strip_unc_prefix(abs.display().to_string());
println!("cargo:rustc-env=IMAGE_NODE_PATH={}", abs_str);
println!("cargo:rustc-env=IMAGE_NODE_FILENAME={}", filename);
println!("cargo:rerun-if-changed={}", abs_str);
} else {
// WRITE AN EMPTY PLACEHOLDER INTO OUT_DIR RATHER THAN POINTING AT A
// NON-EXISTENT PATH: include_bytes! IN web_assets.rs REQUIRES A REAL FILE.
// THE RUNTIME GUARD (`if !IMAGE_NODE.is_empty()`) THEN SKIPS EXTRACTION AND
// AVIF/HEIC CONVERSION GRACEFULLY FALLS BACK TO @napi-rs/canvas.
let out_dir = std::env::var("OUT_DIR").unwrap_or_else(|_| ".".to_string());
let placeholder = std::path::Path::new(&out_dir).join("image.missing.node");
let _ = std::fs::write(&placeholder, b"");
println!(
"cargo:warning=@napi-rs/image native .node not found under {}. AVIF/HEIC upload conversion will rely on @napi-rs/canvas.",
napi_dir.display()
);
println!("cargo:rustc-env=IMAGE_NODE_PATH={}", placeholder.display());
println!("cargo:rustc-env=IMAGE_NODE_FILENAME=image.missing.node");
}
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap_or_else(|_| ".".to_string()));
let empty_icu_path = out_dir.join("empty_icudtl.dat");
if !empty_icu_path.exists() {
let _ = std::fs::write(&empty_icu_path, b"");
}
if let Some(path) = icu_path {
let abs = path.canonicalize().unwrap_or(path.clone());
let abs_str = strip_unc_prefix(abs.display().to_string());
println!("cargo:rustc-env=SKIA_ICU_PATH={}", abs_str);
println!("cargo:rerun-if-changed={}", abs_str);
} else {
let abs = empty_icu_path.canonicalize().unwrap_or(empty_icu_path);
let abs_str = strip_unc_prefix(abs.display().to_string());
println!("cargo:rustc-env=SKIA_ICU_PATH={}", abs_str);
}
// -----------------------------------------------------------------------
// 3. Standalone Node.js Runtime Binary (Optional Bundling)
// -----------------------------------------------------------------------
let web_bin_dir = manifest_dir.join("web").join("bin");
let node_bin_candidates = [
web_bin_dir.join("node.exe"),
web_bin_dir.join("node"),
];
let mut node_bin_path: Option<PathBuf> = None;
for candidate in &node_bin_candidates {
if candidate.exists() {
node_bin_path = Some(candidate.clone());
break;
}
}
let empty_node_path = out_dir.join("empty_node_bin");
if !empty_node_path.exists() {
let _ = std::fs::write(&empty_node_path, b"");
}
if let Some(path) = node_bin_path {
let abs = path.canonicalize().unwrap_or(path.clone());
let abs_str = strip_unc_prefix(abs.display().to_string());
println!("cargo:rustc-env=NODE_BIN_PATH={}", abs_str);
println!("cargo:rerun-if-changed={}", abs_str);
} else {
let abs = empty_node_path.canonicalize().unwrap_or(empty_node_path);
let abs_str = strip_unc_prefix(abs.display().to_string());
println!("cargo:rustc-env=NODE_BIN_PATH={}", abs_str);
}
// -----------------------------------------------------------------------
// 4. Compute Web Build Fingerprint & Versioning
// -----------------------------------------------------------------------
let web_dir = manifest_dir.join("web");
let web_build_dir = web_dir.join("build");
let (web_build_hash, web_build_time) = if web_build_dir.exists() {
let fingerprint = compute_dir_fingerprint(&web_build_dir);
let hash_str = format!("{:012x}", fingerprint);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
(hash_str, now.to_string())
} else {
("unbuilt".to_string(), "0".to_string())
};
println!("cargo:rustc-env=WEB_BUILD_HASH={}", web_build_hash);
println!("cargo:rustc-env=WEB_BUILD_TIME={}", web_build_time);
// -----------------------------------------------------------------------
// 5. RERUN TRIGGERS (FRONTEND SOURCE, CONFIGS, ASSETS, AND OUTPUTS)
// -----------------------------------------------------------------------
rerun_if_dir_changed(&node_modules.join("better-sqlite3"));
rerun_if_dir_changed(&node_modules.join("@napi-rs"));
rerun_if_dir_changed(&web_bin_dir);
rerun_if_dir_changed(&web_dir.join("src"));
rerun_if_dir_changed(&web_dir.join("static"));
rerun_if_dir_changed(&web_dir.join("drizzle"));
rerun_if_dir_changed(&web_build_dir);
println!("cargo:rerun-if-changed={}", web_dir.join("package.json").display());
println!("cargo:rerun-if-changed={}", web_dir.join("yarn.lock").display());
println!("cargo:rerun-if-changed={}", web_dir.join("vite.config.ts").display());
println!("cargo:rerun-if-changed={}", web_dir.join("svelte.config.js").display());
}
/// COMPUTES FAST 64-BIT FNV-1A FINGERPRINT OF A DIRECTORY'S FILES (NAMES, SIZES, AND BYTES)
fn compute_dir_fingerprint(dir: &std::path::Path) -> u64 {
let mut hash = 0xcbf29ce484222325u64;
let prime = 0x100000001b3u64;
let mut files = Vec::new();
collect_files(dir, dir, &mut files);
files.sort();
for (rel_path, full_path) in files {
for b in rel_path.as_bytes() {
hash ^= *b as u64;
hash = hash.wrapping_mul(prime);
}
if let Ok(metadata) = std::fs::metadata(&full_path) {
for b in metadata.len().to_le_bytes() {
hash ^= b as u64;
hash = hash.wrapping_mul(prime);
}
}
if let Ok(bytes) = std::fs::read(&full_path) {
// SAMPLE FIRST AND LAST 256 BYTES FOR SPEED & COMPACTNESS
let sample_len = bytes.len().min(256);
for b in &bytes[..sample_len] {
hash ^= *b as u64;
hash = hash.wrapping_mul(prime);
}
if bytes.len() > 256 {
let tail = &bytes[bytes.len() - 256..];
for b in tail {
hash ^= *b as u64;
hash = hash.wrapping_mul(prime);
}
}
}
}
hash
}
/// RECURSIVELY COLLECTS ALL RELATIVE AND ABSOLUTE PATHS OF FILES IN A DIRECTORY
fn collect_files(root: &std::path::Path, current: &std::path::Path, out: &mut Vec<(String, PathBuf)>) {
if let Ok(entries) = std::fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files(root, &path, out);
} else if let Ok(rel) = path.strip_prefix(root) {
out.push((rel.to_string_lossy().replace('\\', "/"), path));
}
}
}
}
/// Recursively emit `cargo:rerun-if-changed` for all files in a directory so
/// cargo rebuilds whenever frontend assets or compiled bundles change.
fn rerun_if_dir_changed(dir: &std::path::Path) {
if !dir.exists() {
return;
}
println!("cargo:rerun-if-changed={}", dir.display());
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
rerun_if_dir_changed(&path);
} else {
println!("cargo:rerun-if-changed={}", path.display());
}
}
}
}
/// Strip the Windows extended-length path prefix (`\\?\`) that
/// `std::fs::canonicalize` emits on Windows. `include_bytes!` and
/// `include_str!` do not accept UNC extended paths.
fn strip_unc_prefix(s: String) -> String {
s.strip_prefix(r"\\?\")
.map(|stripped| stripped.to_string())
.unwrap_or(s)
}
/// CHECKS EVERY MODEL LISTED IN models/manifest.tsv (SIZE AND SHA-256) BEFORE IT IS EMBEDDED. A MISSING OR DIFFERENT
/// FILE FAILS THE BUILD (NEVER THE SHIPPED BINARY). XIANSCAN_SKIP_MODEL_HASH=1 SKIPS IT WITH A WARNING FOR LOCAL
/// EXPERIMENTS; CI NEVER SETS IT. FETCH THE MODELS WITH scripts/fetch-models.sh.
fn verify_model_manifest() {
use sha2::{Digest, Sha256};
use std::io::Read;
let root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let manifest = root.join("models").join("manifest.tsv");
println!("cargo:rerun-if-changed={}", manifest.display());
println!("cargo:rerun-if-env-changed=XIANSCAN_SKIP_MODEL_HASH");
if std::env::var("XIANSCAN_SKIP_MODEL_HASH").map(|v| v == "1").unwrap_or(false) {
println!("cargo:warning=XIANSCAN_SKIP_MODEL_HASH=1: embedded models are NOT checked against models/manifest.tsv");
return;
}
let text = std::fs::read_to_string(&manifest)
.unwrap_or_else(|e| panic!("embed-models needs {}: {} (run scripts/fetch-models.sh)", manifest.display(), e));
for line in text.lines().skip(1).filter(|l| !l.trim().is_empty()) {
let cols: Vec<&str> = line.split('\t').collect();
assert!(cols.len() == 4, "malformed models/manifest.tsv row: {line}");
let (file, size, sha) = (cols[0], cols[1], cols[2]);
let path = root.join("models").join(file);
println!("cargo:rerun-if-changed={}", path.display());
let mut f = std::fs::File::open(&path)
.unwrap_or_else(|e| panic!("model {} is missing ({e}); run scripts/fetch-models.sh", path.display()));
let mut hasher = Sha256::new();
let mut buf = vec![0_u8; 1 << 20];
let mut total: u64 = 0;
loop {
let n = f.read(&mut buf).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
if n == 0 {
break;
}
total += n as u64;
hasher.update(&buf[..n]);
}
let actual = hex::encode(hasher.finalize());
assert!(
total.to_string() == size && actual == sha,
"model {file} does not match models/manifest.tsv: expected {size} bytes sha256 {sha}, got {total} bytes sha256 {actual} (run scripts/fetch-models.sh)"
);
}
}