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