Skip to main content

hexomc_lib/launch/
launcher.rs

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    /// Instance ID, maps to the `instance/{id}/` directory.
20    pub instance_id: String,
21    /// Path to the java executable.
22    pub java_path: PathBuf,
23    /// Player name (offline mode).
24    pub player_name: String,
25    /// Microsoft auth token (online; None = offline mode).
26    pub auth_token: Option<String>,
27    /// Player UUID.
28    pub auth_uuid: Option<String>,
29    /// Xbox XUID.
30    pub xuid: Option<String>,
31    /// Extra JVM args (e.g. `-Xmx4G`).
32    pub jvm_extra_args: Vec<String>,
33}
34
35impl LaunchOptions {
36    /// Build offline-mode options.
37    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
56/// Launch Minecraft, returning the process handle.
57///
58/// stdout/stderr are inherited from the parent process. Use
59/// [`launch_with_output`] or [`launch_with_channel`] to capture them instead.
60///
61/// The `@argfile` written into `.minecraft/` is deleted a few seconds after the
62/// JVM starts, so the caller must stay alive at least that long for the cleanup
63/// to happen.
64pub 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
70/// Launch Minecraft with stdout/stderr piped, delivering them line by line to
71/// `on_output`.
72///
73/// The callback is invoked from tokio tasks (one per stream), so lines from
74/// stdout and stderr may interleave; `OutputLine::kind` says which is which.
75/// [`GameProcess::wait`] waits for the process *and* for every buffered line to
76/// be delivered.
77pub 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
93/// Same as [`launch_with_output`], but pushes the lines into a channel instead
94/// of a callback — convenient when the consumer is a UI loop or another task.
95///
96/// The channel is unbounded, so the game never blocks on a slow consumer; drop
97/// the receiver if the output is no longer wanted.
98pub 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
111/// Name of the `@argfile`, written into `.minecraft/` (cwd of the game).
112const ARGFILE_NAME: &str = "tempcmd.txt";
113
114/// How long to wait before deleting the `@argfile`. The JVM reads it while
115/// starting up, so it can't be removed immediately after spawn.
116const ARGFILE_CLEANUP_DELAY: Duration = Duration::from_secs(10);
117
118/// A java `Command` ready to spawn, plus the `@argfile` it depends on (None when
119/// args were passed on the command line instead).
120struct Prepared {
121    command: Command,
122    argfile: Option<PathBuf>,
123}
124
125/// Hand back the spawned child, scheduling the `@argfile` for deletion once the
126/// JVM has had time to read it. A failed spawn never read it, so it goes now.
127fn 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
144/// Delete the `@argfile` once `delay` has passed; a missing file is not an error.
145async fn remove_argfile_after(path: PathBuf, delay: Duration) {
146    tokio::time::sleep(delay).await;
147    let _ = fs::remove_file(&path).await;
148}
149
150/// Build the fully-configured java `Command` (classpath, natives, argfile, cwd)
151/// without spawning it — shared by every `launch*` entry point.
152async fn prepare_command(options: &LaunchOptions, base_dir: &Path) -> Result<Prepared> {
153    // Make base_dir absolute (the argfile is read from minecraft_dir, so a relative
154    // base_dir would misresolve) and strip the Windows `\\?\` extended-path prefix
155    // left by canonicalize, which Java can't parse.
156    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    // Old-format start_args (e.g. 1.7.10) contain only game args — no -cp, natives,
174    // or ${mainClass} — so we supply the JVM section ourselves.
175    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    // @argfile is Java 9+ only; Java 8 (e.g. Forge 1.7.10) treats "@tempcmd.txt" as
199    // the main-class name, so pass args on the command line there instead. The
200    // argfile also avoids OS command-length limits; it's relative because cwd is
201    // already minecraft_dir.
202    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            // Make relative paths absolute; once Java's cwd is .minecraft/ a relative
251            // path would break.
252            let abs = lib.path
253                .canonicalize()
254                .unwrap_or_else(|_| lib.path.clone());
255            let s = path_str(&abs);
256            // Dedupe (Forge/NeoForge libs may overlap with vanilla libs).
257            if seen.insert(s.clone()) { Some(s) } else { None }
258        })
259        .collect();
260
261    // client.jar goes last.
262    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            // Generate offline UUID the same way the vanilla launcher does:
284            // UUID v3 (MD5) of "OfflinePlayer:<name>" in the DNS namespace.
285            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    // Old versions (1.7.10) need valid JSON for --userProperties or parsing fails.
306    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
318/// Replace `${key}` placeholders, including the special `${mainClass}`.
319fn 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; // Unknown key: leave as-is to avoid an infinite loop.
339        }
340    }
341    result
342}
343
344async fn write_argfile(args: &[String], path: &Path) -> Result<()> {
345    // @argfile format: one arg per line, quoted if it contains whitespace.
346    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    // Normalize backslashes, then strip the Windows `//?/` extended-path prefix
441    // (from canonicalize), which Java can't parse.
442    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}