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 {
90 if let Ok(cached) = load_game_data(&options.save_dir()).await {
91 self.game_data = Some(cached);
92 let _ = event_tx.send(LaunchEvent::GameDownloadFinished).await;
93 return Ok(());
94 }
95 }
97
98 let client = reqwest::Client::builder()
100 .timeout(Duration::from_secs(options.timeout_secs))
101 .build()
102 .map_err(LaunchError::Http)?;
103
104 let mut version_json = get_version_json(options, &client).await?;
106 let mc_version = version_json.id.clone();
107
108 let mut bundle: Vec<AssetItem> = Vec::new();
110 bundle.extend(get_libraries(options, &version_json));
111 bundle.extend(get_assets_others(options, options.url.as_deref(), &client).await?);
112 bundle.extend(get_assets(options, &version_json, &client).await?);
113
114 let java_result = get_java_files(options, &version_json, &client, &event_tx).await?;
117
118 let pending = check_bundle(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
120 if !pending.is_empty() {
121 let downloader = Downloader::new(options.timeout_secs, options.download_concurrency);
122 downloader
123 .download_multiple(pending, event_tx.clone())
124 .await?;
125 }
126
127 let (loader_libraries, loader_main_class, loader_version_id, loader_type, loader_extra_game_args, loader_extra_jvm_args) = if options.loader.enable {
129 if let Some(loader_type) = &options.loader.loader_type {
130 let mc_jar = options
131 .path
132 .join("versions")
133 .join(&mc_version)
134 .join(format!("{mc_version}.jar"))
135 .to_string_lossy()
136 .into_owned();
137 let mc_json = options
138 .path
139 .join("versions")
140 .join(&mc_version)
141 .join(format!("{mc_version}.json"))
142 .to_string_lossy()
143 .into_owned();
144
145 let input = LoaderInstallInput {
146 mc_version: mc_version.clone(),
147 java_path: java_result.java_path.clone(),
148 mc_jar,
149 mc_json,
150 };
151
152 let loader_impl = create_loader(loader_type.clone());
153 let result = loader_impl.install(options, &input, &client, &event_tx).await?;
154 (result.libraries, result.main_class, Some(result.loader_version), Some(result.loader_type), result.extra_game_args, result.extra_jvm_args)
155 } else {
156 (vec![], None, None, None, vec![], vec![])
157 }
158 } else {
159 (vec![], None, None, None, vec![], vec![])
160 };
161
162 if !loader_libraries.is_empty() {
169 let loader_pending = check_bundle(&loader_libraries, &event_tx, options.clamped_verify_concurrency()).await?;
170 if !loader_pending.is_empty() {
171 let downloader =
172 Downloader::new(options.timeout_secs, options.download_concurrency);
173 downloader
174 .download_multiple(loader_pending, event_tx.clone())
175 .await?;
176 }
177 }
178
179 if options.verify {
181 check_files(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
182 }
183
184 extract_natives(options, &version_json, &bundle).await?;
186 version_json.has_natives = bundle
187 .iter()
188 .any(|item| matches!(item, AssetItem::NativeAsset { .. }));
189
190 if is_old(version_json.assets.as_deref()) {
192 copy_assets(options, &version_json).await?;
193 }
194
195 let game_data = GameData {
197 minecraft_json: version_json,
198 minecraft_loader: None,
199 minecraft_version: mc_version,
200 minecraft_java: JavaInfo {
201 files: java_result.files,
202 path: java_result.java_path,
203 },
204 loader_libraries,
205 loader_main_class,
206 loader_version_id,
207 loader_type,
208 loader_extra_game_args,
209 loader_extra_jvm_args,
210 };
211
212 save_game_data(&options.save_dir(), &game_data).await?;
213 self.game_data = Some(game_data);
214
215 let _ = event_tx.send(LaunchEvent::GameDownloadFinished).await;
216
217 Ok(())
218 }
219
220 pub async fn launch(
230 &self,
231 event_tx: Sender<LaunchEvent>,
232 ) -> Result<tokio::process::Child, LaunchError> {
233 let loaded;
234 let game_data: &GameData = match &self.game_data {
235 Some(gd) => gd,
236 None => {
237 loaded = load_game_data(&self.options.save_dir())
238 .await
239 .map_err(|_| LaunchError::GameDataNotReady)?;
240 &loaded
241 }
242 };
243
244 let options = &self.options;
245 let version_json = &game_data.minecraft_json;
246
247 let natives_path: PathBuf = options
249 .path
250 .join("versions")
251 .join(&version_json.id)
252 .join("natives");
253
254 let mut bundle: Vec<AssetItem> = game_data.loader_libraries.clone();
257 let mut vanilla_libs = get_libraries(options, version_json);
258 let uses_module_path = game_data.loader_extra_jvm_args.iter()
264 .any(|a| a == "-p" || a == "--module-path");
265 let exclude_vanilla_jar = matches!(game_data.loader_type, Some(LoaderType::NeoForge))
266 || (matches!(game_data.loader_type, Some(LoaderType::Forge)) && uses_module_path);
267 if exclude_vanilla_jar {
268 let mc_jar = options
269 .path
270 .join("versions")
271 .join(&version_json.id)
272 .join(format!("{}.jar", version_json.id))
273 .to_string_lossy()
274 .into_owned();
275 vanilla_libs.retain(|lib| !matches!(lib, AssetItem::Asset { path, .. } if path == &mc_jar));
276 }
277 bundle.extend(vanilla_libs);
278
279 let loader_ctx = game_data.loader_version_id.as_ref().map(|vid| LoaderContext {
281 loader_type: game_data.loader_type.as_ref(),
282 version_id: Some(vid.as_str()),
283 extra_game_args: &game_data.loader_extra_game_args,
284 extra_jvm_args: &game_data.loader_extra_jvm_args,
285 });
286 let jvm_args = get_jvm_arguments(options, version_json, &natives_path, loader_ctx.as_ref());
287 let mut game_args = get_game_arguments(options, version_json, loader_ctx.as_ref());
288 let (cp_args, vanilla_main_class) = get_classpath(version_json, &bundle);
289
290 if let Some(w) = options.screen.width {
293 game_args.push("--width".into());
294 game_args.push(w.to_string());
295 }
296 if let Some(h) = options.screen.height {
297 game_args.push("--height".into());
298 game_args.push(h.to_string());
299 }
300 if options.screen.fullscreen {
301 game_args.push("--fullscreen".into());
302 }
303
304 let main_class = game_data
305 .loader_main_class
306 .as_deref()
307 .unwrap_or(&vanilla_main_class)
308 .to_owned();
309
310 let module_path_jars: std::collections::HashSet<String> = {
314 let mut set = std::collections::HashSet::new();
315 let mut iter = jvm_args.iter().peekable();
316 while let Some(arg) = iter.next() {
317 if arg == "-p" {
318 if let Some(module_path) = iter.next() {
319 for jar in module_path.split(':') {
320 if let Some(name) = std::path::Path::new(jar).file_name() {
322 set.insert(name.to_string_lossy().into_owned());
323 }
324 }
325 }
326 }
327 }
328 set
329 };
330
331 let cp_args = if module_path_jars.is_empty() {
333 cp_args
334 } else {
335 cp_args.into_iter().map(|arg| {
336 if arg.contains(':') || arg.ends_with(".jar") {
338 let filtered: Vec<&str> = arg.split(':')
339 .filter(|entry| {
340 let fname = std::path::Path::new(entry)
341 .file_name()
342 .map(|f| f.to_string_lossy().into_owned())
343 .unwrap_or_default();
344 !module_path_jars.contains(&fname)
345 })
346 .collect();
347 filtered.join(":")
348 } else {
349 arg
350 }
351 }).collect()
352 };
353
354 let mut all_args: Vec<String> = Vec::new();
355 all_args.extend(jvm_args);
356 #[cfg(target_os = "linux")]
357 all_args.push("-DGLFW_PLATFORM=x11".into());
358 all_args.extend(cp_args);
359 all_args.push(main_class);
360 all_args.extend(game_args);
361
362 let java_path_raw = &game_data.minecraft_java.path;
363 let java_path_buf = std::path::Path::new(java_path_raw)
366 .canonicalize()
367 .unwrap_or_else(|_| std::path::PathBuf::from(java_path_raw));
368 let java_path = java_path_buf.to_string_lossy();
369
370 let access_token = &options.authenticator.access_token;
372 let cmd_str = format!("{} {}", java_path, all_args.join(" "));
373 let sanitized = if access_token.is_empty() {
374 cmd_str
375 } else {
376 cmd_str.replace(access_token.as_str(), "<access_token>")
377 };
378 let _ = event_tx.send(LaunchEvent::Data(sanitized)).await;
379
380 let mut cmd = tokio::process::Command::new(java_path.as_ref());
382 cmd.args(&all_args)
383 .current_dir(options.save_dir())
384 .stdout(Stdio::piped())
385 .stderr(Stdio::piped());
386
387 #[cfg(target_os = "linux")]
402 {
403 let display = std::env::var_os("DISPLAY").or_else(|| {
404 (0..10u8).find_map(|n| {
405 let sock = format!("/tmp/.X11-unix/X{n}");
406 std::path::Path::new(&sock).exists().then(|| format!(":{n}").into())
407 })
408 });
409 if let Some(disp) = display {
410 cmd.env("DISPLAY", disp);
411 cmd.env_remove("WAYLAND_DISPLAY");
412 cmd.env("GLFW_PLATFORM", "x11");
413 cmd.env("WAYLAND_SOCKET", "invalid");
414 }
415 }
416
417 #[cfg(target_os = "linux")]
424 if crate::game::lwjgl_native::uses_lwjgl2(version_json)
425 && !crate::game::lwjgl_native::xrandr_in_path()
426 {
427 let stub_dir = options.path.join("cache").join("xrandr-stub");
428 if crate::game::lwjgl_native::write_xrandr_stub(&stub_dir).await.is_ok() {
429 let base_path = std::env::var("PATH").unwrap_or_default();
430 cmd.env(
431 "PATH",
432 format!("{}:{}", stub_dir.to_string_lossy(), base_path),
433 );
434 }
435 }
436
437 let mut child = cmd.spawn()
438 .map_err(|e| LaunchError::ProcessError(e.to_string()))?;
439
440 if let Some(stdout) = child.stdout.take() {
442 let tx = event_tx.clone();
443 tokio::spawn(async move {
444 let mut lines = BufReader::new(stdout).lines();
445 while let Ok(Some(line)) = lines.next_line().await {
446 let _ = tx.send(LaunchEvent::Data(line)).await;
447 }
448 });
449 }
450
451 if let Some(stderr) = child.stderr.take() {
453 let tx = event_tx;
454 tokio::spawn(async move {
455 let mut lines = BufReader::new(stderr).lines();
456 while let Ok(Some(line)) = lines.next_line().await {
457 let _ = tx.send(LaunchEvent::Data(line)).await;
458 }
459 });
460 }
461
462 Ok(child)
463 }
464
465 pub async fn start(
478 &mut self,
479 event_tx: Sender<LaunchEvent>,
480 ) -> Result<tokio::process::Child, LaunchError> {
481 self.download_game(event_tx.clone()).await?;
482 self.launch(event_tx).await
483 }
484
485 pub fn is_corrupt_crash(exit_code: i32, logs: &[String]) -> bool {
502 if exit_code == 0 {
503 return false;
504 }
505 const PATTERNS: &[&str] = &[
506 "Unable to access jarfile",
507 "NoClassDefFoundError",
508 "UnsatisfiedLinkError",
509 "FileNotFoundException",
510 "ClassNotFoundException",
511 "Error: Could not find or load main class",
512 "ZipException",
513 "Error opening zip file",
514 ];
515 logs.iter().any(|line| PATTERNS.iter().any(|pat| line.contains(pat)))
516 }
517}
518
519#[cfg(test)]
522mod tests {
523 use super::*;
524 use std::path::PathBuf;
525
526 fn make_options() -> LaunchOptions {
527 use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
528 use crate::models::minecraft::Authenticator;
529 LaunchOptions {
530 path: PathBuf::from("/mc"),
531 version: "1.20.4".into(),
532 authenticator: Authenticator {
533 access_token: "test-token".into(),
534 name: "Player".into(),
535 uuid: "test-uuid".into(),
536 xbox_account: None,
537 user_properties: None,
538 client_id: None,
539 client_token: None,
540 },
541 timeout_secs: 10,
542 download_concurrency: 5,
543 verify_concurrency: 4,
544 memory: MemoryConfig::default(),
545 java: JavaOptions::default(),
546 loader: LoaderConfig::default(),
547 screen: ScreenConfig::default(),
548 verify: false,
549 game_args: vec![],
550 jvm_args: vec![],
551 instance: None,
552 url: None,
553 mcp: None,
554 intel_enabled_mac: false,
555 bypass_offline: false,
556 skip_bundle_check: false,
557 }
558 }
559
560 #[test]
561 fn launcher_new_stores_options() {
562 let opts = make_options();
563 let launcher = Launcher::new(opts.clone());
564 assert_eq!(launcher.options.version, "1.20.4");
565 assert_eq!(launcher.options.path, PathBuf::from("/mc"));
566 }
567
568 #[test]
569 fn launcher_save_dir_no_instance() {
570 let opts = make_options();
571 let launcher = Launcher::new(opts);
572 assert_eq!(launcher.options.save_dir(), PathBuf::from("/mc"));
573 }
574
575 #[test]
576 fn launcher_save_dir_with_instance() {
577 let mut opts = make_options();
578 opts.instance = Some("myworld".into());
579 let launcher = Launcher::new(opts);
580 assert_eq!(
581 launcher.options.save_dir(),
582 PathBuf::from("/mc/instances/myworld")
583 );
584 }
585
586 #[test]
587 fn sanitize_replaces_access_token() {
588 let token = "secret-access-token";
589 let cmd = format!("java -cp foo.jar Main --accessToken {token}");
590 let sanitized = cmd.replace(token, "<access_token>");
591 assert!(!sanitized.contains(token));
592 assert!(sanitized.contains("<access_token>"));
593 }
594
595 #[test]
596 fn all_args_order_is_correct() {
597 let jvm: Vec<String> = vec!["-Xms1G".into(), "-Xmx2G".into()];
599 let cp: Vec<String> = vec!["-cp".into(), "a.jar:b.jar".into()];
600 let main_class = "net.minecraft.client.main.Main".to_owned();
601 let game: Vec<String> = vec!["--username".into(), "Player".into()];
602
603 let mut all: Vec<String> = Vec::new();
604 all.extend(jvm);
605 all.extend(cp);
606 all.push(main_class.clone());
607 all.extend(game);
608
609 assert_eq!(all[0], "-Xms1G");
610 assert_eq!(all[2], "-cp");
611 assert_eq!(all[4], main_class);
612 assert_eq!(all[5], "--username");
613 }
614
615 #[test]
616 fn screen_args_appended_when_set() {
617 use crate::launcher::options::ScreenConfig;
618 let screen = ScreenConfig { width: Some(1920), height: Some(1080), fullscreen: false };
619 let mut game_args: Vec<String> = vec!["--version".into(), "1.20.4".into()];
620 if let Some(w) = screen.width {
621 game_args.push("--width".into());
622 game_args.push(w.to_string());
623 }
624 if let Some(h) = screen.height {
625 game_args.push("--height".into());
626 game_args.push(h.to_string());
627 }
628 assert!(game_args.contains(&"--width".to_string()));
629 assert!(game_args.contains(&"1920".to_string()));
630 assert!(game_args.contains(&"--height".to_string()));
631 assert!(game_args.contains(&"1080".to_string()));
632 assert!(!game_args.contains(&"--fullscreen".to_string()));
633 }
634
635 #[test]
636 fn screen_fullscreen_appended_when_set() {
637 use crate::launcher::options::ScreenConfig;
638 let screen = ScreenConfig { width: None, height: None, fullscreen: true };
639 let mut game_args: Vec<String> = vec![];
640 if screen.fullscreen {
641 game_args.push("--fullscreen".into());
642 }
643 assert!(game_args.contains(&"--fullscreen".to_string()));
644 }
645
646 #[test]
647 fn loader_main_class_overrides_vanilla() {
648 let vanilla = "net.minecraft.client.main.Main".to_owned();
649 let loader_main_class: Option<String> =
650 Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into());
651 let main_class = loader_main_class.as_deref().unwrap_or(&vanilla).to_owned();
652 assert_eq!(main_class, "net.fabricmc.loader.impl.launch.knot.KnotClient");
653 }
654
655 #[test]
656 fn no_loader_main_class_uses_vanilla() {
657 let vanilla = "net.minecraft.client.main.Main".to_owned();
658 let loader_main_class: Option<String> = None;
659 let main_class = loader_main_class.as_deref().unwrap_or(&vanilla).to_owned();
660 assert_eq!(main_class, "net.minecraft.client.main.Main");
661 }
662
663 #[test]
666 fn corrupt_crash_zero_exit_always_false() {
667 let logs = vec!["NoClassDefFoundError: net/minecraft/Foo".into()];
668 assert!(!Launcher::is_corrupt_crash(0, &logs));
669 }
670
671 #[test]
672 fn corrupt_crash_nonzero_no_pattern_false() {
673 let logs = vec!["Exception in thread \"main\" java.lang.RuntimeException".into()];
674 assert!(!Launcher::is_corrupt_crash(1, &logs));
675 }
676
677 #[test]
678 fn corrupt_crash_empty_logs_false() {
679 assert!(!Launcher::is_corrupt_crash(1, &[]));
680 }
681
682 #[test]
683 fn corrupt_crash_all_patterns_detected() {
684 let cases = [
685 "Unable to access jarfile foo.jar",
686 "java.lang.NoClassDefFoundError: Foo",
687 "java.lang.UnsatisfiedLinkError: /lib/foo.so",
688 "java.io.FileNotFoundException: /mc/lib.jar (No such file)",
689 "java.lang.ClassNotFoundException: net.minecraft.Main",
690 "Error: Could not find or load main class Main",
691 "java.util.zip.ZipException: invalid LOC header",
692 "Error opening zip file or JAR manifest missing",
693 ];
694 for case in &cases {
695 assert!(
696 Launcher::is_corrupt_crash(1, &[case.to_string()]),
697 "pattern not detected: {case}"
698 );
699 }
700 }
701}