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
32pub struct Launcher {
35 options: LaunchOptions,
36 game_data: Option<GameData>,
37}
38
39impl Launcher {
40 pub fn new(mut options: LaunchOptions) -> Self {
41 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 pub async fn download_game(
70 &mut self,
71 event_tx: Sender<LaunchEvent>,
72 ) -> Result<(), LaunchError> {
73 let options = &self.options;
74
75 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 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 }
118
119 let client = reqwest::Client::builder()
121 .timeout(Duration::from_secs(options.timeout_secs))
122 .build()
123 .map_err(LaunchError::Http)?;
124
125 let mut version_json = get_version_json(options, &client).await?;
127 let mc_version = version_json.id.clone();
128
129 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 let java_result = get_java_files(options, &version_json, &client, &event_tx).await?;
138
139 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 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 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 if options.verify {
202 check_files(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
203 }
204
205 extract_natives(options, &version_json, &bundle).await?;
207 version_json.has_natives = bundle
208 .iter()
209 .any(|item| matches!(item, AssetItem::NativeAsset { .. }));
210
211 if is_old(version_json.assets.as_deref()) {
213 copy_assets(options, &version_json).await?;
214 }
215
216 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 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 let natives_path: PathBuf = options
270 .path
271 .join("versions")
272 .join(&version_json.id)
273 .join("natives");
274
275 let mut bundle: Vec<AssetItem> = game_data.loader_libraries.clone();
278 let mut vanilla_libs = get_libraries(options, version_json);
279 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 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 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 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 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 let cp_args = if module_path_jars.is_empty() {
354 cp_args
355 } else {
356 cp_args.into_iter().map(|arg| {
357 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 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 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 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 #[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 #[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 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 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 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 pub fn is_corrupt_crash(exit_code: i32, logs: &[String]) -> bool {
523 if exit_code == 0 {
524 return false;
525 }
526 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#[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 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 #[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 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}