Skip to main content

hexomc_lib/launch/
launcher.rs

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    /// Instance ID, maps to the `instance/{id}/` directory.
17    pub instance_id: String,
18    /// Path to the java executable.
19    pub java_path: PathBuf,
20    /// Player name (offline mode).
21    pub player_name: String,
22    /// Microsoft auth token (online; None = offline mode).
23    pub auth_token: Option<String>,
24    /// Player UUID.
25    pub auth_uuid: Option<String>,
26    /// Xbox XUID.
27    pub xuid: Option<String>,
28    /// Extra JVM args (e.g. `-Xmx4G`).
29    pub jvm_extra_args: Vec<String>,
30}
31
32impl LaunchOptions {
33    /// Build offline-mode options.
34    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
53/// Launch Minecraft, returning the process handle.
54pub async fn launch(options: &LaunchOptions, base_dir: &Path) -> Result<Child> {
55    // Make base_dir absolute (the argfile is read from minecraft_dir, so a relative
56    // base_dir would misresolve) and strip the Windows `\\?\` extended-path prefix
57    // left by canonicalize, which Java can't parse.
58    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    // Old-format start_args (e.g. 1.7.10) contain only game args — no -cp, natives,
76    // or ${mainClass} — so we supply the JVM section ourselves.
77    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    // @argfile is Java 9+ only; Java 8 (e.g. Forge 1.7.10) treats "@tempcmd.txt" as
101    // the main-class name, so pass args on the command line there instead. The
102    // argfile also avoids OS command-length limits; it's relative because cwd is
103    // already minecraft_dir.
104    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            // Make relative paths absolute; once Java's cwd is .minecraft/ a relative
153            // path would break.
154            let abs = lib.path
155                .canonicalize()
156                .unwrap_or_else(|_| lib.path.clone());
157            let s = path_str(&abs);
158            // Dedupe (Forge/NeoForge libs may overlap with vanilla libs).
159            if seen.insert(s.clone()) { Some(s) } else { None }
160        })
161        .collect();
162
163    // client.jar goes last.
164    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            // Generate offline UUID the same way the vanilla launcher does:
186            // UUID v3 (MD5) of "OfflinePlayer:<name>" in the DNS namespace.
187            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    // Old versions (1.7.10) need valid JSON for --userProperties or parsing fails.
208    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
220/// Replace `${key}` placeholders, including the special `${mainClass}`.
221fn 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; // Unknown key: leave as-is to avoid an infinite loop.
241        }
242    }
243    result
244}
245
246async fn write_argfile(args: &[String], path: &Path) -> Result<()> {
247    // @argfile format: one arg per line, quoted if it contains whitespace.
248    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    // Normalize backslashes, then strip the Windows `//?/` extended-path prefix
319    // (from canonicalize), which Java can't parse.
320    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}