Skip to main content

minecraft_java_rs_core/launcher/
mod.rs

1pub mod events;
2pub mod game_data;
3pub mod options;
4
5pub use events::LaunchEvent;
6
7use std::path::PathBuf;
8use std::process::Stdio;
9
10use tokio::io::{AsyncBufReadExt, BufReader};
11use tokio::sync::mpsc::Sender;
12
13use crate::error::LaunchError;
14use crate::game::{
15    arguments::{get_classpath, get_game_arguments, get_jvm_arguments, LoaderContext},
16    assets::{copy_assets, get_assets},
17    bundle::{check_bundle, check_files},
18    java::get_java_files,
19    libraries::{extract_natives, get_assets_others, get_libraries},
20    version::get_version_json,
21};
22use crate::launcher::game_data::{load_game_data, save_game_data, GameData, JavaInfo};
23use crate::launcher::options::LaunchOptions;
24use crate::loader::{create_loader, types::LoaderInstallInput};
25use crate::models::loader::LoaderType;
26use crate::models::minecraft::AssetItem;
27use crate::net::check::check_internet;
28use crate::net::downloader::Downloader;
29use crate::utils::version_check::is_old;
30
31// ── Launcher ──────────────────────────────────────────────────────────────────
32
33pub struct Launcher {
34    options: LaunchOptions,
35    game_data: Option<GameData>,
36}
37
38impl Launcher {
39    pub fn new(mut options: LaunchOptions) -> Self {
40        // Absolutize options.path so every path derived from it (classpath,
41        // natives, game args, java binary) works even when the Java process
42        // runs with a different current_dir (e.g. save_dir for Tauri).
43        if options.path.is_relative() {
44            if let Ok(abs) = std::env::current_dir().map(|cwd| cwd.join(&options.path)) {
45                options.path = abs;
46            }
47        }
48        Self {
49            options,
50            game_data: None,
51        }
52    }
53
54    pub fn options(&self) -> &LaunchOptions {
55        &self.options
56    }
57
58    pub fn game_data(&self) -> Option<&GameData> {
59        self.game_data.as_ref()
60    }
61
62    /// Download, verify, and optionally install a mod loader for the configured
63    /// Minecraft version. Stores the result in `self.game_data`.
64    ///
65    /// Emits progress events on `event_tx`. After this call,
66    /// [`Launcher::launch`] can be invoked without downloading again.
67    ///
68    /// If there is no internet connection and a valid cache exists, the cache
69    /// is loaded without network access. If there is no cache either, returns
70    /// [`LaunchError::NoInternetNoCache`].
71    pub async fn download_game(
72        &mut self,
73        event_tx: Sender<LaunchEvent>,
74    ) -> Result<(), LaunchError> {
75        let options = &self.options;
76
77        // ── Offline fast-path ─────────────────────────────────────────────────
78        if !check_internet().await {
79            self.game_data = Some(
80                load_game_data(&options.save_dir())
81                    .await
82                    .map_err(|_| LaunchError::NoInternetNoCache)?,
83            );
84            return Ok(());
85        }
86
87        // ── Cache fast-path (skip_bundle_check) ──────────────────────────────
88        // "Skip if possible" hint: load from cache and return early when the
89        // caller trusts the existing installation.  If the cache is absent,
90        // fall through to the full download path without error.
91        //
92        // Java is the one thing we never skip: without the runtime the process
93        // can't even spawn (start() fails with os error 2), so `is_corrupt_crash`
94        // would never get to see the crash logs produced by any missing game
95        // files. We therefore always ensure Java is present here, while still
96        // skipping the (expensive) bundle integrity check for everything else.
97        if options.skip_bundle_check {
98            if let Ok(mut cached) = load_game_data(&options.save_dir()).await {
99                let java_present = std::path::Path::new(&cached.minecraft_java.path).exists();
100                if !java_present {
101                    let client = crate::net::client::build_client(
102                        options.timeout_secs,
103                        options.force_ipv4,
104                        options.dns,
105                    )
106                    .map_err(LaunchError::Http)?;
107                    let java_result =
108                        get_java_files(options, &cached.minecraft_json, &client, &event_tx).await?;
109                    cached.minecraft_java = JavaInfo {
110                        files: java_result.files,
111                        path: java_result.java_path,
112                    };
113                    save_game_data(&options.save_dir(), &cached).await?;
114                }
115                self.game_data = Some(cached);
116                let _ = event_tx.send(LaunchEvent::GameDownloadFinished).await;
117                return Ok(());
118            }
119            // Cache absent → continue with normal download.
120        }
121
122        // ── Shared HTTP client ────────────────────────────────────────────────
123        let client =
124            crate::net::client::build_client(options.timeout_secs, options.force_ipv4, options.dns)
125                .map_err(LaunchError::Http)?;
126
127        // ── Version JSON ──────────────────────────────────────────────────────
128        let mut version_json = get_version_json(options, &client).await?;
129        let mc_version = version_json.id.clone();
130
131        // ── File bundle ───────────────────────────────────────────────────────
132        let mut bundle: Vec<AssetItem> = Vec::new();
133        bundle.extend(get_libraries(options, &version_json));
134        bundle.extend(get_assets_others(options, options.url.as_deref(), &client).await?);
135        bundle.extend(get_assets(options, &version_json, &client).await?);
136
137        // Java runtime download is managed separately (has its own concurrency
138        // and progress reporting); its files are not added to the bundle.
139        let java_result = get_java_files(options, &version_json, &client, &event_tx).await?;
140
141        // ── Bundle integrity check & download ─────────────────────────────────
142        let pending =
143            check_bundle(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
144        if !pending.is_empty() {
145            let downloader = Downloader::new(
146                options.timeout_secs,
147                options.clamped_concurrency(),
148                options.force_ipv4,
149                options.dns,
150            );
151            downloader
152                .download_multiple(pending, event_tx.clone())
153                .await?;
154        }
155
156        // ── Mod loader install ────────────────────────────────────────────────
157        let (
158            loader_libraries,
159            loader_main_class,
160            loader_version_id,
161            loader_type,
162            loader_extra_game_args,
163            loader_extra_jvm_args,
164        ) = if options.loader.enable {
165            if let Some(loader_type) = &options.loader.loader_type {
166                let mc_jar = options
167                    .path
168                    .join("versions")
169                    .join(&mc_version)
170                    .join(format!("{mc_version}.jar"))
171                    .to_string_lossy()
172                    .into_owned();
173                let mc_json = options
174                    .path
175                    .join("versions")
176                    .join(&mc_version)
177                    .join(format!("{mc_version}.json"))
178                    .to_string_lossy()
179                    .into_owned();
180
181                let input = LoaderInstallInput {
182                    mc_version: mc_version.clone(),
183                    java_path: java_result.java_path.clone(),
184                    mc_jar,
185                    mc_json,
186                };
187
188                let loader_impl = create_loader(loader_type.clone());
189                let result = loader_impl
190                    .install(options, &input, &client, &event_tx)
191                    .await?;
192                (
193                    result.libraries,
194                    result.main_class,
195                    Some(result.loader_version),
196                    Some(result.loader_type),
197                    result.extra_game_args,
198                    result.extra_jvm_args,
199                )
200            } else {
201                (vec![], None, None, None, vec![], vec![])
202            }
203        } else {
204            (vec![], None, None, None, vec![], vec![])
205        };
206
207        // ── Download Forge/NeoForge runtime libraries ─────────────────────────
208        // The loader install step (above) downloads processor/install-time JARs
209        // but NOT the runtime classpath libraries listed in version.json (e.g.
210        // bootstraplauncher, securejarhandler, modlauncher).  We check and
211        // download them here.  When --installClient was used the files already
212        // exist and check_bundle returns an empty pending list immediately.
213        if !loader_libraries.is_empty() {
214            let loader_pending = check_bundle(
215                &loader_libraries,
216                &event_tx,
217                options.clamped_verify_concurrency(),
218            )
219            .await?;
220            if !loader_pending.is_empty() {
221                let downloader = Downloader::new(
222                    options.timeout_secs,
223                    options.clamped_concurrency(),
224                    options.force_ipv4,
225                    options.dns,
226                );
227                downloader
228                    .download_multiple(loader_pending, event_tx.clone())
229                    .await?;
230            }
231        }
232
233        // ── Optional post-download SHA-1 verify ───────────────────────────────
234        if options.verify {
235            check_files(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
236        }
237
238        // ── Extract native JARs ───────────────────────────────────────────────
239        extract_natives(options, &version_json, &bundle).await?;
240        version_json.has_natives = bundle
241            .iter()
242            .any(|item| matches!(item, AssetItem::NativeAsset { .. }));
243
244        // ── Legacy asset copy (pre-1.6) ───────────────────────────────────────
245        if is_old(version_json.assets.as_deref()) {
246            copy_assets(options, &version_json).await?;
247        }
248
249        // ── Persist & store ───────────────────────────────────────────────────
250        let game_data = GameData {
251            minecraft_json: version_json,
252            minecraft_loader: None,
253            minecraft_version: mc_version,
254            minecraft_java: JavaInfo {
255                files: java_result.files,
256                path: java_result.java_path,
257            },
258            loader_libraries,
259            loader_main_class,
260            loader_version_id,
261            loader_type,
262            loader_extra_game_args,
263            loader_extra_jvm_args,
264        };
265
266        save_game_data(&options.save_dir(), &game_data).await?;
267        self.game_data = Some(game_data);
268
269        let _ = event_tx.send(LaunchEvent::GameDownloadFinished).await;
270
271        Ok(())
272    }
273
274    /// Assemble the Java command line and spawn the Minecraft process.
275    ///
276    /// Resolves game data from `self.game_data` (set by [`Launcher::download_game`])
277    /// or, if absent, from the persisted cache on disk. Returns
278    /// [`LaunchError::GameDataNotReady`] if neither is available.
279    ///
280    /// Stdout and stderr are piped; each line is forwarded as a
281    /// [`LaunchEvent::Data`] event. The caller is responsible for calling
282    /// `child.wait()` and emitting [`LaunchEvent::Close`] when appropriate.
283    pub async fn launch(
284        &self,
285        event_tx: Sender<LaunchEvent>,
286    ) -> Result<tokio::process::Child, LaunchError> {
287        let loaded;
288        let game_data: &GameData = match &self.game_data {
289            Some(gd) => gd,
290            None => {
291                loaded = load_game_data(&self.options.save_dir())
292                    .await
293                    .map_err(|_| LaunchError::GameDataNotReady)?;
294                &loaded
295            }
296        };
297
298        let options = &self.options;
299        let version_json = &game_data.minecraft_json;
300
301        // Natives directory used for -Djava.library.path.
302        let natives_path: PathBuf = options
303            .path
304            .join("versions")
305            .join(&version_json.id)
306            .join("natives");
307
308        // Build the classpath: loader libraries FIRST so Forge/NeoForge classes
309        // take precedence over vanilla when there are collisions.
310        let mut bundle: Vec<AssetItem> = game_data.loader_libraries.clone();
311        let mut vanilla_libs = get_libraries(options, version_json);
312        // Modern Forge (1.17+) and NeoForge use the bootstraplauncher which manages
313        // Minecraft classes via client-slim.jar. Including the full vanilla jar
314        // causes split-package conflicts in the Java module layer.
315        // Old Forge (pre-1.17, no module path args) uses FML's class patcher which
316        // needs the vanilla jar directly on the classpath — don't exclude it there.
317        let uses_module_path = game_data
318            .loader_extra_jvm_args
319            .iter()
320            .any(|a| a == "-p" || a == "--module-path");
321        let exclude_vanilla_jar = matches!(game_data.loader_type, Some(LoaderType::NeoForge))
322            || (matches!(game_data.loader_type, Some(LoaderType::Forge)) && uses_module_path);
323        if exclude_vanilla_jar {
324            let mc_jar = options
325                .path
326                .join("versions")
327                .join(&version_json.id)
328                .join(format!("{}.jar", version_json.id))
329                .to_string_lossy()
330                .into_owned();
331            vanilla_libs
332                .retain(|lib| !matches!(lib, AssetItem::Asset { path, .. } if path == &mc_jar));
333        }
334        bundle.extend(vanilla_libs);
335
336        // Argument assembly.
337        let loader_ctx = game_data
338            .loader_version_id
339            .as_ref()
340            .map(|vid| LoaderContext {
341                loader_type: game_data.loader_type.as_ref(),
342                version_id: Some(vid.as_str()),
343                extra_game_args: &game_data.loader_extra_game_args,
344                extra_jvm_args: &game_data.loader_extra_jvm_args,
345            });
346        let jvm_args = get_jvm_arguments(options, version_json, &natives_path, loader_ctx.as_ref());
347        let mut game_args = get_game_arguments(options, version_json, loader_ctx.as_ref());
348        let (cp_args, vanilla_main_class) = get_classpath(version_json, &bundle);
349
350        // Screen size / fullscreen (conditional args from the version JSON are
351        // skipped by get_game_arguments, so we add them here explicitly).
352        if let Some(w) = options.screen.width {
353            game_args.push("--width".into());
354            game_args.push(w.to_string());
355        }
356        if let Some(h) = options.screen.height {
357            game_args.push("--height".into());
358            game_args.push(h.to_string());
359        }
360        if options.screen.fullscreen {
361            game_args.push("--fullscreen".into());
362        }
363
364        let main_class = game_data
365            .loader_main_class
366            .as_deref()
367            .unwrap_or(&vanilla_main_class)
368            .to_owned();
369
370        // Collect JARs on the Java module path (-p flag) from JVM args so we
371        // can exclude them from -cp. NeoForge places bootstrap JARs on the
372        // module path; having them on both paths causes IllegalStateException.
373        let module_path_jars: std::collections::HashSet<String> = {
374            let mut set = std::collections::HashSet::new();
375            let mut iter = jvm_args.iter().peekable();
376            while let Some(arg) = iter.next() {
377                if arg == "-p" {
378                    if let Some(module_path) = iter.next() {
379                        for jar in module_path.split(':') {
380                            // Normalize to a canonical filename for matching.
381                            if let Some(name) = std::path::Path::new(jar).file_name() {
382                                set.insert(name.to_string_lossy().into_owned());
383                            }
384                        }
385                    }
386                }
387            }
388            set
389        };
390
391        // Filter module-path JARs out of -cp to avoid duplicate module errors.
392        let cp_args = if module_path_jars.is_empty() {
393            cp_args
394        } else {
395            cp_args
396                .into_iter()
397                .map(|arg| {
398                    // The classpath string is the arg after "-cp".
399                    if arg.contains(':') || arg.ends_with(".jar") {
400                        let filtered: Vec<&str> = arg
401                            .split(':')
402                            .filter(|entry| {
403                                let fname = std::path::Path::new(entry)
404                                    .file_name()
405                                    .map(|f| f.to_string_lossy().into_owned())
406                                    .unwrap_or_default();
407                                !module_path_jars.contains(&fname)
408                            })
409                            .collect();
410                        filtered.join(":")
411                    } else {
412                        arg
413                    }
414                })
415                .collect()
416        };
417
418        let mut all_args: Vec<String> = Vec::new();
419        all_args.extend(jvm_args);
420        #[cfg(target_os = "linux")]
421        all_args.push("-DGLFW_PLATFORM=x11".into());
422        all_args.extend(cp_args);
423        all_args.push(main_class);
424        all_args.extend(game_args);
425
426        let java_path_raw = &game_data.minecraft_java.path;
427        // Resolve to an absolute path so the binary is found regardless of
428        // what current_dir is set to below.
429        let java_path_buf = std::path::Path::new(java_path_raw)
430            .canonicalize()
431            .unwrap_or_else(|_| std::path::PathBuf::from(java_path_raw));
432        let java_path = java_path_buf.to_string_lossy();
433
434        // Sanitize auth token before logging the command.
435        let access_token = &options.authenticator.access_token;
436        let cmd_str = format!("{} {}", java_path, all_args.join(" "));
437        let sanitized = if access_token.is_empty() {
438            cmd_str
439        } else {
440            cmd_str.replace(access_token.as_str(), "<access_token>")
441        };
442        let _ = event_tx.send(LaunchEvent::Data(sanitized)).await;
443
444        // Spawn the process.
445        let mut cmd = tokio::process::Command::new(java_path.as_ref());
446        cmd.args(&all_args)
447            .current_dir(options.save_dir())
448            .stdout(Stdio::piped())
449            .stderr(Stdio::piped());
450
451        // On Linux, force GLFW 3.4+ to use X11 via XWayland to avoid the
452        // [0x1000C] "Wayland: does not provide window position" error that
453        // Forge treats as fatal in its strict GLX._initGlfw error callback.
454        //
455        // DISPLAY may not be exported on pure Wayland sessions (e.g. GNOME on
456        // Fedora with on-demand XWayland) even when XWayland is available, so
457        // we fall back to probing the X11 socket directly.
458        //
459        // Removing WAYLAND_DISPLAY alone is not enough: libwayland's
460        // wl_display_connect(NULL) falls back to "wayland-0" via
461        // XDG_RUNTIME_DIR even when WAYLAND_DISPLAY is absent. Setting
462        // WAYLAND_SOCKET to a non-numeric value causes wl_display_connect to
463        // return NULL immediately (before the fallback path), so GLFW's Wayland
464        // backend fails and it falls through to X11.
465        #[cfg(target_os = "linux")]
466        {
467            let display = std::env::var_os("DISPLAY").or_else(|| {
468                (0..10u8).find_map(|n| {
469                    let sock = format!("/tmp/.X11-unix/X{n}");
470                    std::path::Path::new(&sock)
471                        .exists()
472                        .then(|| format!(":{n}").into())
473                })
474            });
475            if let Some(disp) = display {
476                cmd.env("DISPLAY", disp);
477                cmd.env_remove("WAYLAND_DISPLAY");
478                cmd.env("GLFW_PLATFORM", "x11");
479                cmd.env("WAYLAND_SOCKET", "invalid");
480            }
481        }
482
483        // LWJGL 2 (old Minecraft, pre-1.13) runs `xrandr` at startup via
484        // Runtime.exec() to enumerate display modes. If xrandr is not installed
485        // the subprocess returns nothing, getScreenNames() returns an empty
486        // array, and LinuxDisplay:951 throws ArrayIndexOutOfBoundsException: 0.
487        // Fix: if xrandr is missing, drop a minimal stub script into a cache
488        // dir and prepend that dir to PATH so Java finds it first.
489        #[cfg(target_os = "linux")]
490        if crate::game::lwjgl_native::uses_lwjgl2(version_json)
491            && !crate::game::lwjgl_native::xrandr_in_path()
492        {
493            let stub_dir = options.path.join("cache").join("xrandr-stub");
494            if crate::game::lwjgl_native::write_xrandr_stub(&stub_dir)
495                .await
496                .is_ok()
497            {
498                let base_path = std::env::var("PATH").unwrap_or_default();
499                cmd.env(
500                    "PATH",
501                    format!("{}:{}", stub_dir.to_string_lossy(), base_path),
502                );
503            }
504        }
505
506        let mut child = cmd
507            .spawn()
508            .map_err(|e| LaunchError::ProcessError(e.to_string()))?;
509
510        // Pipe stdout lines → LaunchEvent::Data.
511        if let Some(stdout) = child.stdout.take() {
512            let tx = event_tx.clone();
513            tokio::spawn(async move {
514                let mut lines = BufReader::new(stdout).lines();
515                while let Ok(Some(line)) = lines.next_line().await {
516                    let _ = tx.send(LaunchEvent::Data(line)).await;
517                }
518            });
519        }
520
521        // Pipe stderr lines → LaunchEvent::Data.
522        if let Some(stderr) = child.stderr.take() {
523            let tx = event_tx;
524            tokio::spawn(async move {
525                let mut lines = BufReader::new(stderr).lines();
526                while let Ok(Some(line)) = lines.next_line().await {
527                    let _ = tx.send(LaunchEvent::Data(line)).await;
528                }
529            });
530        }
531
532        Ok(child)
533    }
534
535    /// Download the game and immediately launch it.
536    ///
537    /// Equivalent to `download_game` followed by `launch`. Returns the
538    /// [`tokio::process::Child`] handle so the caller can monitor or kill
539    /// the process.
540    ///
541    /// To receive a [`LaunchEvent::Close`] event, wait on the returned child
542    /// and send it yourself:
543    /// ```ignore
544    /// let code = child.wait().await?.code().unwrap_or(-1);
545    /// let _ = tx.send(LaunchEvent::Close(code)).await;
546    /// ```
547    pub async fn start(
548        &mut self,
549        event_tx: Sender<LaunchEvent>,
550    ) -> Result<tokio::process::Child, LaunchError> {
551        self.download_game(event_tx.clone()).await?;
552        self.launch(event_tx).await
553    }
554
555    /// Heuristically detect whether a game crash was caused by a corrupt or
556    /// incomplete installation.
557    ///
558    /// Returns `true` when `exit_code` is non-zero **and** at least one log
559    /// line matches a known corrupt-installation pattern. The caller can use
560    /// this as a signal to force a full re-check by calling `download_game()`
561    /// with `skip_bundle_check: false` on the next attempt.
562    ///
563    /// # Example
564    /// ```ignore
565    /// let code = child.wait().await?.code().unwrap_or(-1);
566    /// let lines: Vec<String> = /* collected LaunchEvent::Data lines */;
567    /// if Launcher::is_corrupt_crash(code, &lines) {
568    ///     // Re-run download_game with skip_bundle_check: false
569    /// }
570    /// ```
571    pub fn is_corrupt_crash(exit_code: i32, logs: &[String]) -> bool {
572        if exit_code == 0 {
573            return false;
574        }
575        // Match only on JVM exception class names, which the runtime prints
576        // verbatim regardless of its locale. Localized prose messages (e.g.
577        // "Error: Could not find or load main class", "Unable to access
578        // jarfile", "Error opening zip file") are translated on non-English
579        // JVMs and must not be relied on — the corresponding exception is
580        // always present alongside them and is locale-independent:
581        //   missing main class / jar  → ClassNotFoundException / NoClassDefFoundError
582        //   missing native library    → UnsatisfiedLinkError
583        //   missing file              → FileNotFoundException / NoSuchFileException
584        //   corrupt archive           → ZipException
585        const PATTERNS: &[&str] = &[
586            "NoClassDefFoundError",
587            "ClassNotFoundException",
588            "UnsatisfiedLinkError",
589            "FileNotFoundException",
590            "NoSuchFileException",
591            "ZipException",
592        ];
593        logs.iter()
594            .any(|line| PATTERNS.iter().any(|pat| line.contains(pat)))
595    }
596}
597
598// ── Tests ─────────────────────────────────────────────────────────────────────
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use std::path::PathBuf;
604
605    fn make_options() -> LaunchOptions {
606        use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
607        use crate::models::minecraft::Authenticator;
608        LaunchOptions {
609            path: PathBuf::from("/mc"),
610            version: "1.20.4".into(),
611            authenticator: Authenticator {
612                access_token: "test-token".into(),
613                name: "Player".into(),
614                uuid: "test-uuid".into(),
615                xbox_account: None,
616                user_properties: None,
617                client_id: None,
618                client_token: None,
619            },
620            timeout_secs: 10,
621            download_concurrency: 5,
622            verify_concurrency: 4,
623            memory: MemoryConfig::default(),
624            java: JavaOptions::default(),
625            loader: LoaderConfig::default(),
626            screen: ScreenConfig::default(),
627            verify: false,
628            game_args: vec![],
629            jvm_args: vec![],
630            instance: None,
631            url: None,
632            mcp: None,
633            intel_enabled_mac: false,
634            bypass_offline: false,
635            skip_bundle_check: false,
636            force_ipv4: false,
637            dns: None,
638        }
639    }
640
641    #[test]
642    fn launcher_new_stores_options() {
643        let opts = make_options();
644        let launcher = Launcher::new(opts.clone());
645        assert_eq!(launcher.options.version, "1.20.4");
646        assert_eq!(launcher.options.path, PathBuf::from("/mc"));
647    }
648
649    #[test]
650    fn launcher_save_dir_no_instance() {
651        let opts = make_options();
652        let launcher = Launcher::new(opts);
653        assert_eq!(launcher.options.save_dir(), PathBuf::from("/mc"));
654    }
655
656    #[test]
657    fn launcher_save_dir_with_instance() {
658        let mut opts = make_options();
659        opts.instance = Some("myworld".into());
660        let launcher = Launcher::new(opts);
661        assert_eq!(
662            launcher.options.save_dir(),
663            PathBuf::from("/mc/instances/myworld")
664        );
665    }
666
667    #[test]
668    fn sanitize_replaces_access_token() {
669        let token = "secret-access-token";
670        let cmd = format!("java -cp foo.jar Main --accessToken {token}");
671        let sanitized = cmd.replace(token, "<access_token>");
672        assert!(!sanitized.contains(token));
673        assert!(sanitized.contains("<access_token>"));
674    }
675
676    #[test]
677    fn all_args_order_is_correct() {
678        // Verify the expected CLI ordering: jvm_args, -cp, classpath, main_class, game_args
679        let jvm: Vec<String> = vec!["-Xms1G".into(), "-Xmx2G".into()];
680        let cp: Vec<String> = vec!["-cp".into(), "a.jar:b.jar".into()];
681        let main_class = "net.minecraft.client.main.Main".to_owned();
682        let game: Vec<String> = vec!["--username".into(), "Player".into()];
683
684        let mut all: Vec<String> = Vec::new();
685        all.extend(jvm);
686        all.extend(cp);
687        all.push(main_class.clone());
688        all.extend(game);
689
690        assert_eq!(all[0], "-Xms1G");
691        assert_eq!(all[2], "-cp");
692        assert_eq!(all[4], main_class);
693        assert_eq!(all[5], "--username");
694    }
695
696    #[test]
697    fn screen_args_appended_when_set() {
698        use crate::launcher::options::ScreenConfig;
699        let screen = ScreenConfig {
700            width: Some(1920),
701            height: Some(1080),
702            fullscreen: false,
703        };
704        let mut game_args: Vec<String> = vec!["--version".into(), "1.20.4".into()];
705        if let Some(w) = screen.width {
706            game_args.push("--width".into());
707            game_args.push(w.to_string());
708        }
709        if let Some(h) = screen.height {
710            game_args.push("--height".into());
711            game_args.push(h.to_string());
712        }
713        assert!(game_args.contains(&"--width".to_string()));
714        assert!(game_args.contains(&"1920".to_string()));
715        assert!(game_args.contains(&"--height".to_string()));
716        assert!(game_args.contains(&"1080".to_string()));
717        assert!(!game_args.contains(&"--fullscreen".to_string()));
718    }
719
720    #[test]
721    fn screen_fullscreen_appended_when_set() {
722        use crate::launcher::options::ScreenConfig;
723        let screen = ScreenConfig {
724            width: None,
725            height: None,
726            fullscreen: true,
727        };
728        let mut game_args: Vec<String> = vec![];
729        if screen.fullscreen {
730            game_args.push("--fullscreen".into());
731        }
732        assert!(game_args.contains(&"--fullscreen".to_string()));
733    }
734
735    #[test]
736    fn loader_main_class_overrides_vanilla() {
737        let vanilla = "net.minecraft.client.main.Main".to_owned();
738        let loader_main_class: Option<String> =
739            Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into());
740        let main_class = loader_main_class.as_deref().unwrap_or(&vanilla).to_owned();
741        assert_eq!(
742            main_class,
743            "net.fabricmc.loader.impl.launch.knot.KnotClient"
744        );
745    }
746
747    #[test]
748    fn no_loader_main_class_uses_vanilla() {
749        let vanilla = "net.minecraft.client.main.Main".to_owned();
750        let loader_main_class: Option<String> = None;
751        let main_class = loader_main_class.as_deref().unwrap_or(&vanilla).to_owned();
752        assert_eq!(main_class, "net.minecraft.client.main.Main");
753    }
754
755    // ── is_corrupt_crash ─────────────────────────────────────────────────────
756
757    #[test]
758    fn corrupt_crash_zero_exit_always_false() {
759        let logs = vec!["NoClassDefFoundError: net/minecraft/Foo".into()];
760        assert!(!Launcher::is_corrupt_crash(0, &logs));
761    }
762
763    #[test]
764    fn corrupt_crash_nonzero_no_pattern_false() {
765        let logs = vec!["Exception in thread \"main\" java.lang.RuntimeException".into()];
766        assert!(!Launcher::is_corrupt_crash(1, &logs));
767    }
768
769    #[test]
770    fn corrupt_crash_empty_logs_false() {
771        assert!(!Launcher::is_corrupt_crash(1, &[]));
772    }
773
774    #[test]
775    fn corrupt_crash_all_patterns_detected() {
776        let cases = [
777            "java.lang.NoClassDefFoundError: Foo",
778            "java.lang.ClassNotFoundException: net.minecraft.Main",
779            "java.lang.UnsatisfiedLinkError: /lib/foo.so",
780            "java.io.FileNotFoundException: /mc/lib.jar (No such file)",
781            "java.nio.file.NoSuchFileException: /mc/versions/1.20.4/1.20.4.jar",
782            "java.util.zip.ZipException: invalid LOC header",
783        ];
784        for case in &cases {
785            assert!(
786                Launcher::is_corrupt_crash(1, &[case.to_string()]),
787                "pattern not detected: {case}"
788            );
789        }
790    }
791
792    #[test]
793    fn corrupt_crash_detected_on_localized_main_class_error() {
794        // A non-English JVM translates the prose line but the cause exception
795        // (ClassNotFoundException) is locale-independent and must still match.
796        let logs = vec![
797            "Error: no se ha encontrado o cargado la clase principal net.minecraft.client.main.Main".into(),
798            "Causado por: java.lang.ClassNotFoundException: net.minecraft.client.main.Main".into(),
799        ];
800        assert!(Launcher::is_corrupt_crash(1, &logs));
801    }
802}