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        // ── Shared HTTP client ────────────────────────────────────────────────
86        let client = reqwest::Client::builder()
87            .timeout(Duration::from_secs(options.timeout_secs))
88            .build()
89            .map_err(LaunchError::Http)?;
90
91        // ── Version JSON ──────────────────────────────────────────────────────
92        let mut version_json = get_version_json(options, &client).await?;
93        let mc_version = version_json.id.clone();
94
95        // ── File bundle ───────────────────────────────────────────────────────
96        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        // Java runtime download is managed separately (has its own concurrency
102        // and progress reporting); its files are not added to the bundle.
103        let java_result = get_java_files(options, &version_json, &client, &event_tx).await?;
104
105        // ── Bundle integrity check & download ─────────────────────────────────
106        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        // ── Mod loader install ────────────────────────────────────────────────
115        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        // ── Download Forge/NeoForge runtime libraries ─────────────────────────
150        // The loader install step (above) downloads processor/install-time JARs
151        // but NOT the runtime classpath libraries listed in version.json (e.g.
152        // bootstraplauncher, securejarhandler, modlauncher).  We check and
153        // download them here.  When --installClient was used the files already
154        // exist and check_bundle returns an empty pending list immediately.
155        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        // ── Optional post-download SHA-1 verify ───────────────────────────────
167        if options.verify {
168            check_files(&bundle, &event_tx, options.clamped_verify_concurrency()).await?;
169        }
170
171        // ── Extract native JARs ───────────────────────────────────────────────
172        extract_natives(options, &version_json, &bundle).await?;
173        version_json.has_natives = bundle
174            .iter()
175            .any(|item| matches!(item, AssetItem::NativeAsset { .. }));
176
177        // ── Legacy asset copy (pre-1.6) ───────────────────────────────────────
178        if is_old(version_json.assets.as_deref()) {
179            copy_assets(options, &version_json).await?;
180        }
181
182        // ── Persist & store ───────────────────────────────────────────────────
183        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    /// Assemble the Java command line and spawn the Minecraft process.
208    ///
209    /// Resolves game data from `self.game_data` (set by [`Launcher::download_game`])
210    /// or, if absent, from the persisted cache on disk. Returns
211    /// [`LaunchError::GameDataNotReady`] if neither is available.
212    ///
213    /// Stdout and stderr are piped; each line is forwarded as a
214    /// [`LaunchEvent::Data`] event. The caller is responsible for calling
215    /// `child.wait()` and emitting [`LaunchEvent::Close`] when appropriate.
216    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        // Natives directory used for -Djava.library.path.
235        let natives_path: PathBuf = options
236            .path
237            .join("versions")
238            .join(&version_json.id)
239            .join("natives");
240
241        // Build the classpath: loader libraries FIRST so Forge/NeoForge classes
242        // take precedence over vanilla when there are collisions.
243        let mut bundle: Vec<AssetItem> = game_data.loader_libraries.clone();
244        let mut vanilla_libs = get_libraries(options, version_json);
245        // Forge/NeoForge: exclude the vanilla Minecraft client JAR from the classpath.
246        // The bootstraplauncher manages Minecraft classes via client-slim.jar in its
247        // library directory. Including the full vanilla jar causes split-package
248        // conflicts in the Java module layer (_1._20._1 vs minecraft modules).
249        if matches!(game_data.loader_type, Some(LoaderType::Forge) | Some(LoaderType::NeoForge)) {
250            let mc_jar = options
251                .path
252                .join("versions")
253                .join(&version_json.id)
254                .join(format!("{}.jar", version_json.id))
255                .to_string_lossy()
256                .into_owned();
257            vanilla_libs.retain(|lib| !matches!(lib, AssetItem::Asset { path, .. } if path == &mc_jar));
258        }
259        bundle.extend(vanilla_libs);
260
261        // Argument assembly.
262        let loader_ctx = game_data.loader_version_id.as_ref().map(|vid| LoaderContext {
263            loader_type: game_data.loader_type.as_ref(),
264            version_id: Some(vid.as_str()),
265            extra_game_args: &game_data.loader_extra_game_args,
266            extra_jvm_args: &game_data.loader_extra_jvm_args,
267        });
268        let jvm_args = get_jvm_arguments(options, version_json, &natives_path, loader_ctx.as_ref());
269        let mut game_args = get_game_arguments(options, version_json, loader_ctx.as_ref());
270        let (cp_args, vanilla_main_class) = get_classpath(version_json, &bundle);
271
272        // Screen size / fullscreen (conditional args from the version JSON are
273        // skipped by get_game_arguments, so we add them here explicitly).
274        if let Some(w) = options.screen.width {
275            game_args.push("--width".into());
276            game_args.push(w.to_string());
277        }
278        if let Some(h) = options.screen.height {
279            game_args.push("--height".into());
280            game_args.push(h.to_string());
281        }
282        if options.screen.fullscreen {
283            game_args.push("--fullscreen".into());
284        }
285
286        let main_class = game_data
287            .loader_main_class
288            .as_deref()
289            .unwrap_or(&vanilla_main_class)
290            .to_owned();
291
292        // Collect JARs on the Java module path (-p flag) from JVM args so we
293        // can exclude them from -cp. NeoForge places bootstrap JARs on the
294        // module path; having them on both paths causes IllegalStateException.
295        let module_path_jars: std::collections::HashSet<String> = {
296            let mut set = std::collections::HashSet::new();
297            let mut iter = jvm_args.iter().peekable();
298            while let Some(arg) = iter.next() {
299                if arg == "-p" {
300                    if let Some(module_path) = iter.next() {
301                        for jar in module_path.split(':') {
302                            // Normalize to a canonical filename for matching.
303                            if let Some(name) = std::path::Path::new(jar).file_name() {
304                                set.insert(name.to_string_lossy().into_owned());
305                            }
306                        }
307                    }
308                }
309            }
310            set
311        };
312
313        // Filter module-path JARs out of -cp to avoid duplicate module errors.
314        let cp_args = if module_path_jars.is_empty() {
315            cp_args
316        } else {
317            cp_args.into_iter().map(|arg| {
318                // The classpath string is the arg after "-cp".
319                if arg.contains(':') || arg.ends_with(".jar") {
320                    let filtered: Vec<&str> = arg.split(':')
321                        .filter(|entry| {
322                            let fname = std::path::Path::new(entry)
323                                .file_name()
324                                .map(|f| f.to_string_lossy().into_owned())
325                                .unwrap_or_default();
326                            !module_path_jars.contains(&fname)
327                        })
328                        .collect();
329                    filtered.join(":")
330                } else {
331                    arg
332                }
333            }).collect()
334        };
335
336        let mut all_args: Vec<String> = Vec::new();
337        all_args.extend(jvm_args);
338        #[cfg(target_os = "linux")]
339        all_args.push("-DGLFW_PLATFORM=x11".into());
340        all_args.extend(cp_args);
341        all_args.push(main_class);
342        all_args.extend(game_args);
343
344        let java_path_raw = &game_data.minecraft_java.path;
345        // Resolve to an absolute path so the binary is found regardless of
346        // what current_dir is set to below.
347        let java_path_buf = std::path::Path::new(java_path_raw)
348            .canonicalize()
349            .unwrap_or_else(|_| std::path::PathBuf::from(java_path_raw));
350        let java_path = java_path_buf.to_string_lossy();
351
352        // Sanitize auth token before logging the command.
353        let access_token = &options.authenticator.access_token;
354        let cmd_str = format!("{} {}", java_path, all_args.join(" "));
355        let sanitized = if access_token.is_empty() {
356            cmd_str
357        } else {
358            cmd_str.replace(access_token.as_str(), "<access_token>")
359        };
360        let _ = event_tx.send(LaunchEvent::Data(sanitized)).await;
361
362        // Spawn the process.
363        let mut cmd = tokio::process::Command::new(java_path.as_ref());
364        cmd.args(&all_args)
365            .current_dir(options.save_dir())
366            .stdout(Stdio::piped())
367            .stderr(Stdio::piped());
368
369        // On Linux, force GLFW 3.4+ to use X11 via XWayland (if available) to
370        // avoid residual [0x1000C] Wayland errors that crash MC's GLFW init check.
371        #[cfg(target_os = "linux")]
372        if std::env::var_os("DISPLAY").is_some() {
373            cmd.env_remove("WAYLAND_DISPLAY");
374            cmd.env("GLFW_PLATFORM", "x11");
375        }
376
377        let mut child = cmd.spawn()
378            .map_err(|e| LaunchError::ProcessError(e.to_string()))?;
379
380        // Pipe stdout lines → LaunchEvent::Data.
381        if let Some(stdout) = child.stdout.take() {
382            let tx = event_tx.clone();
383            tokio::spawn(async move {
384                let mut lines = BufReader::new(stdout).lines();
385                while let Ok(Some(line)) = lines.next_line().await {
386                    let _ = tx.send(LaunchEvent::Data(line)).await;
387                }
388            });
389        }
390
391        // Pipe stderr lines → LaunchEvent::Data.
392        if let Some(stderr) = child.stderr.take() {
393            let tx = event_tx;
394            tokio::spawn(async move {
395                let mut lines = BufReader::new(stderr).lines();
396                while let Ok(Some(line)) = lines.next_line().await {
397                    let _ = tx.send(LaunchEvent::Data(line)).await;
398                }
399            });
400        }
401
402        Ok(child)
403    }
404
405    /// Download the game and immediately launch it.
406    ///
407    /// Equivalent to `download_game` followed by `launch`. Returns the
408    /// [`tokio::process::Child`] handle so the caller can monitor or kill
409    /// the process.
410    ///
411    /// To receive a [`LaunchEvent::Close`] event, wait on the returned child
412    /// and send it yourself:
413    /// ```ignore
414    /// let code = child.wait().await?.code().unwrap_or(-1);
415    /// let _ = tx.send(LaunchEvent::Close(code)).await;
416    /// ```
417    pub async fn start(
418        &mut self,
419        event_tx: Sender<LaunchEvent>,
420    ) -> Result<tokio::process::Child, LaunchError> {
421        self.download_game(event_tx.clone()).await?;
422        self.launch(event_tx).await
423    }
424}
425
426// ── Tests ─────────────────────────────────────────────────────────────────────
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use std::path::PathBuf;
432
433    fn make_options() -> LaunchOptions {
434        use crate::launcher::options::{JavaOptions, LoaderConfig, MemoryConfig, ScreenConfig};
435        use crate::models::minecraft::Authenticator;
436        LaunchOptions {
437            path: PathBuf::from("/mc"),
438            version: "1.20.4".into(),
439            authenticator: Authenticator {
440                access_token: "test-token".into(),
441                name: "Player".into(),
442                uuid: "test-uuid".into(),
443                xbox_account: None,
444                user_properties: None,
445                client_id: None,
446                client_token: None,
447            },
448            timeout_secs: 10,
449            download_concurrency: 5,
450            verify_concurrency: 4,
451            memory: MemoryConfig::default(),
452            java: JavaOptions::default(),
453            loader: LoaderConfig::default(),
454            screen: ScreenConfig::default(),
455            verify: false,
456            game_args: vec![],
457            jvm_args: vec![],
458            instance: None,
459            url: None,
460            mcp: None,
461            intel_enabled_mac: false,
462            bypass_offline: false,
463        }
464    }
465
466    #[test]
467    fn launcher_new_stores_options() {
468        let opts = make_options();
469        let launcher = Launcher::new(opts.clone());
470        assert_eq!(launcher.options.version, "1.20.4");
471        assert_eq!(launcher.options.path, PathBuf::from("/mc"));
472    }
473
474    #[test]
475    fn launcher_save_dir_no_instance() {
476        let opts = make_options();
477        let launcher = Launcher::new(opts);
478        assert_eq!(launcher.options.save_dir(), PathBuf::from("/mc"));
479    }
480
481    #[test]
482    fn launcher_save_dir_with_instance() {
483        let mut opts = make_options();
484        opts.instance = Some("myworld".into());
485        let launcher = Launcher::new(opts);
486        assert_eq!(
487            launcher.options.save_dir(),
488            PathBuf::from("/mc/instances/myworld")
489        );
490    }
491
492    #[test]
493    fn sanitize_replaces_access_token() {
494        let token = "secret-access-token";
495        let cmd = format!("java -cp foo.jar Main --accessToken {token}");
496        let sanitized = cmd.replace(token, "<access_token>");
497        assert!(!sanitized.contains(token));
498        assert!(sanitized.contains("<access_token>"));
499    }
500
501    #[test]
502    fn all_args_order_is_correct() {
503        // Verify the expected CLI ordering: jvm_args, -cp, classpath, main_class, game_args
504        let jvm: Vec<String> = vec!["-Xms1G".into(), "-Xmx2G".into()];
505        let cp: Vec<String> = vec!["-cp".into(), "a.jar:b.jar".into()];
506        let main_class = "net.minecraft.client.main.Main".to_owned();
507        let game: Vec<String> = vec!["--username".into(), "Player".into()];
508
509        let mut all: Vec<String> = Vec::new();
510        all.extend(jvm);
511        all.extend(cp);
512        all.push(main_class.clone());
513        all.extend(game);
514
515        assert_eq!(all[0], "-Xms1G");
516        assert_eq!(all[2], "-cp");
517        assert_eq!(all[4], main_class);
518        assert_eq!(all[5], "--username");
519    }
520
521    #[test]
522    fn screen_args_appended_when_set() {
523        use crate::launcher::options::ScreenConfig;
524        let screen = ScreenConfig { width: Some(1920), height: Some(1080), fullscreen: false };
525        let mut game_args: Vec<String> = vec!["--version".into(), "1.20.4".into()];
526        if let Some(w) = screen.width {
527            game_args.push("--width".into());
528            game_args.push(w.to_string());
529        }
530        if let Some(h) = screen.height {
531            game_args.push("--height".into());
532            game_args.push(h.to_string());
533        }
534        assert!(game_args.contains(&"--width".to_string()));
535        assert!(game_args.contains(&"1920".to_string()));
536        assert!(game_args.contains(&"--height".to_string()));
537        assert!(game_args.contains(&"1080".to_string()));
538        assert!(!game_args.contains(&"--fullscreen".to_string()));
539    }
540
541    #[test]
542    fn screen_fullscreen_appended_when_set() {
543        use crate::launcher::options::ScreenConfig;
544        let screen = ScreenConfig { width: None, height: None, fullscreen: true };
545        let mut game_args: Vec<String> = vec![];
546        if screen.fullscreen {
547            game_args.push("--fullscreen".into());
548        }
549        assert!(game_args.contains(&"--fullscreen".to_string()));
550    }
551
552    #[test]
553    fn loader_main_class_overrides_vanilla() {
554        let vanilla = "net.minecraft.client.main.Main".to_owned();
555        let loader_main_class: Option<String> =
556            Some("net.fabricmc.loader.impl.launch.knot.KnotClient".into());
557        let main_class = loader_main_class.as_deref().unwrap_or(&vanilla).to_owned();
558        assert_eq!(main_class, "net.fabricmc.loader.impl.launch.knot.KnotClient");
559    }
560
561    #[test]
562    fn no_loader_main_class_uses_vanilla() {
563        let vanilla = "net.minecraft.client.main.Main".to_owned();
564        let loader_main_class: Option<String> = None;
565        let main_class = loader_main_class.as_deref().unwrap_or(&vanilla).to_owned();
566        assert_eq!(main_class, "net.minecraft.client.main.Main");
567    }
568}