jartodynamiclibrary 0.1.0

Pack a Forge/NeoForge/Fabric mod .jar into a dynamic library (.dll/.so/.dylib) that loads it into a running Minecraft JVM
#[path = "build/classfile.rs"]
mod classfile;
#[path = "build/jar.rs"]
mod jar;

use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const SOURCE: &str = "java/ForgeInjector.java";
const CLASS_NAME: &str = "ForgeInjector.class";
const MAX_MAJOR_VERSION: u16 = 52;

fn main() {
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed={SOURCE}");
    println!("cargo:rerun-if-env-changed=JAVAC");
    println!("cargo:rerun-if-env-changed=JAVA_HOME");
    println!("cargo:rerun-if-env-changed=INJECTOR_CLASS_FILE");

    let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by cargo"));
    let destination = out_dir.join(CLASS_NAME);

    if let Some(prebuilt) = env::var_os("INJECTOR_CLASS_FILE") {
        println!("cargo:rerun-if-changed={}", Path::new(&prebuilt).display());
        fs::copy(&prebuilt, &destination)
            .unwrap_or_else(|e| panic!("can't copy INJECTOR_CLASS_FILE {prebuilt:?}: {e}"));
    } else {
        compile_injector(&out_dir, &destination);
    }

    check_class_file(&destination);

    embed_input_jar(&out_dir);
}

fn embed_input_jar(out_dir: &Path) {
    println!("cargo:rerun-if-env-changed=INPUT_JAR");
    let jar_path = match env::var_os("INPUT_JAR") {
        Some(path) => PathBuf::from(path),
        None => PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("set by cargo")).join("input.jar"),
    };
    println!("cargo:rerun-if-changed={}", jar_path.display());

    let class_dir = out_dir.join("jar-classes");
    let _ = fs::remove_dir_all(&class_dir);
    fs::create_dir_all(&class_dir).expect("can't create jar-classes dir");

    let jar = if jar_path.is_file() {
        jar::read_jar(&jar_path).unwrap_or_else(|e| panic!("input jar: {e}"))
    } else if env::var("PROFILE").as_deref() == Ok("release") && env::var_os("CARGO_FEATURE_GUI").is_none() {
        panic!(
            "input jar not found at {}.\nPut your mod there as input.jar or set INPUT_JAR=/path/to/mod.jar",
            jar_path.display()
        );
    } else {
        println!(
            "cargo:warning=no input jar at {}: building with ZERO mod classes (debug build or GUI build)",
            jar_path.display()
        );
        jar::JarClasses {
            classes: Vec::new(),
            skipped: Vec::new(),
        }
    };

    for note in &jar.skipped {
        println!("cargo:warning=skipped jar entry: {note}");
    }

    let mut generated = String::from("pub static JAR_CLASSES: &[&[u8]] = &[\n");
    let mut manifest = String::new();
    for (index, class) in jar.classes.iter().enumerate() {
        let file = class_dir.join(format!("{index}.class"));
        fs::write(&file, &class.data).expect("can't write class file");
        generated.push_str(&format!("    include_bytes!({:?}),\n", file.to_string_lossy()));
        manifest.push_str(&format!("{index}\tpriority={}\t{}\n", class.priority, class.name));
    }
    generated.push_str("];\n");

    fs::write(out_dir.join("jar_classes.rs"), generated).expect("can't write jar_classes.rs");
    fs::write(class_dir.join("manifest.txt"), manifest).expect("can't write manifest");
}

fn javac_path() -> PathBuf {
    if let Some(javac) = env::var_os("JAVAC") {
        return PathBuf::from(javac);
    }
    if let Some(home) = env::var_os("JAVA_HOME") {
        let candidate = Path::new(&home)
            .join("bin")
            .join(if cfg!(windows) { "javac.exe" } else { "javac" });
        if candidate.is_file() {
            return candidate;
        }
    }
    PathBuf::from("javac")
}

fn compile_injector(out_dir: &Path, destination: &Path) {
    let javac = javac_path();
    let class_dir = out_dir.join("injector-classes");
    let _ = fs::remove_dir_all(&class_dir);
    fs::create_dir_all(&class_dir).expect("can't create class output dir");

    let attempts: [&[&str]; 2] = [&["--release", "8"], &["-source", "1.8", "-target", "1.8"]];
    let mut last_error = String::new();

    for flags in attempts {
        let output = Command::new(&javac)
            .args(flags)
            .args(["-encoding", "UTF-8", "-d"])
            .arg(&class_dir)
            .arg(SOURCE)
            .output();

        match output {
            Ok(out) if out.status.success() => {
                fs::copy(class_dir.join(CLASS_NAME), destination)
                    .expect("javac succeeded but ForgeInjector.class is missing");
                return;
            }
            Ok(out) => {
                last_error = format!(
                    "`javac {}` failed ({}):\n{}",
                    flags.join(" "),
                    out.status,
                    String::from_utf8_lossy(&out.stderr)
                );
            }
            Err(e) => {
                panic!(
                    "can't run {javac:?}: {e}\n\
                     Install a JDK (8 or newer), set JAVAC / JAVA_HOME, \
                     or point INJECTOR_CLASS_FILE at a prebuilt ForgeInjector.class"
                );
            }
        }
    }
    panic!("{last_error}");
}

fn check_class_file(path: &Path) {
    let bytes = fs::read(path).unwrap_or_else(|e| panic!("can't read {}: {e}", path.display()));
    assert!(
        bytes.len() >= 8 && bytes[..4] == [0xCA, 0xFE, 0xBA, 0xBE],
        "{} is not a class file",
        path.display()
    );
    let major = u16::from_be_bytes([bytes[6], bytes[7]]);
    assert!(
        major <= MAX_MAJOR_VERSION,
        "injector class has major version {major}; it must be compiled for Java 8 (<= {MAX_MAJOR_VERSION})"
    );
}