1use std::{
2 collections::HashMap,
3 path::{Path, PathBuf},
4 process::{Child, Command},
5};
6use uuid::Uuid;
7use tokio::fs;
8
9use crate::{
10 error::{HexoError, Result},
11 install::vanilla::InstanceConfig,
12};
13
14#[derive(Debug, Clone)]
15pub struct LaunchOptions {
16 pub instance_id: String,
18 pub java_path: PathBuf,
20 pub player_name: String,
22 pub auth_token: Option<String>,
24 pub auth_uuid: Option<String>,
26 pub xuid: Option<String>,
28 pub jvm_extra_args: Vec<String>,
30}
31
32impl LaunchOptions {
33 pub fn offline(instance_id: impl Into<String>, java_path: PathBuf, player_name: impl Into<String>) -> Self {
35 Self {
36 instance_id: instance_id.into(),
37 java_path,
38 player_name: player_name.into(),
39 auth_token: None,
40 auth_uuid: None,
41 xuid: None,
42 jvm_extra_args: vec![
43 "-Xmx2G".to_string(),
44 "-Xms512M".to_string(),
45 "-Dfile.encoding=UTF-8".to_string(),
46 "-Dstdout.encoding=UTF-8".to_string(),
47 "-Dstderr.encoding=UTF-8".to_string(),
48 ],
49 }
50 }
51}
52
53pub async fn launch(options: &LaunchOptions, base_dir: &Path) -> Result<Child> {
55 let canon = base_dir.canonicalize()?;
59 let base_str = canon.to_string_lossy().replace('\\', "/");
60 let base_str = base_str.strip_prefix("//?/").unwrap_or(&base_str).to_string();
61 let base_dir_buf = PathBuf::from(base_str);
62 let base_dir = base_dir_buf.as_path();
63 let instance_dir = base_dir.join("instance").join(&options.instance_id);
64 let config = InstanceConfig::load(&instance_dir).await?;
65
66 let natives_dir = instance_dir.join("natives");
67 extract_natives(&config, &natives_dir)?;
68
69 let classpath = build_classpath(&config, &instance_dir);
70 let argdata = build_argdata(&options, base_dir, &instance_dir, &config, &classpath);
71
72 let mut final_args: Vec<String> = Vec::new();
73 final_args.extend(options.jvm_extra_args.iter().cloned());
74
75 let is_legacy = !config.start_args.iter().any(|a| a == "${mainClass}");
78 if is_legacy {
79 for jvm_arg in [
80 "-Djava.library.path=${natives_directory}",
81 "-cp",
82 "${classpath}",
83 "${mainClass}",
84 ] {
85 final_args.push(replace_placeholders(jvm_arg, &argdata, &config.main_class));
86 }
87 }
88
89 for arg in &config.start_args {
90 let replaced = replace_placeholders(arg, &argdata, &config.main_class);
91 final_args.push(replaced);
92 }
93
94 let minecraft_dir = instance_dir.join(".minecraft");
95 let mut command = Command::new(&options.java_path);
96 command
97 .current_dir(&minecraft_dir)
98 .env("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
99
100 if config.java_version >= 9 {
105 let argfile_path = minecraft_dir.join("tempcmd.txt");
106 write_argfile(&final_args, &argfile_path).await?;
107 command.arg("@tempcmd.txt");
108 } else {
109 command.args(&final_args);
110 }
111
112 let child = command.spawn().map_err(HexoError::Io)?;
113
114 Ok(child)
115}
116
117fn extract_natives(config: &InstanceConfig, natives_dir: &Path) -> Result<()> {
118 std::fs::create_dir_all(natives_dir)?;
119
120 for native in &config.natives {
121 if !native.path.exists() {
122 continue;
123 }
124 let file = std::fs::File::open(&native.path)?;
125 let mut archive = zip::ZipArchive::new(file).map_err(|e| HexoError::Other(e.to_string()))?;
126
127 for i in 0..archive.len() {
128 let mut entry = archive.by_index(i).map_err(|e| HexoError::Other(e.to_string()))?;
129 let name = entry.name().to_string();
130 if name.starts_with("META-INF") || name.ends_with('/') {
131 continue;
132 }
133 let out_path = natives_dir.join(&name);
134 if let Some(parent) = out_path.parent() {
135 std::fs::create_dir_all(parent)?;
136 }
137 let mut out = std::fs::File::create(&out_path)?;
138 std::io::copy(&mut entry, &mut out)?;
139 }
140 }
141
142 Ok(())
143}
144
145fn build_classpath(config: &InstanceConfig, instance_dir: &Path) -> String {
146 let sep = classpath_sep();
147 let mut seen = std::collections::HashSet::new();
148 let mut parts: Vec<String> = config
149 .lib_list
150 .iter()
151 .filter_map(|lib| {
152 let abs = lib.path
155 .canonicalize()
156 .unwrap_or_else(|_| lib.path.clone());
157 let s = path_str(&abs);
158 if seen.insert(s.clone()) { Some(s) } else { None }
160 })
161 .collect();
162
163 let client_jar_path = instance_dir.join(format!("{}.jar", config.version_id));
165 parts.push(path_str(&client_jar_path));
166
167 parts.join(sep)
168}
169
170fn build_argdata(
171 opts: &LaunchOptions,
172 base_dir: &Path,
173 instance_dir: &Path,
174 config: &InstanceConfig,
175 classpath: &str,
176) -> HashMap<String, String> {
177 let minecraft_dir = instance_dir.join(".minecraft");
178 let mut map = HashMap::new();
179
180 let auth_token = opts.auth_token.as_deref().unwrap_or("-1");
181 let offline_uuid;
182 let auth_uuid = match opts.auth_uuid.as_deref() {
183 Some(u) => u,
184 None => {
185 let key = format!("OfflinePlayer:{}", opts.player_name);
188 offline_uuid = Uuid::new_v3(&Uuid::NAMESPACE_DNS, key.as_bytes())
189 .to_string()
190 .replace('-', "");
191 &offline_uuid
192 }
193 };
194 let xuid = opts.xuid.as_deref().unwrap_or("-1");
195 let user_type = if opts.auth_token.is_some() { "msa" } else { "mojang" };
196
197 map.insert("auth_player_name".into(), opts.player_name.clone());
198 map.insert("version_name".into(), config.version_id.clone());
199 map.insert("game_directory".into(), path_str(&minecraft_dir));
200 map.insert("assets_root".into(), path_str(&base_dir.join("assets")));
201 map.insert("assets_index_name".into(), config.assets_id.clone());
202 map.insert("auth_uuid".into(), auth_uuid.to_string());
203 map.insert("auth_access_token".into(), auth_token.to_string());
204 map.insert("clientid".into(), "-1".into());
205 map.insert("auth_xuid".into(), xuid.to_string());
206 map.insert("user_type".into(), user_type.to_string());
207 map.insert("user_properties".into(), "{}".into());
209 map.insert("version_type".into(), "release".into());
210 map.insert("natives_directory".into(), path_str(&instance_dir.join("natives")));
211 map.insert("launcher_name".into(), "HexoLauncher".into());
212 map.insert("launcher_version".into(), env!("CARGO_PKG_VERSION").to_string());
213 map.insert("classpath".into(), classpath.to_string());
214 map.insert("library_directory".into(), path_str(&base_dir.join("libraries")));
215 map.insert("classpath_separator".into(), classpath_sep().to_string());
216
217 map
218}
219
220fn replace_placeholders(
222 arg: &str,
223 data: &HashMap<String, String>,
224 main_class: &str,
225) -> String {
226 if arg == "${mainClass}" {
227 return main_class.to_string();
228 }
229
230 let mut result = arg.to_string();
231 while let Some(start) = result.find("${") {
232 let end = match result[start..].find('}') {
233 Some(i) => start + i,
234 None => break,
235 };
236 let key = &result[start + 2..end];
237 if let Some(value) = data.get(key) {
238 result = format!("{}{}{}", &result[..start], value, &result[end + 1..]);
239 } else {
240 break; }
242 }
243 result
244}
245
246async fn write_argfile(args: &[String], path: &Path) -> Result<()> {
247 let content = args
249 .iter()
250 .map(|a| {
251 if a.contains(' ') {
252 format!("\"{}\"", a)
253 } else {
254 a.clone()
255 }
256 })
257 .collect::<Vec<_>>()
258 .join("\n");
259
260 fs::write(path, content).await?;
261 Ok(())
262}
263
264fn classpath_sep() -> &'static str {
265 if cfg!(target_os = "windows") { ";" } else { ":" }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn make_data() -> HashMap<String, String> {
273 let mut m = HashMap::new();
274 m.insert("auth_player_name".into(), "Steve".into());
275 m.insert("version_name".into(), "1.21.4".into());
276 m.insert("classpath".into(), "/path/to/libs.jar".into());
277 m
278 }
279
280 #[test]
281 fn replace_simple_placeholder() {
282 let data = make_data();
283 let result = replace_placeholders("--username ${auth_player_name}", &data, "net.mc.Main");
284 assert_eq!(result, "--username Steve");
285 }
286
287 #[test]
288 fn replace_main_class() {
289 let data = make_data();
290 let result = replace_placeholders("${mainClass}", &data, "cpw.mods.bootstraplauncher.BootstrapLauncher");
291 assert_eq!(result, "cpw.mods.bootstraplauncher.BootstrapLauncher");
292 }
293
294 #[test]
295 fn replace_multiple_placeholders_in_one_arg() {
296 let mut data = make_data();
297 data.insert("game_directory".into(), "/home/user/.minecraft".into());
298 let result = replace_placeholders("${version_name}:${game_directory}", &data, "Main");
299 assert_eq!(result, "1.21.4:/home/user/.minecraft");
300 }
301
302 #[test]
303 fn unknown_placeholder_preserved() {
304 let data = make_data();
305 let result = replace_placeholders("${unknown_key}", &data, "Main");
306 assert_eq!(result, "${unknown_key}");
307 }
308
309 #[test]
310 fn no_placeholder_passthrough() {
311 let data = make_data();
312 let result = replace_placeholders("-Xmx2G", &data, "Main");
313 assert_eq!(result, "-Xmx2G");
314 }
315}
316
317fn path_str(p: &Path) -> String {
318 let s = p.to_string_lossy().replace('\\', "/");
321 if let Some(stripped) = s.strip_prefix("//?/") {
322 stripped.to_string()
323 } else {
324 s
325 }
326}