1use std::{
2 collections::HashMap,
3 path::{Path, PathBuf},
4 process::{Child, Command, Stdio},
5 sync::Arc,
6 time::Duration,
7};
8use uuid::Uuid;
9use tokio::{fs, sync::mpsc};
10
11use crate::{
12 error::{HexoError, Result},
13 install::vanilla::InstanceConfig,
14 launch::output::{GameProcess, OutputFn, OutputLine},
15};
16
17#[derive(Debug, Clone)]
18pub struct LaunchOptions {
19 pub instance_id: String,
21 pub java_path: PathBuf,
23 pub player_name: String,
25 pub auth_token: Option<String>,
27 pub auth_uuid: Option<String>,
29 pub xuid: Option<String>,
31 pub jvm_extra_args: Vec<String>,
33}
34
35impl LaunchOptions {
36 pub fn offline(instance_id: impl Into<String>, java_path: PathBuf, player_name: impl Into<String>) -> Self {
38 Self {
39 instance_id: instance_id.into(),
40 java_path,
41 player_name: player_name.into(),
42 auth_token: None,
43 auth_uuid: None,
44 xuid: None,
45 jvm_extra_args: vec![
46 "-Xmx2G".to_string(),
47 "-Xms512M".to_string(),
48 "-Dfile.encoding=UTF-8".to_string(),
49 "-Dstdout.encoding=UTF-8".to_string(),
50 "-Dstderr.encoding=UTF-8".to_string(),
51 ],
52 }
53 }
54}
55
56pub async fn launch(options: &LaunchOptions, base_dir: &Path) -> Result<Child> {
65 let Prepared { mut command, argfile } = prepare_command(options, base_dir).await?;
66
67 finish_spawn(command.spawn(), argfile)
68}
69
70pub async fn launch_with_output(
78 options: &LaunchOptions,
79 base_dir: &Path,
80 on_output: OutputFn,
81) -> Result<GameProcess> {
82 let Prepared { mut command, argfile } = prepare_command(options, base_dir).await?;
83 command
84 .stdout(Stdio::piped())
85 .stderr(Stdio::piped())
86 .stdin(Stdio::null());
87
88 let child = finish_spawn(tokio::process::Command::from(command).spawn(), argfile)?;
89
90 Ok(GameProcess::new(child, on_output))
91}
92
93pub async fn launch_with_channel(
99 options: &LaunchOptions,
100 base_dir: &Path,
101) -> Result<(GameProcess, mpsc::UnboundedReceiver<OutputLine>)> {
102 let (tx, rx) = mpsc::unbounded_channel();
103 let sink: OutputFn = Arc::new(move |line| {
104 let _ = tx.send(line);
105 });
106
107 let process = launch_with_output(options, base_dir, sink).await?;
108 Ok((process, rx))
109}
110
111const ARGFILE_NAME: &str = "tempcmd.txt";
113
114const ARGFILE_CLEANUP_DELAY: Duration = Duration::from_secs(10);
117
118struct Prepared {
121 command: Command,
122 argfile: Option<PathBuf>,
123}
124
125fn finish_spawn<T>(spawned: std::io::Result<T>, argfile: Option<PathBuf>) -> Result<T> {
128 match spawned {
129 Ok(child) => {
130 if let Some(path) = argfile {
131 tokio::spawn(remove_argfile_after(path, ARGFILE_CLEANUP_DELAY));
132 }
133 Ok(child)
134 }
135 Err(e) => {
136 if let Some(path) = argfile {
137 let _ = std::fs::remove_file(path);
138 }
139 Err(HexoError::Io(e))
140 }
141 }
142}
143
144async fn remove_argfile_after(path: PathBuf, delay: Duration) {
146 tokio::time::sleep(delay).await;
147 let _ = fs::remove_file(&path).await;
148}
149
150async fn prepare_command(options: &LaunchOptions, base_dir: &Path) -> Result<Prepared> {
153 let canon = base_dir.canonicalize()?;
157 let base_str = canon.to_string_lossy().replace('\\', "/");
158 let base_str = base_str.strip_prefix("//?/").unwrap_or(&base_str).to_string();
159 let base_dir_buf = PathBuf::from(base_str);
160 let base_dir = base_dir_buf.as_path();
161 let instance_dir = base_dir.join("instance").join(&options.instance_id);
162 let config = InstanceConfig::load(&instance_dir).await?;
163
164 let natives_dir = instance_dir.join("natives");
165 extract_natives(&config, &natives_dir)?;
166
167 let classpath = build_classpath(&config, &instance_dir);
168 let argdata = build_argdata(&options, base_dir, &instance_dir, &config, &classpath);
169
170 let mut final_args: Vec<String> = Vec::new();
171 final_args.extend(options.jvm_extra_args.iter().cloned());
172
173 let is_legacy = !config.start_args.iter().any(|a| a == "${mainClass}");
176 if is_legacy {
177 for jvm_arg in [
178 "-Djava.library.path=${natives_directory}",
179 "-cp",
180 "${classpath}",
181 "${mainClass}",
182 ] {
183 final_args.push(replace_placeholders(jvm_arg, &argdata, &config.main_class));
184 }
185 }
186
187 for arg in &config.start_args {
188 let replaced = replace_placeholders(arg, &argdata, &config.main_class);
189 final_args.push(replaced);
190 }
191
192 let minecraft_dir = instance_dir.join(".minecraft");
193 let mut command = Command::new(&options.java_path);
194 command
195 .current_dir(&minecraft_dir)
196 .env("JAVA_TOOL_OPTIONS", "-Dfile.encoding=UTF-8");
197
198 let argfile = if config.java_version >= 9 {
203 let argfile_path = minecraft_dir.join(ARGFILE_NAME);
204 write_argfile(&final_args, &argfile_path).await?;
205 command.arg(format!("@{}", ARGFILE_NAME));
206 Some(argfile_path)
207 } else {
208 command.args(&final_args);
209 None
210 };
211
212 Ok(Prepared { command, argfile })
213}
214
215fn extract_natives(config: &InstanceConfig, natives_dir: &Path) -> Result<()> {
216 std::fs::create_dir_all(natives_dir)?;
217
218 for native in &config.natives {
219 if !native.path.exists() {
220 continue;
221 }
222 let file = std::fs::File::open(&native.path)?;
223 let mut archive = zip::ZipArchive::new(file).map_err(|e| HexoError::Other(e.to_string()))?;
224
225 for i in 0..archive.len() {
226 let mut entry = archive.by_index(i).map_err(|e| HexoError::Other(e.to_string()))?;
227 let name = entry.name().to_string();
228 if name.starts_with("META-INF") || name.ends_with('/') {
229 continue;
230 }
231 let out_path = natives_dir.join(&name);
232 if let Some(parent) = out_path.parent() {
233 std::fs::create_dir_all(parent)?;
234 }
235 let mut out = std::fs::File::create(&out_path)?;
236 std::io::copy(&mut entry, &mut out)?;
237 }
238 }
239
240 Ok(())
241}
242
243fn build_classpath(config: &InstanceConfig, instance_dir: &Path) -> String {
244 let sep = classpath_sep();
245 let mut seen = std::collections::HashSet::new();
246 let mut parts: Vec<String> = config
247 .lib_list
248 .iter()
249 .filter_map(|lib| {
250 let abs = lib.path
253 .canonicalize()
254 .unwrap_or_else(|_| lib.path.clone());
255 let s = path_str(&abs);
256 if seen.insert(s.clone()) { Some(s) } else { None }
258 })
259 .collect();
260
261 let client_jar_path = instance_dir.join(format!("{}.jar", config.version_id));
263 parts.push(path_str(&client_jar_path));
264
265 parts.join(sep)
266}
267
268fn build_argdata(
269 opts: &LaunchOptions,
270 base_dir: &Path,
271 instance_dir: &Path,
272 config: &InstanceConfig,
273 classpath: &str,
274) -> HashMap<String, String> {
275 let minecraft_dir = instance_dir.join(".minecraft");
276 let mut map = HashMap::new();
277
278 let auth_token = opts.auth_token.as_deref().unwrap_or("-1");
279 let offline_uuid;
280 let auth_uuid = match opts.auth_uuid.as_deref() {
281 Some(u) => u,
282 None => {
283 let key = format!("OfflinePlayer:{}", opts.player_name);
286 offline_uuid = Uuid::new_v3(&Uuid::NAMESPACE_DNS, key.as_bytes())
287 .to_string()
288 .replace('-', "");
289 &offline_uuid
290 }
291 };
292 let xuid = opts.xuid.as_deref().unwrap_or("-1");
293 let user_type = if opts.auth_token.is_some() { "msa" } else { "mojang" };
294
295 map.insert("auth_player_name".into(), opts.player_name.clone());
296 map.insert("version_name".into(), config.version_id.clone());
297 map.insert("game_directory".into(), path_str(&minecraft_dir));
298 map.insert("assets_root".into(), path_str(&base_dir.join("assets")));
299 map.insert("assets_index_name".into(), config.assets_id.clone());
300 map.insert("auth_uuid".into(), auth_uuid.to_string());
301 map.insert("auth_access_token".into(), auth_token.to_string());
302 map.insert("clientid".into(), "-1".into());
303 map.insert("auth_xuid".into(), xuid.to_string());
304 map.insert("user_type".into(), user_type.to_string());
305 map.insert("user_properties".into(), "{}".into());
307 map.insert("version_type".into(), "release".into());
308 map.insert("natives_directory".into(), path_str(&instance_dir.join("natives")));
309 map.insert("launcher_name".into(), "HexoLauncher".into());
310 map.insert("launcher_version".into(), env!("CARGO_PKG_VERSION").to_string());
311 map.insert("classpath".into(), classpath.to_string());
312 map.insert("library_directory".into(), path_str(&base_dir.join("libraries")));
313 map.insert("classpath_separator".into(), classpath_sep().to_string());
314
315 map
316}
317
318fn replace_placeholders(
320 arg: &str,
321 data: &HashMap<String, String>,
322 main_class: &str,
323) -> String {
324 if arg == "${mainClass}" {
325 return main_class.to_string();
326 }
327
328 let mut result = arg.to_string();
329 while let Some(start) = result.find("${") {
330 let end = match result[start..].find('}') {
331 Some(i) => start + i,
332 None => break,
333 };
334 let key = &result[start + 2..end];
335 if let Some(value) = data.get(key) {
336 result = format!("{}{}{}", &result[..start], value, &result[end + 1..]);
337 } else {
338 break; }
340 }
341 result
342}
343
344async fn write_argfile(args: &[String], path: &Path) -> Result<()> {
345 let content = args
347 .iter()
348 .map(|a| {
349 if a.contains(' ') {
350 format!("\"{}\"", a)
351 } else {
352 a.clone()
353 }
354 })
355 .collect::<Vec<_>>()
356 .join("\n");
357
358 fs::write(path, content).await?;
359 Ok(())
360}
361
362fn classpath_sep() -> &'static str {
363 if cfg!(target_os = "windows") { ";" } else { ":" }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 fn make_data() -> HashMap<String, String> {
371 let mut m = HashMap::new();
372 m.insert("auth_player_name".into(), "Steve".into());
373 m.insert("version_name".into(), "1.21.4".into());
374 m.insert("classpath".into(), "/path/to/libs.jar".into());
375 m
376 }
377
378 #[test]
379 fn replace_simple_placeholder() {
380 let data = make_data();
381 let result = replace_placeholders("--username ${auth_player_name}", &data, "net.mc.Main");
382 assert_eq!(result, "--username Steve");
383 }
384
385 #[test]
386 fn replace_main_class() {
387 let data = make_data();
388 let result = replace_placeholders("${mainClass}", &data, "cpw.mods.bootstraplauncher.BootstrapLauncher");
389 assert_eq!(result, "cpw.mods.bootstraplauncher.BootstrapLauncher");
390 }
391
392 #[test]
393 fn replace_multiple_placeholders_in_one_arg() {
394 let mut data = make_data();
395 data.insert("game_directory".into(), "/home/user/.minecraft".into());
396 let result = replace_placeholders("${version_name}:${game_directory}", &data, "Main");
397 assert_eq!(result, "1.21.4:/home/user/.minecraft");
398 }
399
400 #[test]
401 fn unknown_placeholder_preserved() {
402 let data = make_data();
403 let result = replace_placeholders("${unknown_key}", &data, "Main");
404 assert_eq!(result, "${unknown_key}");
405 }
406
407 #[test]
408 fn no_placeholder_passthrough() {
409 let data = make_data();
410 let result = replace_placeholders("-Xmx2G", &data, "Main");
411 assert_eq!(result, "-Xmx2G");
412 }
413
414 #[tokio::test]
415 async fn argfile_removed_after_delay() {
416 let dir = tempfile::tempdir().unwrap();
417 let path = dir.path().join(ARGFILE_NAME);
418 std::fs::write(&path, "-Xmx2G").unwrap();
419
420 remove_argfile_after(path.clone(), Duration::from_millis(10)).await;
421
422 assert!(!path.exists());
423 }
424
425 #[tokio::test]
426 async fn argfile_removed_when_spawn_fails() {
427 let dir = tempfile::tempdir().unwrap();
428 let path = dir.path().join(ARGFILE_NAME);
429 std::fs::write(&path, "-Xmx2G").unwrap();
430
431 let failed = Err(std::io::Error::from(std::io::ErrorKind::NotFound));
432 let result: Result<Child> = finish_spawn(failed, Some(path.clone()));
433
434 assert!(result.is_err());
435 assert!(!path.exists());
436 }
437}
438
439fn path_str(p: &Path) -> String {
440 let s = p.to_string_lossy().replace('\\', "/");
443 if let Some(stripped) = s.strip_prefix("//?/") {
444 stripped.to_string()
445 } else {
446 s
447 }
448}