use std::{
collections::HashMap,
path::{Path, PathBuf},
process::{Child, Command},
};
use uuid::Uuid;
use tokio::fs;
use crate::{
error::{HexoError, Result},
install::vanilla::InstanceConfig,
};
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub instance_id: String,
pub java_path: PathBuf,
pub player_name: String,
pub auth_token: Option<String>,
pub auth_uuid: Option<String>,
pub xuid: Option<String>,
pub jvm_extra_args: Vec<String>,
}
impl LaunchOptions {
pub fn offline(instance_id: impl Into<String>, java_path: PathBuf, player_name: impl Into<String>) -> Self {
Self {
instance_id: instance_id.into(),
java_path,
player_name: player_name.into(),
auth_token: None,
auth_uuid: None,
xuid: None,
jvm_extra_args: vec![
"-Xmx2G".to_string(),
"-Xms512M".to_string(),
"-Dfile.encoding=UTF-8".to_string(),
"-Dstdout.encoding=UTF-8".to_string(),
"-Dstderr.encoding=UTF-8".to_string(),
],
}
}
}
pub async fn launch(options: &LaunchOptions, base_dir: &Path) -> Result<Child> {
let canon = base_dir.canonicalize()?;
let base_str = canon.to_string_lossy().replace('\\', "/");
let base_str = base_str.strip_prefix("//?/").unwrap_or(&base_str).to_string();
let base_dir_buf = PathBuf::from(base_str);
let base_dir = base_dir_buf.as_path();
let instance_dir = base_dir.join("instance").join(&options.instance_id);
let config = InstanceConfig::load(&instance_dir).await?;
let natives_dir = instance_dir.join("natives");
extract_natives(&config, &natives_dir)?;
let classpath = build_classpath(&config, &instance_dir);
let argdata = build_argdata(&options, base_dir, &instance_dir, &config, &classpath);
let mut final_args: Vec<String> = Vec::new();
final_args.extend(options.jvm_extra_args.iter().cloned());
let is_legacy = !config.start_args.iter().any(|a| a == "${mainClass}");
if is_legacy {
for jvm_arg in [
"-Djava.library.path=${natives_directory}",
"-cp",
"${classpath}",
"${mainClass}",
] {
final_args.push(replace_placeholders(jvm_arg, &argdata, &config.main_class));
}
}
for arg in &config.start_args {
let replaced = replace_placeholders(arg, &argdata, &config.main_class);
final_args.push(replaced);
}
let minecraft_dir = instance_dir.join(".minecraft");
let mut command = Command::new(&options.java_path);
command
.current_dir(&minecraft_dir)
.env("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
if config.java_version >= 9 {
let argfile_path = minecraft_dir.join("tempcmd.txt");
write_argfile(&final_args, &argfile_path).await?;
command.arg("@tempcmd.txt");
} else {
command.args(&final_args);
}
let child = command.spawn().map_err(HexoError::Io)?;
Ok(child)
}
fn extract_natives(config: &InstanceConfig, natives_dir: &Path) -> Result<()> {
std::fs::create_dir_all(natives_dir)?;
for native in &config.natives {
if !native.path.exists() {
continue;
}
let file = std::fs::File::open(&native.path)?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| HexoError::Other(e.to_string()))?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| HexoError::Other(e.to_string()))?;
let name = entry.name().to_string();
if name.starts_with("META-INF") || name.ends_with('/') {
continue;
}
let out_path = natives_dir.join(&name);
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out = std::fs::File::create(&out_path)?;
std::io::copy(&mut entry, &mut out)?;
}
}
Ok(())
}
fn build_classpath(config: &InstanceConfig, instance_dir: &Path) -> String {
let sep = classpath_sep();
let mut seen = std::collections::HashSet::new();
let mut parts: Vec<String> = config
.lib_list
.iter()
.filter_map(|lib| {
let abs = lib.path
.canonicalize()
.unwrap_or_else(|_| lib.path.clone());
let s = path_str(&abs);
if seen.insert(s.clone()) { Some(s) } else { None }
})
.collect();
let client_jar_path = instance_dir.join(format!("{}.jar", config.version_id));
parts.push(path_str(&client_jar_path));
parts.join(sep)
}
fn build_argdata(
opts: &LaunchOptions,
base_dir: &Path,
instance_dir: &Path,
config: &InstanceConfig,
classpath: &str,
) -> HashMap<String, String> {
let minecraft_dir = instance_dir.join(".minecraft");
let mut map = HashMap::new();
let auth_token = opts.auth_token.as_deref().unwrap_or("-1");
let offline_uuid;
let auth_uuid = match opts.auth_uuid.as_deref() {
Some(u) => u,
None => {
let key = format!("OfflinePlayer:{}", opts.player_name);
offline_uuid = Uuid::new_v3(&Uuid::NAMESPACE_DNS, key.as_bytes())
.to_string()
.replace('-', "");
&offline_uuid
}
};
let xuid = opts.xuid.as_deref().unwrap_or("-1");
let user_type = if opts.auth_token.is_some() { "msa" } else { "mojang" };
map.insert("auth_player_name".into(), opts.player_name.clone());
map.insert("version_name".into(), config.version_id.clone());
map.insert("game_directory".into(), path_str(&minecraft_dir));
map.insert("assets_root".into(), path_str(&base_dir.join("assets")));
map.insert("assets_index_name".into(), config.assets_id.clone());
map.insert("auth_uuid".into(), auth_uuid.to_string());
map.insert("auth_access_token".into(), auth_token.to_string());
map.insert("clientid".into(), "-1".into());
map.insert("auth_xuid".into(), xuid.to_string());
map.insert("user_type".into(), user_type.to_string());
map.insert("user_properties".into(), "{}".into());
map.insert("version_type".into(), "release".into());
map.insert("natives_directory".into(), path_str(&instance_dir.join("natives")));
map.insert("launcher_name".into(), "HexoLauncher".into());
map.insert("launcher_version".into(), env!("CARGO_PKG_VERSION").to_string());
map.insert("classpath".into(), classpath.to_string());
map.insert("library_directory".into(), path_str(&base_dir.join("libraries")));
map.insert("classpath_separator".into(), classpath_sep().to_string());
map
}
fn replace_placeholders(
arg: &str,
data: &HashMap<String, String>,
main_class: &str,
) -> String {
if arg == "${mainClass}" {
return main_class.to_string();
}
let mut result = arg.to_string();
while let Some(start) = result.find("${") {
let end = match result[start..].find('}') {
Some(i) => start + i,
None => break,
};
let key = &result[start + 2..end];
if let Some(value) = data.get(key) {
result = format!("{}{}{}", &result[..start], value, &result[end + 1..]);
} else {
break; }
}
result
}
async fn write_argfile(args: &[String], path: &Path) -> Result<()> {
let content = args
.iter()
.map(|a| {
if a.contains(' ') {
format!("\"{}\"", a)
} else {
a.clone()
}
})
.collect::<Vec<_>>()
.join("\n");
fs::write(path, content).await?;
Ok(())
}
fn classpath_sep() -> &'static str {
if cfg!(target_os = "windows") { ";" } else { ":" }
}
#[cfg(test)]
mod tests {
use super::*;
fn make_data() -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("auth_player_name".into(), "Steve".into());
m.insert("version_name".into(), "1.21.4".into());
m.insert("classpath".into(), "/path/to/libs.jar".into());
m
}
#[test]
fn replace_simple_placeholder() {
let data = make_data();
let result = replace_placeholders("--username ${auth_player_name}", &data, "net.mc.Main");
assert_eq!(result, "--username Steve");
}
#[test]
fn replace_main_class() {
let data = make_data();
let result = replace_placeholders("${mainClass}", &data, "cpw.mods.bootstraplauncher.BootstrapLauncher");
assert_eq!(result, "cpw.mods.bootstraplauncher.BootstrapLauncher");
}
#[test]
fn replace_multiple_placeholders_in_one_arg() {
let mut data = make_data();
data.insert("game_directory".into(), "/home/user/.minecraft".into());
let result = replace_placeholders("${version_name}:${game_directory}", &data, "Main");
assert_eq!(result, "1.21.4:/home/user/.minecraft");
}
#[test]
fn unknown_placeholder_preserved() {
let data = make_data();
let result = replace_placeholders("${unknown_key}", &data, "Main");
assert_eq!(result, "${unknown_key}");
}
#[test]
fn no_placeholder_passthrough() {
let data = make_data();
let result = replace_placeholders("-Xmx2G", &data, "Main");
assert_eq!(result, "-Xmx2G");
}
}
fn path_str(p: &Path) -> String {
let s = p.to_string_lossy().replace('\\', "/");
if let Some(stripped) = s.strip_prefix("//?/") {
stripped.to_string()
} else {
s
}
}