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
31pub struct Launcher {
34 options: LaunchOptions,
35 game_data: Option<GameData>,
36}
37
38impl Launcher {
39 pub fn new(mut options: LaunchOptions) -> Self {
40 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 pub async fn download_game(
72 &mut self,
73 event_tx: Sender<LaunchEvent>,
74 ) -> Result<(), LaunchError> {
75 let options = &self.options;
76
77 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 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 }
121
122 let client =
124 crate::net::client::build_client(options.timeout_secs, options.force_ipv4, options.dns)
125 .map_err(LaunchError::Http)?;
126
127 let mut version_json = get_version_json(options, &client).await?;
129 let mc_version = version_json.id.clone();
130
131 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 let java_result = get_java_files(options, &version_json, &client, &event_tx).await?;
140
141 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 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 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 if options.verify {
235 check_files(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
236 }
237
238 extract_natives(options, &version_json, &bundle).await?;
240 version_json.has_natives = bundle
241 .iter()
242 .any(|item| matches!(item, AssetItem::NativeAsset { .. }));
243
244 if is_old(version_json.assets.as_deref()) {
246 copy_assets(options, &version_json).await?;
247 }
248
249 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 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 let natives_path: PathBuf = options
303 .path
304 .join("versions")
305 .join(&version_json.id)
306 .join("natives");
307
308 let mut bundle: Vec<AssetItem> = game_data.loader_libraries.clone();
311 let mut vanilla_libs = get_libraries(options, version_json);
312 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 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 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 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 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 let cp_args = if module_path_jars.is_empty() {
393 cp_args
394 } else {
395 cp_args
396 .into_iter()
397 .map(|arg| {
398 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 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 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 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 #[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 #[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 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 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 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 pub fn is_corrupt_crash(exit_code: i32, logs: &[String]) -> bool {
572 if exit_code == 0 {
573 return false;
574 }
575 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#[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 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 #[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 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}