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 let client = reqwest::Client::builder()
87 .timeout(Duration::from_secs(options.timeout_secs))
88 .build()
89 .map_err(LaunchError::Http)?;
90
91 let mut version_json = get_version_json(options, &client).await?;
93 let mc_version = version_json.id.clone();
94
95 let mut bundle: Vec<AssetItem> = Vec::new();
97 bundle.extend(get_libraries(options, &version_json));
98 bundle.extend(get_assets_others(options, options.url.as_deref(), &client).await?);
99 bundle.extend(get_assets(options, &version_json, &client).await?);
100
101 let java_result = get_java_files(options, &version_json, &client, &event_tx).await?;
104
105 let pending = check_bundle(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
107 if !pending.is_empty() {
108 let downloader = Downloader::new(options.timeout_secs, options.download_concurrency);
109 downloader
110 .download_multiple(pending, event_tx.clone())
111 .await?;
112 }
113
114 let (loader_libraries, loader_main_class, loader_version_id, loader_type, loader_extra_game_args, loader_extra_jvm_args) = if options.loader.enable {
116 if let Some(loader_type) = &options.loader.loader_type {
117 let mc_jar = options
118 .path
119 .join("versions")
120 .join(&mc_version)
121 .join(format!("{mc_version}.jar"))
122 .to_string_lossy()
123 .into_owned();
124 let mc_json = options
125 .path
126 .join("versions")
127 .join(&mc_version)
128 .join(format!("{mc_version}.json"))
129 .to_string_lossy()
130 .into_owned();
131
132 let input = LoaderInstallInput {
133 mc_version: mc_version.clone(),
134 java_path: java_result.java_path.clone(),
135 mc_jar,
136 mc_json,
137 };
138
139 let loader_impl = create_loader(loader_type.clone());
140 let result = loader_impl.install(options, &input, &client, &event_tx).await?;
141 (result.libraries, result.main_class, Some(result.loader_version), Some(result.loader_type), result.extra_game_args, result.extra_jvm_args)
142 } else {
143 (vec![], None, None, None, vec![], vec![])
144 }
145 } else {
146 (vec![], None, None, None, vec![], vec![])
147 };
148
149 if !loader_libraries.is_empty() {
156 let loader_pending = check_bundle(&loader_libraries, &event_tx, options.clamped_verify_concurrency()).await?;
157 if !loader_pending.is_empty() {
158 let downloader =
159 Downloader::new(options.timeout_secs, options.download_concurrency);
160 downloader
161 .download_multiple(loader_pending, event_tx.clone())
162 .await?;
163 }
164 }
165
166 if options.verify {
168 check_files(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
169 }
170
171 extract_natives(options, &version_json, &bundle).await?;
173 version_json.has_natives = bundle
174 .iter()
175 .any(|item| matches!(item, AssetItem::NativeAsset { .. }));
176
177 if is_old(version_json.assets.as_deref()) {
179 copy_assets(options, &version_json).await?;
180 }
181
182 let game_data = GameData {
184 minecraft_json: version_json,
185 minecraft_loader: None,
186 minecraft_version: mc_version,
187 minecraft_java: JavaInfo {
188 files: java_result.files,
189 path: java_result.java_path,
190 },
191 loader_libraries,
192 loader_main_class,
193 loader_version_id,
194 loader_type,
195 loader_extra_game_args,
196 loader_extra_jvm_args,
197 };
198
199 save_game_data(&options.save_dir(), &game_data).await?;
200 self.game_data = Some(game_data);
201
202 let _ = event_tx.send(LaunchEvent::GameDownloadFinished).await;
203
204 Ok(())
205 }
206
207 pub async fn launch(
217 &self,
218 event_tx: Sender<LaunchEvent>,
219 ) -> Result<tokio::process::Child, LaunchError> {
220 let loaded;
221 let game_data: &GameData = match &self.game_data {
222 Some(gd) => gd,
223 None => {
224 loaded = load_game_data(&self.options.save_dir())
225 .await
226 .map_err(|_| LaunchError::GameDataNotReady)?;
227 &loaded
228 }
229 };
230
231 let options = &self.options;
232 let version_json = &game_data.minecraft_json;
233
234 let natives_path: PathBuf = options
236 .path
237 .join("versions")
238 .join(&version_json.id)
239 .join("natives");
240
241 let mut bundle: Vec<AssetItem> = game_data.loader_libraries.clone();
244 let mut vanilla_libs = get_libraries(options, version_json);
245 let uses_module_path = game_data.loader_extra_jvm_args.iter()
251 .any(|a| a == "-p" || a == "--module-path");
252 let exclude_vanilla_jar = matches!(game_data.loader_type, Some(LoaderType::NeoForge))
253 || (matches!(game_data.loader_type, Some(LoaderType::Forge)) && uses_module_path);
254 if exclude_vanilla_jar {
255 let mc_jar = options
256 .path
257 .join("versions")
258 .join(&version_json.id)
259 .join(format!("{}.jar", version_json.id))
260 .to_string_lossy()
261 .into_owned();
262 vanilla_libs.retain(|lib| !matches!(lib, AssetItem::Asset { path, .. } if path == &mc_jar));
263 }
264 bundle.extend(vanilla_libs);
265
266 let loader_ctx = game_data.loader_version_id.as_ref().map(|vid| LoaderContext {
268 loader_type: game_data.loader_type.as_ref(),
269 version_id: Some(vid.as_str()),
270 extra_game_args: &game_data.loader_extra_game_args,
271 extra_jvm_args: &game_data.loader_extra_jvm_args,
272 });
273 let jvm_args = get_jvm_arguments(options, version_json, &natives_path, loader_ctx.as_ref());
274 let mut game_args = get_game_arguments(options, version_json, loader_ctx.as_ref());
275 let (cp_args, vanilla_main_class) = get_classpath(version_json, &bundle);
276
277 if let Some(w) = options.screen.width {
280 game_args.push("--width".into());
281 game_args.push(w.to_string());
282 }
283 if let Some(h) = options.screen.height {
284 game_args.push("--height".into());
285 game_args.push(h.to_string());
286 }
287 if options.screen.fullscreen {
288 game_args.push("--fullscreen".into());
289 }
290
291 let main_class = game_data
292 .loader_main_class
293 .as_deref()
294 .unwrap_or(&vanilla_main_class)
295 .to_owned();
296
297 let module_path_jars: std::collections::HashSet<String> = {
301 let mut set = std::collections::HashSet::new();
302 let mut iter = jvm_args.iter().peekable();
303 while let Some(arg) = iter.next() {
304 if arg == "-p" {
305 if let Some(module_path) = iter.next() {
306 for jar in module_path.split(':') {
307 if let Some(name) = std::path::Path::new(jar).file_name() {
309 set.insert(name.to_string_lossy().into_owned());
310 }
311 }
312 }
313 }
314 }
315 set
316 };
317
318 let cp_args = if module_path_jars.is_empty() {
320 cp_args
321 } else {
322 cp_args.into_iter().map(|arg| {
323 if arg.contains(':') || arg.ends_with(".jar") {
325 let filtered: Vec<&str> = arg.split(':')
326 .filter(|entry| {
327 let fname = std::path::Path::new(entry)
328 .file_name()
329 .map(|f| f.to_string_lossy().into_owned())
330 .unwrap_or_default();
331 !module_path_jars.contains(&fname)
332 })
333 .collect();
334 filtered.join(":")
335 } else {
336 arg
337 }
338 }).collect()
339 };
340
341 let mut all_args: Vec<String> = Vec::new();
342 all_args.extend(jvm_args);
343 #[cfg(target_os = "linux")]
344 all_args.push("-DGLFW_PLATFORM=x11".into());
345 all_args.extend(cp_args);
346 all_args.push(main_class);
347 all_args.extend(game_args);
348
349 let java_path_raw = &game_data.minecraft_java.path;
350 let java_path_buf = std::path::Path::new(java_path_raw)
353 .canonicalize()
354 .unwrap_or_else(|_| std::path::PathBuf::from(java_path_raw));
355 let java_path = java_path_buf.to_string_lossy();
356
357 let access_token = &options.authenticator.access_token;
359 let cmd_str = format!("{} {}", java_path, all_args.join(" "));
360 let sanitized = if access_token.is_empty() {
361 cmd_str
362 } else {
363 cmd_str.replace(access_token.as_str(), "<access_token>")
364 };
365 let _ = event_tx.send(LaunchEvent::Data(sanitized)).await;
366
367 let mut cmd = tokio::process::Command::new(java_path.as_ref());
369 cmd.args(&all_args)
370 .current_dir(options.save_dir())
371 .stdout(Stdio::piped())
372 .stderr(Stdio::piped());
373
374 #[cfg(target_os = "linux")]
377 if std::env::var_os("DISPLAY").is_some() {
378 cmd.env_remove("WAYLAND_DISPLAY");
379 cmd.env("GLFW_PLATFORM", "x11");
380 }
381
382 #[cfg(target_os = "linux")]
389 if crate::game::lwjgl_native::uses_lwjgl2(version_json)
390 && !crate::game::lwjgl_native::xrandr_in_path()
391 {
392 let stub_dir = options.path.join("cache").join("xrandr-stub");
393 if crate::game::lwjgl_native::write_xrandr_stub(&stub_dir).await.is_ok() {
394 let base_path = std::env::var("PATH").unwrap_or_default();
395 cmd.env(
396 "PATH",
397 format!("{}:{}", stub_dir.to_string_lossy(), base_path),
398 );
399 }
400 }
401
402 let mut child = cmd.spawn()
403 .map_err(|e| LaunchError::ProcessError(e.to_string()))?;
404
405 if let Some(stdout) = child.stdout.take() {
407 let tx = event_tx.clone();
408 tokio::spawn(async move {
409 let mut lines = BufReader::new(stdout).lines();
410 while let Ok(Some(line)) = lines.next_line().await {
411 let _ = tx.send(LaunchEvent::Data(line)).await;
412 }
413 });
414 }
415
416 if let Some(stderr) = child.stderr.take() {
418 let tx = event_tx;
419 tokio::spawn(async move {
420 let mut lines = BufReader::new(stderr).lines();
421 while let Ok(Some(line)) = lines.next_line().await {
422 let _ = tx.send(LaunchEvent::Data(line)).await;
423 }
424 });
425 }
426
427 Ok(child)
428 }
429
430 pub async fn start(
443 &mut self,
444 event_tx: Sender<LaunchEvent>,
445 ) -> Result<tokio::process::Child, LaunchError> {
446 self.download_game(event_tx.clone()).await?;
447 self.launch(event_tx).await
448 }
449}
450
451#[cfg(test)]
454mod tests {
455 use super::*;
456 use std::path::PathBuf;
457
458 fn make_options() -> LaunchOptions {
459 use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
460 use crate::models::minecraft::Authenticator;
461 LaunchOptions {
462 path: PathBuf::from("/mc"),
463 version: "1.20.4".into(),
464 authenticator: Authenticator {
465 access_token: "test-token".into(),
466 name: "Player".into(),
467 uuid: "test-uuid".into(),
468 xbox_account: None,
469 user_properties: None,
470 client_id: None,
471 client_token: None,
472 },
473 timeout_secs: 10,
474 download_concurrency: 5,
475 verify_concurrency: 4,
476 memory: MemoryConfig::default(),
477 java: JavaOptions::default(),
478 loader: LoaderConfig::default(),
479 screen: ScreenConfig::default(),
480 verify: false,
481 game_args: vec![],
482 jvm_args: vec![],
483 instance: None,
484 url: None,
485 mcp: None,
486 intel_enabled_mac: false,
487 bypass_offline: false,
488 }
489 }
490
491 #[test]
492 fn launcher_new_stores_options() {
493 let opts = make_options();
494 let launcher = Launcher::new(opts.clone());
495 assert_eq!(launcher.options.version, "1.20.4");
496 assert_eq!(launcher.options.path, PathBuf::from("/mc"));
497 }
498
499 #[test]
500 fn launcher_save_dir_no_instance() {
501 let opts = make_options();
502 let launcher = Launcher::new(opts);
503 assert_eq!(launcher.options.save_dir(), PathBuf::from("/mc"));
504 }
505
506 #[test]
507 fn launcher_save_dir_with_instance() {
508 let mut opts = make_options();
509 opts.instance = Some("myworld".into());
510 let launcher = Launcher::new(opts);
511 assert_eq!(
512 launcher.options.save_dir(),
513 PathBuf::from("/mc/instances/myworld")
514 );
515 }
516
517 #[test]
518 fn sanitize_replaces_access_token() {
519 let token = "secret-access-token";
520 let cmd = format!("java -cp foo.jar Main --accessToken {token}");
521 let sanitized = cmd.replace(token, "<access_token>");
522 assert!(!sanitized.contains(token));
523 assert!(sanitized.contains("<access_token>"));
524 }
525
526 #[test]
527 fn all_args_order_is_correct() {
528 let jvm: Vec<String> = vec!["-Xms1G".into(), "-Xmx2G".into()];
530 let cp: Vec<String> = vec!["-cp".into(), "a.jar:b.jar".into()];
531 let main_class = "net.minecraft.client.main.Main".to_owned();
532 let game: Vec<String> = vec!["--username".into(), "Player".into()];
533
534 let mut all: Vec<String> = Vec::new();
535 all.extend(jvm);
536 all.extend(cp);
537 all.push(main_class.clone());
538 all.extend(game);
539
540 assert_eq!(all[0], "-Xms1G");
541 assert_eq!(all[2], "-cp");
542 assert_eq!(all[4], main_class);
543 assert_eq!(all[5], "--username");
544 }
545
546 #[test]
547 fn screen_args_appended_when_set() {
548 use crate::launcher::options::ScreenConfig;
549 let screen = ScreenConfig { width: Some(1920), height: Some(1080), fullscreen: false };
550 let mut game_args: Vec<String> = vec!["--version".into(), "1.20.4".into()];
551 if let Some(w) = screen.width {
552 game_args.push("--width".into());
553 game_args.push(w.to_string());
554 }
555 if let Some(h) = screen.height {
556 game_args.push("--height".into());
557 game_args.push(h.to_string());
558 }
559 assert!(game_args.contains(&"--width".to_string()));
560 assert!(game_args.contains(&"1920".to_string()));
561 assert!(game_args.contains(&"--height".to_string()));
562 assert!(game_args.contains(&"1080".to_string()));
563 assert!(!game_args.contains(&"--fullscreen".to_string()));
564 }
565
566 #[test]
567 fn screen_fullscreen_appended_when_set() {
568 use crate::launcher::options::ScreenConfig;
569 let screen = ScreenConfig { width: None, height: None, fullscreen: true };
570 let mut game_args: Vec<String> = vec![];
571 if screen.fullscreen {
572 game_args.push("--fullscreen".into());
573 }
574 assert!(game_args.contains(&"--fullscreen".to_string()));
575 }
576
577 #[test]
578 fn loader_main_class_overrides_vanilla() {
579 let vanilla = "net.minecraft.client.main.Main".to_owned();
580 let loader_main_class: Option<String> =
581 Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into());
582 let main_class = loader_main_class.as_deref().unwrap_or(&vanilla).to_owned();
583 assert_eq!(main_class, "net.fabricmc.loader.impl.launch.knot.KnotClient");
584 }
585
586 #[test]
587 fn no_loader_main_class_uses_vanilla() {
588 let vanilla = "net.minecraft.client.main.Main".to_owned();
589 let loader_main_class: Option<String> = None;
590 let main_class = loader_main_class.as_deref().unwrap_or(&vanilla).to_owned();
591 assert_eq!(main_class, "net.minecraft.client.main.Main");
592 }
593}