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