diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 6082d1ee9..c46315f66 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -792,7 +792,7 @@ pub fn preparer( match sources::resolve(minecraft, &pins, &known, &linked, &fetch, log)? { sources::Resolved::Folder(dir) => dir, sources::Resolved::NotFound(checked) => { - let not_found = sources::not_found(&version, &checked); + let not_found = sources::not_found(&version, &checked, minecraft); download_for_owner(&pins, &own, &fetch, log).map_err(|e| not_found + &e)?; own } diff --git a/launcher/src/sources.rs b/launcher/src/sources.rs index f6a9753ea..864e6bc73 100644 --- a/launcher/src/sources.rs +++ b/launcher/src/sources.rs @@ -207,6 +207,33 @@ pub(crate) fn sha1_file(path: &Path) -> Option { Some(hash.finalize().iter().map(|b| format!("{b:02x}")).collect()) } +/// The Prism or MultiMC launcher root above `dir` (#693). Their instances live in +/// `instances//` (Prism's game folder is `minecraft`, MultiMC's `.minecraft`) and the +/// shared `libraries` and assets sit in the launcher root above `instances`, two or three +/// folders above what players naturally pick. +fn instance_root(dir: &Path) -> Option { + dir.ancestors() + .find(|a| a.file_name() == Some(std::ffi::OsStr::new("instances"))) + .and_then(Path::parent) + .map(Path::to_path_buf) +} + +/// The launcher whose tree `path` is inside, when its folder name is one Horizon knows, so the +/// not-found message can name it (#693). +fn launcher_of(path: &Path) -> Option<&'static str> { + path.ancestors() + .filter_map(Path::file_name) + .filter_map(|name| name.to_str()) + .find_map(|name| match name { + "PrismLauncher" => Some("Prism Launcher"), + "multimc" | "MultiMC" => Some("MultiMC"), + "ModrinthApp" | "com.modrinth.theseus" => Some("the Modrinth App"), + "atlauncher" | "ATLauncher" => Some("ATLauncher"), + "gdlauncher_carbon" | "gdlauncher_next" => Some("GDLauncher"), + _ => None, + }) +} + /// The pinned version in one launcher folder, or what the folder lacks. The folder holding /// `libraries` is the folder itself or its `meta` (Modrinth App), `data` (GDLauncher) or /// `datastore` (GDLauncher Next). @@ -248,19 +275,29 @@ pub fn find(dir: &Path, pins: &Pins) -> Result { } /// The error when no folder has the pinned version: every folder looked in with what it -/// lacks, and what the player can do about it (#419). -pub fn not_found(version: &str, checked: &[(PathBuf, String)]) -> String { +/// lacks, and what the player can do about it (#419, #693). When the chosen folder is inside a +/// launcher Horizon knows, the advice names it: a Prism or MultiMC instance downloads the game +/// only when it is launched once. +pub fn not_found(version: &str, checked: &[(PathBuf, String)], chosen: &Path) -> String { let mut text = format!("Minecraft {version} was not found. Horizon looked in:"); for (dir, missing) in checked { text += &format!("\n {} ({missing})", dir.display()); } - text += &format!( - "\nTo fix it:\n \ - • Play {version} once in the official Minecraft launcher, or in Prism Launcher, MultiMC, \ - the Modrinth App, ATLauncher or GDLauncher, then prepare again.\n \ - • Or choose the folder where your launcher keeps Minecraft {version} (Minecraft folder › \ - Choose… in the launcher, --minecraft on the command line)." - ); + text += "\nTo fix it:"; + text += &match launcher_of(chosen) { + Some(launcher) => format!( + "\n • Launch the {version} instance once in {launcher} so it downloads the game, \ + then prepare again.\n \ + • Or choose the folder {launcher} keeps Minecraft {version} in (Minecraft folder › \ + Choose… in the launcher, --minecraft on the command line)." + ), + None => format!( + "\n • Play {version} once in the official Minecraft launcher, or in Prism Launcher, \ + MultiMC, the Modrinth App, ATLauncher or GDLauncher, then prepare again.\n \ + • Or choose the folder where your launcher keeps Minecraft {version} (Minecraft \ + folder › Choose… in the launcher, --minecraft on the command line)." + ), + }; text } @@ -277,9 +314,9 @@ pub enum Resolved { } /// A Minecraft folder in the official launcher's layout holding the pinned version: the -/// chosen folder when it is one, else the files found in the chosen folder (or a folder above -/// it: a launcher's instance folder may be chosen) or any launcher's default folder, linked into -/// `linked`. +/// chosen folder when it is one, else the files found in the chosen folder, a folder above it +/// (a launcher's instance folder may be chosen, and the launcher root above a Prism or MultiMC +/// `instances` folder is looked in too) or any launcher's default folder, linked into `linked`. pub fn resolve( chosen: &Path, pins: &Pins, @@ -290,13 +327,22 @@ pub fn resolve( ) -> Result { let mut checked = Vec::new(); let mut found = None; - for dir in chosen.ancestors().take(4) { - match find(dir, pins) { + // The chosen folder, the folders above it (an instance's own folder may be chosen), and, + // when it is inside a Prism or MultiMC instance tree, the launcher root above `instances` + // that holds their shared `libraries` (#693). + let mut dirs: Vec = chosen.ancestors().take(4).map(Path::to_path_buf).collect(); + if let Some(root) = instance_root(chosen) { + if !dirs.contains(&root) { + dirs.push(root); + } + } + for dir in dirs { + match find(&dir, pins) { Ok(f) => { found = Some(("the chosen folder", f)); break; } - Err(missing) if dir == chosen => checked.push((dir.to_path_buf(), missing)), + Err(missing) if dir.as_path() == chosen => checked.push((dir, missing)), Err(_) => {} } } diff --git a/launcher/src/sources_tests.rs b/launcher/src/sources_tests.rs index 381d19f27..b273fa4ba 100644 --- a/launcher/src/sources_tests.rs +++ b/launcher/src/sources_tests.rs @@ -90,7 +90,7 @@ fn resolve_with( &|_| {}, )? { Resolved::Folder(dir) => Ok(dir), - Resolved::NotFound(checked) => Err(not_found("26.3", &checked)), + Resolved::NotFound(checked) => Err(not_found("26.3", &checked, chosen)), } } @@ -273,6 +273,75 @@ fn an_instance_folder_may_be_chosen() { assert_linked(&linked, &dir, jar); } +/// Every folder a Prism or MultiMC player naturally picks: the instance list, one instance, an +/// instance's game folder and a folder below it. The shared `libraries` are in the launcher +/// root, which the chosen folder's ancestors do not always reach (#693). +#[test] +fn a_prism_instances_folder_finds_the_launcher_root() { + for (name, chosen) in [ + ("list", "instances"), + ("instance", "instances/26.3"), + ("game", "instances/26.3/minecraft"), + // Deeper than the four folders above it: only the `instances` root helps here. + ("deeper", "instances/26.3/minecraft/saves"), + ] { + let s = Scratch::new(&format!("prism-tree-{name}")); + let jar = "libraries/com/mojang/minecraft/26.3/minecraft-26.3-client.jar"; + let dir = s.launcher("PrismLauncher", "", jar); + s.write( + "PrismLauncher/meta/net.minecraft/26.3.json", + br#"{"mainJar": {"name": "com.mojang:minecraft:26.3:client"}}"#, + ); + fs::create_dir_all(dir.join(chosen)).unwrap(); + let linked = s.0.join("linked"); + let got = resolve_with(&dir.join(chosen), &[], &linked, &Mojang::new(MANIFEST)).unwrap(); + assert_eq!(got, linked, "{chosen}"); + assert_linked(&linked, &dir, jar); + } +} + +#[test] +fn a_multimc_instances_folder_finds_the_launcher_root() { + let s = Scratch::new("multimc-tree"); + let jar = "libraries/com/mojang/minecraft/26.3/minecraft-26.3-client.jar"; + let dir = s.launcher("multimc", "", jar); + s.write( + "multimc/meta/net.minecraft/26.3.json", + br#"{"mainJar": {"name": "com.mojang:minecraft:26.3:client"}}"#, + ); + let chosen = dir.join("instances/26.3/.minecraft"); + fs::create_dir_all(&chosen).unwrap(); + let linked = s.0.join("linked"); + let got = resolve_with(&chosen, &[], &linked, &Mojang::new(MANIFEST)).unwrap(); + assert_eq!(got, linked); + assert_linked(&linked, &dir, jar); +} + +/// When the chosen folder is inside a launcher tree, the error names it and says to launch the +/// instance once, instead of listing every launcher (#693). +#[test] +fn the_not_found_message_names_the_launcher_that_was_chosen() { + let s = Scratch::new("prism-hint"); + let dir = s.0.join("PrismLauncher"); + let chosen = dir.join("instances/26.3/minecraft"); + fs::create_dir_all(&chosen).unwrap(); + let error = + resolve_with(&chosen, &[], &s.0.join("linked"), &Mojang::new(MANIFEST)).unwrap_err(); + assert!( + error.contains("Launch the 26.3 instance once in Prism Launcher"), + "{error}" + ); + assert!(!error.contains("or in Prism Launcher, MultiMC"), "{error}"); + // An unknown folder keeps the advice that names every launcher. + let other = s.0.join("elsewhere"); + fs::create_dir_all(&other).unwrap(); + let error = resolve_with(&other, &[], &s.0.join("linked"), &Mojang::new(MANIFEST)).unwrap_err(); + assert!( + error.contains("Prism Launcher, MultiMC, the Modrinth App, ATLauncher or GDLauncher"), + "{error}" + ); +} + #[test] fn the_known_folders_are_searched_after_the_chosen_one() { let s = Scratch::new("known");