Skip to main content

lighty_launch/arguments/
arguments.rs

1// Copyright (c) 2025 Hamadi
2// Licensed under the MIT License
3
4use lighty_auth::{AuthProvider, UserProfile};
5use lighty_loaders::types::version_metadata::Version;
6use lighty_loaders::types::VersionInfo;
7use std::borrow::Cow;
8use std::collections::{HashMap, HashSet};
9
10// Public placeholder keys matching `${...}` tokens in Mojang's version manifest.
11// Pass them to `ArgumentsBuilder::set(key, value)` to override substitution.
12
13/// Player username (`${auth_player_name}`).
14pub const KEY_AUTH_PLAYER_NAME: &str = "auth_player_name";
15/// Player UUID (`${auth_uuid}`).
16pub const KEY_AUTH_UUID: &str = "auth_uuid";
17/// Mojang/Microsoft access token (`${auth_access_token}`).
18pub const KEY_AUTH_ACCESS_TOKEN: &str = "auth_access_token";
19/// Xbox Live user ID (`${auth_xuid}`).
20pub const KEY_AUTH_XUID: &str = "auth_xuid";
21/// OAuth client ID (`${clientid}`).
22pub const KEY_CLIENT_ID: &str = "clientid";
23/// User type (`${user_type}`).
24pub const KEY_USER_TYPE: &str = "user_type";
25/// User properties JSON (`${user_properties}`).
26pub const KEY_USER_PROPERTIES: &str = "user_properties";
27/// Version name (`${version_name}`).
28pub const KEY_VERSION_NAME: &str = "version_name";
29/// Version type — `"release"`, `"snapshot"`, ... (`${version_type}`).
30pub const KEY_VERSION_TYPE: &str = "version_type";
31/// Game run directory (`${game_directory}`).
32pub const KEY_GAME_DIRECTORY: &str = "game_directory";
33/// Assets root directory (`${assets_root}`).
34pub const KEY_ASSETS_ROOT: &str = "assets_root";
35/// Native libraries directory (`${natives_directory}`).
36pub const KEY_NATIVES_DIRECTORY: &str = "natives_directory";
37/// Maven libraries directory (`${library_directory}`).
38pub const KEY_LIBRARY_DIRECTORY: &str = "library_directory";
39/// Assets index id (`${assets_index_name}`).
40pub const KEY_ASSETS_INDEX_NAME: &str = "assets_index_name";
41/// Launcher brand name (`${launcher_name}`).
42pub const KEY_LAUNCHER_NAME: &str = "launcher_name";
43/// Launcher version (`${launcher_version}`).
44pub const KEY_LAUNCHER_VERSION: &str = "launcher_version";
45/// Final classpath value (`${classpath}`).
46pub const KEY_CLASSPATH: &str = "classpath";
47/// OS-specific classpath separator (`${classpath_separator}`).
48pub const KEY_CLASSPATH_SEPARATOR: &str = "classpath_separator";
49
50// Default values used when no real session data is available
51const DEFAULT_ACCESS_TOKEN: &str = "0";
52const DEFAULT_XUID: &str = "0";
53const DEFAULT_USER_TYPE: &str = "legacy";
54const DEFAULT_USER_PROPERTIES: &str = "{}";
55const DEFAULT_VERSION_TYPE: &str = "release";
56const CP_FLAG: &str = "-cp";
57
58/// Maps an `AuthProvider` to the `${user_type}` string the game expects.
59///
60/// - `Microsoft` → `"msa"` (Microsoft Account, modern online auth)
61/// - `Azuriom` → `"mojang"` (legacy Mojang-style sessions, what Azuriom mimics)
62/// - `Offline` → `"legacy"` (matches the historical default for unauth'd play)
63/// - `Custom` → `"legacy"` (callers can override via `set(KEY_USER_TYPE, ...)`
64///   on the `ArgumentsBuilder` when they need something specific)
65fn user_type_for(provider: &AuthProvider) -> &'static str {
66    match provider {
67        AuthProvider::Microsoft { .. } => "msa",
68        AuthProvider::Azuriom { .. } => "mojang",
69        AuthProvider::Offline | AuthProvider::Custom { .. } => "legacy",
70    }
71}
72
73/// Builds the final argv (JVM args + main class + game args + raw args)
74/// from a resolved [`Version`] plus runtime overrides and removals.
75///
76/// Implemented blanket-style for every [`VersionInfo`]; user code rarely
77/// invokes [`Self::build_arguments`] directly — `LaunchBuilder::run`
78/// does it internally.
79pub trait Arguments {
80    /// Constructs the launch argv for `builder` using the supplied
81    /// overrides and removals.
82    ///
83    /// Pass the authenticated `profile` so auth-derived placeholders
84    /// (`${auth_player_name}`, `${auth_uuid}`, `${auth_access_token}`,
85    /// `${auth_xuid}`, `${user_type}`) are populated from real session
86    /// data. `None` keeps the legacy hardcoded defaults (`"0"`,
87    /// `"legacy"`, …) — useful for dry-run argv inspection or tests.
88    fn build_arguments(
89        &self,
90        builder: &Version,
91        profile: Option<&UserProfile>,
92        arg_overrides: &HashMap<String, String>,
93        arg_removals: &HashSet<String>,
94        jvm_overrides: &HashMap<String, String>,
95        jvm_removals: &HashSet<String>,
96        raw_args: &[String],
97    ) -> Vec<String>;
98}
99
100impl<T: VersionInfo> Arguments for T {
101    fn build_arguments(
102        &self,
103        builder: &Version,
104        profile: Option<&UserProfile>,
105        arg_overrides: &HashMap<String, String>,
106        arg_removals: &HashSet<String>,
107        jvm_overrides: &HashMap<String, String>,
108        jvm_removals: &HashSet<String>,
109        raw_args: &[String],
110    ) -> Vec<String> {
111        let mut variables = create_variable_map(self, builder, profile);
112
113        for (key, value) in arg_overrides {
114            variables.insert(key.clone(), value.clone());
115        }
116
117        // KEY_GAME_DIRECTORY is special: the runner already resolved it against
118        // version.game_dirs() and wrote the absolute path via set_runtime_dir.
119        // Force the resolved value so the JVM gets the absolute path.
120        variables.insert(
121            KEY_GAME_DIRECTORY.into(),
122            self.runtime_dir().display().to_string(),
123        );
124
125        let game_args = replace_variables_in_vec(&builder.arguments.game, &variables);
126
127        let mut jvm_args = builder.arguments.jvm
128            .as_ref()
129            .map(|jvm| replace_variables_in_vec(jvm, &variables))
130            .unwrap_or_else(|| build_default_jvm_args(&variables));
131
132        // macOS: -XstartOnFirstThread is MANDATORY for LWJGL/OpenGL
133        #[cfg(target_os = "macos")]
134        if !jvm_args.iter().any(|arg| arg == "-XstartOnFirstThread") {
135            jvm_args.insert(0, "-XstartOnFirstThread".to_string());
136        }
137
138        // LWJGL needs java.library.path to find natives.
139        if !jvm_args.iter().any(|arg| arg.starts_with("-Djava.library.path=")) {
140            let natives_dir = variables.get(KEY_NATIVES_DIRECTORY).cloned().unwrap_or_default();
141            jvm_args.insert(0, format!("-Djava.library.path={}", natives_dir));
142        }
143
144        if !jvm_args.iter().any(|arg| arg.starts_with("-Dminecraft.launcher.brand=")) {
145            let launcher_name = variables.get(KEY_LAUNCHER_NAME).cloned().unwrap_or_default();
146            jvm_args.insert(0, format!("-Dminecraft.launcher.brand={}", launcher_name));
147        }
148
149        if !jvm_args.iter().any(|arg| arg.starts_with("-Dminecraft.launcher.version=")) {
150            let launcher_version = variables.get(KEY_LAUNCHER_VERSION).cloned().unwrap_or_default();
151            jvm_args.insert(0, format!("-Dminecraft.launcher.version={}", launcher_version));
152        }
153
154        // Classpath must be the last JVM arg before the main class.
155        let module_path_opt = jvm_args
156            .iter()
157            .position(|arg| arg == "-p")
158            .and_then(|p_idx| jvm_args.get(p_idx + 1).cloned());
159
160        if let Some(cp_idx) = jvm_args.iter().position(|arg| arg == CP_FLAG) {
161            if let Some(ref module_path) = module_path_opt {
162                lighty_core::trace_debug!("Module-path detected: {}", module_path);
163                if let Some(existing_cp) = jvm_args.get(cp_idx + 1) {
164                    let filtered_classpath =
165                        filter_classpath_from_modulepath(existing_cp, module_path);
166                    lighty_core::trace_debug!("Classpath filtered for module-path");
167
168                    jvm_args[cp_idx + 1] = filtered_classpath;
169                } else {
170                    lighty_core::trace_warn!("-cp found but no value after it");
171                }
172            } else {
173                lighty_core::trace_debug!("No module-path, keeping existing classpath unchanged");
174            }
175        } else {
176            let classpath = variables.get(KEY_CLASSPATH).cloned().unwrap_or_default();
177
178            if let Some(ref module_path) = module_path_opt {
179                lighty_core::trace_debug!("Module-path detected: {}", module_path);
180                let filtered_classpath = filter_classpath_from_modulepath(&classpath, module_path);
181                lighty_core::trace_debug!("Classpath filtered for module-path");
182
183                jvm_args.push(CP_FLAG.into());
184                jvm_args.push(filtered_classpath);
185            } else {
186                jvm_args.push(CP_FLAG.into());
187                jvm_args.push(classpath);
188            }
189        }
190
191        apply_jvm_overrides(&mut jvm_args, jvm_overrides);
192        apply_jvm_removals(&mut jvm_args, jvm_removals);
193        let game_args = apply_arg_removals(game_args, arg_removals);
194
195        let mut full_args = jvm_args;
196        full_args.push(builder.main_class.main_class.clone());
197        full_args.extend(game_args);
198
199        full_args.extend_from_slice(raw_args);
200
201        lighty_core::trace_debug!(args = ?full_args, "Launch arguments built");
202
203        full_args
204    }
205}
206
207/// Builds the launch-argument placeholder map.
208///
209/// `profile` drives every auth-related placeholder. When `None`, we keep
210/// the legacy hardcoded defaults (this is what the previous offline-only
211/// code path effectively produced, and what callers without an auth flow
212/// — tests, dry-run tools — get for free).
213fn create_variable_map<T: VersionInfo>(
214    version: &T,
215    builder: &Version,
216    profile: Option<&UserProfile>,
217) -> HashMap<String, String> {
218        let mut map = HashMap::new();
219
220        #[cfg(target_os = "windows")]
221        let classpath_separator = ";";
222        #[cfg(not(target_os = "windows"))]
223        let classpath_separator = ":";
224
225        // Auth placeholders fall back to legacy defaults when profile is None
226        // so offline/no-auth callers see the same argv as before.
227        let username = profile.map(|p| p.username.as_str()).unwrap_or("");
228        let uuid = profile.map(|p| p.uuid.as_str()).unwrap_or("");
229
230        // Token resolution: prefer the OS-keychain handle (read on demand)
231        // when present; fall back to the in-memory `SecretString`. The
232        // secret is exposed for the strict scope of the argv insertion.
233        let token_secret: Option<lighty_auth::SecretString> = profile.and_then(|p| {
234            #[cfg(feature = "keyring")]
235            if let Some(h) = &p.token_handle {
236                return h.read().ok();
237            }
238            p.access_token.clone()
239        });
240        let access_token = token_secret
241            .as_ref()
242            .map(|s| lighty_auth::ExposeSecret::expose_secret(s))
243            .unwrap_or(DEFAULT_ACCESS_TOKEN);
244        let xuid = profile
245            .and_then(|p| p.xuid.as_deref())
246            .unwrap_or(DEFAULT_XUID);
247        let user_type = profile
248            .map(|p| user_type_for(&p.provider))
249            .unwrap_or(DEFAULT_USER_TYPE);
250
251        map.insert(KEY_AUTH_PLAYER_NAME.into(), username.into());
252        map.insert(KEY_AUTH_UUID.into(), uuid.into());
253        map.insert(KEY_AUTH_ACCESS_TOKEN.into(), access_token.into());
254        map.insert(KEY_AUTH_XUID.into(), xuid.into());
255        map.insert(KEY_CLIENT_ID.into(), lighty_core::AppState::client_id().to_string());
256        map.insert(KEY_USER_TYPE.into(), user_type.into());
257        map.insert(KEY_USER_PROPERTIES.into(), DEFAULT_USER_PROPERTIES.into());
258
259        map.insert(KEY_VERSION_NAME.into(), version.name().into());
260        map.insert(KEY_VERSION_TYPE.into(), DEFAULT_VERSION_TYPE.into());
261
262        // runtime_dir() is the single source of truth shared with the install
263        // pipeline so mods land where the game actually scans for them.
264        map.insert(KEY_GAME_DIRECTORY.into(), version.runtime_dir().display().to_string());
265        map.insert(KEY_ASSETS_ROOT.into(), version.game_dirs().join("assets").display().to_string());
266        map.insert(KEY_NATIVES_DIRECTORY.into(), version.game_dirs().join("natives").display().to_string());
267        map.insert(KEY_LIBRARY_DIRECTORY.into(), version.game_dirs().join("libraries").display().to_string());
268
269        let assets_index_name = builder.assets_index
270            .as_ref()
271            .map(|idx| idx.id.clone())
272            .unwrap_or_else(|| version.minecraft_version().into());
273        map.insert(KEY_ASSETS_INDEX_NAME.into(), assets_index_name);
274
275        map.insert(KEY_LAUNCHER_NAME.into(), lighty_core::AppState::name().to_string());
276        map.insert(KEY_LAUNCHER_VERSION.into(), lighty_core::AppState::app_version().to_string());
277
278        let classpath = build_classpath(version, &builder.libraries);
279        map.insert(KEY_CLASSPATH.into(), classpath);
280        map.insert(KEY_CLASSPATH_SEPARATOR.into(), classpath_separator.to_string());
281
282        map
283}
284
285/// Builds the runtime classpath from the resolved library list.
286fn build_classpath<T: VersionInfo>(version: &T, libraries: &[lighty_loaders::types::version_metadata::Library]) -> String {
287        #[cfg(target_os = "windows")]
288        let separator = ";";
289        #[cfg(not(target_os = "windows"))]
290        let separator = ":";
291
292        let lib_dir = version.game_dirs().join("libraries");
293
294        let mut classpath_entries: Vec<String> = libraries
295            .iter()
296            .filter_map(|lib| {
297                lib.path.as_ref().map(|path| {
298                    lib_dir.join(path).display().to_string()
299                })
300            })
301            .collect();
302
303        classpath_entries.push(
304            version.game_dirs().join(format!("{}.jar", version.name())).display().to_string()
305        );
306
307        classpath_entries.join(separator)
308}
309
310/// Default JVM arguments used for legacy versions that don't ship one.
311fn build_default_jvm_args(variables: &HashMap<String, String>) -> Vec<String> {
312        let natives_dir = variables.get(KEY_NATIVES_DIRECTORY).cloned().unwrap_or_default();
313        let launcher_name = variables.get(KEY_LAUNCHER_NAME).cloned().unwrap_or_default();
314        let launcher_version = variables.get(KEY_LAUNCHER_VERSION).cloned().unwrap_or_default();
315        let classpath = variables.get(KEY_CLASSPATH).cloned().unwrap_or_default();
316
317        vec![
318            "-Xms1024M".into(),
319            "-Xmx2048M".into(),
320            format!("-Djava.library.path={}", natives_dir),
321            format!("-Dminecraft.launcher.brand={}", launcher_name),
322            format!("-Dminecraft.launcher.version={}", launcher_version),
323            CP_FLAG.into(),
324            classpath,
325        ]
326}
327
328
329/// Replaces variables in a vector of arguments efficiently
330fn replace_variables_in_vec(args: &[String], variables: &HashMap<String, String>) -> Vec<String> {
331    args.iter()
332        .map(|arg| replace_variables_cow(arg, variables).into_owned())
333        .collect()
334}
335
336/// Cow-based variable replacement. Only allocates when replacements are needed.
337fn replace_variables_cow<'a>(
338    input: &'a str,
339    variables: &HashMap<String, String>
340) -> Cow<'a, str> {
341    if !input.contains("${") {
342        return Cow::Borrowed(input);
343    }
344
345    let mut result = String::with_capacity(input.len() + 128);
346    let mut last_end = 0;
347
348    for (start, _) in input.match_indices("${") {
349        if let Some(end_offset) = input[start..].find('}') {
350            let end = start + end_offset;
351            let key = &input[start + 2..end];
352
353            result.push_str(&input[last_end..start]);
354
355            if let Some(value) = variables.get(key) {
356                result.push_str(value);
357            } else {
358                result.push_str(&input[start..=end]);
359            }
360
361            last_end = end + 1;
362        }
363    }
364
365    result.push_str(&input[last_end..]);
366    Cow::Owned(result)
367}
368
369/// Applies JVM overrides, prepending the `-` prefix automatically.
370///
371/// Format examples:
372/// - `Xmx` → `-Xmx`
373/// - `XX:+UseG1GC` → `-XX:+UseG1GC`
374/// - `Djava.library.path` → `-Djava.library.path`
375fn apply_jvm_overrides(jvm_args: &mut Vec<String>, jvm_overrides: &HashMap<String, String>) {
376    for (key, value) in jvm_overrides {
377        let formatted_option = format_jvm_option(key, value);
378
379        let key_prefix = format!("-{}", key.split('=').next().unwrap_or(key));
380        if let Some(pos) = jvm_args.iter().position(|arg| arg.starts_with(&key_prefix)) {
381            jvm_args[pos] = formatted_option;
382        } else {
383            // Insert before -cp so the classpath stays last.
384            if let Some(cp_pos) = jvm_args.iter().position(|arg| arg == CP_FLAG) {
385                jvm_args.insert(cp_pos, formatted_option);
386            } else {
387                jvm_args.push(formatted_option);
388            }
389        }
390    }
391}
392
393/// Formats a JVM option using the appropriate separator for its kind.
394///
395/// # Examples
396/// - `("Xmx", "4G")` → `-Xmx4G`
397/// - `("Xms", "2G")` → `-Xms2G`
398/// - `("XX:+UseG1GC", "")` → `-XX:+UseG1GC`
399/// - `("Djava.library.path", "/path")` → `-Djava.library.path=/path`
400fn format_jvm_option(key: &str, value: &str) -> String {
401    if value.is_empty() {
402        format!("-{}", key)
403    } else if key.starts_with('X') && !key.contains(':') && !key.contains('=') {
404        // -Xmx, -Xms, etc. — no separator between key and value.
405        format!("-{}{}", key, value)
406    } else {
407        // -D, -XX:, etc. — `=` separator.
408        format!("-{}={}", key, value)
409    }
410}
411
412/// Removes JVM options whose key appears in `jvm_removals`.
413fn apply_jvm_removals(jvm_args: &mut Vec<String>, jvm_removals: &HashSet<String>) {
414    jvm_args.retain(|arg| {
415        let arg_key = if let Some(stripped) = arg.strip_prefix('-') {
416            stripped.split('=').next().unwrap_or(stripped)
417                .split(|c: char| c.is_numeric()).next().unwrap_or(stripped)
418        } else {
419            return true;
420        };
421
422        !jvm_removals.contains(arg_key)
423    });
424}
425
426/// Filters out game arguments whose key matches an entry in `arg_removals`.
427fn apply_arg_removals(game_args: Vec<String>, arg_removals: &HashSet<String>) -> Vec<String> {
428    game_args.into_iter()
429        .filter(|arg| {
430            !arg_removals.iter().any(|removal| {
431                arg == removal || arg.starts_with(&format!("--{}", removal))
432            })
433        })
434        .collect()
435}
436
437/// Filters the classpath, excluding JARs whose artifact already appears
438/// on the module-path (matched by base name, version-agnostic).
439fn filter_classpath_from_modulepath(classpath: &str, module_path: &str) -> String {
440    let separator = get_path_separator();
441
442    // Extract base artifact name from a jar filename: "asm-analysis-9.5.jar" -> "asm-analysis"
443    let module_artifacts: std::collections::HashSet<String> = module_path
444        .split(separator)
445        .filter_map(|path| {
446            std::path::Path::new(path)
447                .file_name()
448                .and_then(|f| f.to_str())
449                .and_then(|filename| {
450                    if let Some(stem) = filename.strip_suffix(".jar") {
451                        let mut base_name = stem;
452                        if let Some(pos) = stem.rfind(|c: char| c.is_ascii_digit()) {
453                            if let Some(dash_pos) = stem[..pos].rfind('-') {
454                                base_name = &stem[..dash_pos];
455                            }
456                        }
457                        Some(base_name.to_string())
458                    } else {
459                        None
460                    }
461                })
462        })
463        .collect();
464
465    lighty_core::trace_debug!("Module artifacts to exclude: {:?}", module_artifacts);
466
467    classpath
468        .split(separator)
469        .filter(|path| {
470            if let Some(filename) = std::path::Path::new(path)
471                .file_name()
472                .and_then(|f| f.to_str())
473            {
474                if let Some(stem) = filename.strip_suffix(".jar") {
475                    let base_name = if let Some(pos) = stem.rfind(|c: char| c.is_ascii_digit()) {
476                        if let Some(dash_pos) = stem[..pos].rfind('-') {
477                            &stem[..dash_pos]
478                        } else {
479                            stem
480                        }
481                    } else {
482                        stem
483                    };
484                    !module_artifacts.contains(base_name)
485                } else {
486                    true
487                }
488            } else {
489                true
490            }
491        })
492        .collect::<Vec<&str>>()
493        .join(separator)
494}
495
496fn get_path_separator() -> &'static str {
497    #[cfg(target_os = "windows")] {
498        ";"
499    }
500    #[cfg(not(target_os = "windows"))] {
501        ":"
502    }
503}