1850 lines
61 KiB
Rust
1850 lines
61 KiB
Rust
use std::{
|
|
fmt,
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
use futures::stream::{FuturesUnordered, StreamExt};
|
|
use io::IOError;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
install::{
|
|
InstallPhaseDetails, InstallPhaseId, InstallProgress,
|
|
InstallProgressReporter,
|
|
},
|
|
launcher::download::LocalRuntimeSource,
|
|
state::{
|
|
ModLoader, State,
|
|
instances::{
|
|
adapters::sqlite::instance_rows,
|
|
watcher::{unwatch_instance_folder, watch_instance_folder},
|
|
},
|
|
},
|
|
util::{
|
|
fetch::{self, IoSemaphore},
|
|
io,
|
|
},
|
|
};
|
|
|
|
pub mod atlauncher;
|
|
mod axolotl;
|
|
pub mod curseforge;
|
|
pub(crate) mod direct_link;
|
|
pub mod gdlauncher;
|
|
pub(crate) mod generic;
|
|
pub mod hmcl;
|
|
pub mod hmcl_config;
|
|
mod instance_json;
|
|
pub mod mmc;
|
|
mod modrinth_app;
|
|
mod pcl;
|
|
pub use pcl::config_exists;
|
|
pub use pcl::read_pcl_registry;
|
|
pub mod pe_info;
|
|
|
|
/// A scanned importable instance with its resolved filesystem path.
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct ImportableInstance {
|
|
pub name: String,
|
|
pub path: String,
|
|
/// Whether this instance qualifies for compatible mode import (single
|
|
/// version with a shared `mods/` directory and no version isolation).
|
|
#[serde(default)]
|
|
pub compatible_mode: bool,
|
|
/// For compatible mode: the original version subfolder path containing
|
|
/// the version JSON. `path` is set to the GameDir, and `version_path`
|
|
/// records `<GameDir>/versions/<name>` so the importer can locate the JSON.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub version_path: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub enum ImportLauncherType {
|
|
Axolotl,
|
|
MultiMC,
|
|
PrismLauncher,
|
|
ATLauncher,
|
|
GDLauncher,
|
|
Curseforge,
|
|
ModrinthApp,
|
|
PCL2,
|
|
PCL2CE,
|
|
HMCL,
|
|
Generic,
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
// impl display
|
|
impl fmt::Display for ImportLauncherType {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
ImportLauncherType::Axolotl => write!(f, "Axolotl"),
|
|
ImportLauncherType::MultiMC => write!(f, "MultiMC"),
|
|
ImportLauncherType::PrismLauncher => write!(f, "PrismLauncher"),
|
|
ImportLauncherType::ATLauncher => write!(f, "ATLauncher"),
|
|
ImportLauncherType::GDLauncher => write!(f, "GDLauncher"),
|
|
ImportLauncherType::Curseforge => write!(f, "Curseforge"),
|
|
ImportLauncherType::ModrinthApp => {
|
|
write!(f, "Modrinth source installation")
|
|
}
|
|
ImportLauncherType::PCL2 => write!(f, "PCL2"),
|
|
ImportLauncherType::PCL2CE => write!(f, "PCL2CE"),
|
|
ImportLauncherType::HMCL => write!(f, "HMCL"),
|
|
ImportLauncherType::Generic => write!(f, "Generic"),
|
|
ImportLauncherType::Unknown => write!(f, "Unknown"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Return a list of importable instances from a launcher type and base path,
|
|
/// by iterating through the folder and checking each candidate.
|
|
pub async fn get_importable_instances(
|
|
launcher_type: ImportLauncherType,
|
|
base_path: PathBuf,
|
|
) -> crate::Result<Vec<ImportableInstance>> {
|
|
let instances = match launcher_type {
|
|
ImportLauncherType::Axolotl => get_axolotl_instances(&base_path).await,
|
|
ImportLauncherType::ModrinthApp => {
|
|
get_modrinth_app_instances(&base_path).await
|
|
}
|
|
ImportLauncherType::GDLauncher | ImportLauncherType::ATLauncher => {
|
|
get_instances_subfolder_scan(&base_path, "instances", launcher_type)
|
|
.await
|
|
}
|
|
ImportLauncherType::Curseforge => {
|
|
get_instances_subfolder_scan(&base_path, "Instances", launcher_type)
|
|
.await
|
|
}
|
|
ImportLauncherType::MultiMC => {
|
|
let subpath =
|
|
mmc::get_instances_subpath(base_path.join("multimc.cfg"))
|
|
.await
|
|
.unwrap_or_else(|| "instances".to_string());
|
|
get_instances_subfolder_scan(&base_path, &subpath, launcher_type)
|
|
.await
|
|
}
|
|
ImportLauncherType::PrismLauncher => {
|
|
let subpath =
|
|
mmc::get_instances_subpath(base_path.join("prismlauncher.cfg"))
|
|
.await
|
|
.unwrap_or_else(|| "instances".to_string());
|
|
get_instances_subfolder_scan(&base_path, &subpath, launcher_type)
|
|
.await
|
|
}
|
|
ImportLauncherType::PCL2 | ImportLauncherType::PCL2CE => {
|
|
get_pcl_instances(&base_path).await
|
|
}
|
|
ImportLauncherType::HMCL => get_hmcl_instances(&base_path).await,
|
|
ImportLauncherType::Generic => get_generic_instances(&base_path).await,
|
|
ImportLauncherType::Unknown => {
|
|
get_unknown_launcher_instances(&base_path).await
|
|
}
|
|
}?;
|
|
|
|
// For Generic, compatible mode is already checked inside
|
|
// `get_generic_instances`. For launchers that use
|
|
// `collect_launcher_instances` (PCL2/PCL2CE, HMCL, MultiMC, etc.),
|
|
// compatible mode is checked per-GameDir inside that function.
|
|
// No global check needed here.
|
|
|
|
Ok(instances)
|
|
}
|
|
|
|
async fn check_compatible_mode(instance: &mut ImportableInstance) {
|
|
let path = PathBuf::from(&instance.path);
|
|
let Some(version_name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
return;
|
|
};
|
|
let Some(parent) = path.parent() else {
|
|
return;
|
|
};
|
|
if parent.file_name().and_then(|n| n.to_str()) != Some("versions") {
|
|
return;
|
|
}
|
|
let Some(game_dir) = parent.parent() else {
|
|
return;
|
|
};
|
|
let mods_dir = game_dir.join("mods");
|
|
let version_dir = game_dir.join("versions").join(version_name);
|
|
|
|
let has_mods = mods_dir.is_dir();
|
|
|
|
let no_resourcepacks = !version_dir.join("resourcepacks").is_dir();
|
|
let has_valid_json = instance_json::detect(&version_dir).is_some();
|
|
|
|
if has_mods && no_resourcepacks && has_valid_json {
|
|
instance.compatible_mode = true;
|
|
}
|
|
}
|
|
|
|
fn finalize_compatible_instance(instance: &mut ImportableInstance) {
|
|
instance.version_path = Some(instance.path.clone());
|
|
if let Some(pos) = instance.name.rfind(':') {
|
|
instance.name = instance.name[pos + 1..].to_string();
|
|
}
|
|
if let Some(stripped) = instance.name.strip_prefix("versions/") {
|
|
instance.name = stripped.to_string();
|
|
}
|
|
let p = PathBuf::from(&instance.path);
|
|
if let Some(game_dir) = p.parent().and_then(|v| v.parent()) {
|
|
instance.path = game_dir.to_string_lossy().to_string();
|
|
}
|
|
}
|
|
|
|
/// Scans a launcher's instances subfolder, validating each candidate with the
|
|
/// launcher-specific check.
|
|
async fn get_instances_subfolder_scan(
|
|
base_path: &Path,
|
|
instances_subfolder: &str,
|
|
launcher_type: ImportLauncherType,
|
|
) -> crate::Result<Vec<ImportableInstance>> {
|
|
let instances_folder = base_path.join(instances_subfolder);
|
|
let mut result = Vec::new();
|
|
let mut dir = io::read_dir(&instances_folder).await.map_err(|_| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"Invalid {launcher_type} launcher path, could not find '{instances_subfolder}' subfolder."
|
|
))
|
|
})?;
|
|
while let Some(entry) = dir
|
|
.next_entry()
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, &instances_folder))?
|
|
{
|
|
let path = entry.path();
|
|
if path.is_dir()
|
|
&& is_valid_importable_instance(path.clone(), launcher_type).await
|
|
{
|
|
let name = path.file_name();
|
|
if let Some(name) = name {
|
|
let name = name.to_string_lossy().to_string();
|
|
result.push(ImportableInstance {
|
|
path: path.to_string_lossy().to_string(),
|
|
name,
|
|
compatible_mode: false,
|
|
version_path: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
/// Collects Modrinth launcher profiles from its internal database.
|
|
async fn get_modrinth_app_instances(
|
|
base_path: &Path,
|
|
) -> crate::Result<Vec<ImportableInstance>> {
|
|
let names =
|
|
modrinth_app::get_importable_instances(base_path.to_path_buf()).await?;
|
|
Ok(names
|
|
.into_iter()
|
|
.map(|n| ImportableInstance {
|
|
name: n.clone(),
|
|
path: n,
|
|
compatible_mode: false,
|
|
version_path: None,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// Collects PCL2 / PCL2CE instances from the registry and CE config,
|
|
/// de-duplicating paths that appear in both sources.
|
|
async fn get_pcl_instances(
|
|
base_path: &Path,
|
|
) -> crate::Result<Vec<ImportableInstance>> {
|
|
if !pe_info::folder_has_product(base_path, "Plain Craft Launcher") {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut collector = InstanceCollector::new();
|
|
// Try both PCL2 registry and PCLCE config — the user may have instances
|
|
// registered in either or both sources.
|
|
if pcl::read_pcl_registry().is_some() {
|
|
collect_launcher_instances(&mut collector, pcl::get_pcl_instances())
|
|
.await;
|
|
}
|
|
if pcl::config_exists() {
|
|
collect_launcher_instances(&mut collector, pcl::get_pclce_instances())
|
|
.await;
|
|
}
|
|
// Also check for a .minecraft folder next to the launcher executable.
|
|
if let Some(entry) = pcl::get_local_dotminecraft(base_path) {
|
|
collect_launcher_instances(&mut collector, vec![entry]).await;
|
|
}
|
|
Ok(collector.instances)
|
|
}
|
|
|
|
/// Collects HMCL instances from its launcher config.
|
|
async fn get_hmcl_instances(
|
|
base_path: &Path,
|
|
) -> crate::Result<Vec<ImportableInstance>> {
|
|
if !hmcl::config_exists(base_path) {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut collector = InstanceCollector::new();
|
|
collect_launcher_instances(&mut collector, hmcl::get_instances(base_path))
|
|
.await;
|
|
Ok(collector.instances)
|
|
}
|
|
|
|
/// Collects Axolotl instances from a base path. A base path holding
|
|
/// `axolotl_config.json` is itself an instance; otherwise direct child
|
|
/// folders with their own config are treated as instances.
|
|
async fn get_axolotl_instances(
|
|
base_path: &Path,
|
|
) -> crate::Result<Vec<ImportableInstance>> {
|
|
if base_path.join("axolotl_config.json").is_file() {
|
|
let name = base_path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "imported".to_string());
|
|
return Ok(vec![ImportableInstance {
|
|
name,
|
|
path: base_path.to_string_lossy().to_string(),
|
|
compatible_mode: false,
|
|
version_path: None,
|
|
}]);
|
|
}
|
|
|
|
let mut instances = Vec::new();
|
|
let mut dir = io::read_dir(base_path).await?;
|
|
while let Some(entry) = dir.next_entry().await? {
|
|
let path = entry.path();
|
|
if path.is_dir()
|
|
&& path.join("axolotl_config.json").is_file()
|
|
&& let Some(name) = path.file_name()
|
|
{
|
|
instances.push(ImportableInstance {
|
|
name: name.to_string_lossy().to_string(),
|
|
path: path.to_string_lossy().to_string(),
|
|
compatible_mode: false,
|
|
version_path: None,
|
|
});
|
|
}
|
|
}
|
|
Ok(instances)
|
|
}
|
|
|
|
/// Scans a generic launcher folder for instance JSONs.
|
|
async fn get_generic_instances(
|
|
base_path: &Path,
|
|
) -> crate::Result<Vec<ImportableInstance>> {
|
|
let mut scanned = scan_instances_at(base_path, None).await;
|
|
// A folder import commonly targets a launcher or pack directory whose
|
|
// game files live one level below it in `.minecraft`. Treat that nested
|
|
// root exactly like a directly selected `.minecraft` folder, preserving
|
|
// the version-directory path so the importer can resolve its metadata.
|
|
if base_path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.is_none_or(|name| !name.eq_ignore_ascii_case(".minecraft"))
|
|
{
|
|
scanned.extend(
|
|
scan_instances_at(&base_path.join(".minecraft"), None).await,
|
|
);
|
|
}
|
|
let mut collector = InstanceCollector::new();
|
|
for (name, path) in scanned {
|
|
collector.push(name, path);
|
|
}
|
|
let mut instances = collector.instances;
|
|
|
|
if instances.is_empty() && base_path.is_dir() {
|
|
let mut dir = io::read_dir(base_path).await?;
|
|
while let Some(entry) = dir.next_entry().await? {
|
|
let path = entry.path();
|
|
if path.is_dir()
|
|
&& instance_json::detect(&path).is_some()
|
|
&& let Some(name) = path.file_name()
|
|
{
|
|
instances.push(ImportableInstance {
|
|
name: name.to_string_lossy().to_string(),
|
|
path: path.to_string_lossy().to_string(),
|
|
compatible_mode: false,
|
|
version_path: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for instance in &mut instances {
|
|
check_compatible_mode(instance).await;
|
|
if instance.compatible_mode {
|
|
finalize_compatible_instance(instance);
|
|
}
|
|
}
|
|
|
|
Ok(instances)
|
|
}
|
|
|
|
/// Probes every known launcher type in a folder and merges all found
|
|
/// instances, de-duplicating by resolved path.
|
|
async fn get_unknown_launcher_instances(
|
|
base_path: &Path,
|
|
) -> crate::Result<Vec<ImportableInstance>> {
|
|
let mut collector = InstanceCollector::new();
|
|
|
|
// PCL2
|
|
if pe_info::folder_has_product(base_path, "Plain Craft Launcher")
|
|
&& pcl::read_pcl_registry().is_some()
|
|
{
|
|
collect_launcher_instances(&mut collector, pcl::get_pcl_instances())
|
|
.await;
|
|
}
|
|
|
|
// PCL2CE
|
|
if pe_info::folder_has_product(base_path, "Plain Craft Launcher")
|
|
&& pcl::config_exists()
|
|
{
|
|
collect_launcher_instances(&mut collector, pcl::get_pclce_instances())
|
|
.await;
|
|
}
|
|
|
|
// Also check for a .minecraft folder next to the launcher executable.
|
|
if pe_info::folder_has_product(base_path, "Plain Craft Launcher") {
|
|
if let Some(entry) = pcl::get_local_dotminecraft(base_path) {
|
|
collect_launcher_instances(&mut collector, vec![entry]).await;
|
|
}
|
|
}
|
|
|
|
// HMCL
|
|
if hmcl::config_exists(base_path) {
|
|
collect_launcher_instances(
|
|
&mut collector,
|
|
hmcl::get_instances(base_path),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// ModrinthApp: uses its internal SQLite database; query real physical
|
|
// profile paths for accurate dedup.
|
|
for (iname, ipath) in modrinth_app::get_importable_instances_with_paths(
|
|
base_path.to_path_buf(),
|
|
)
|
|
.await
|
|
.unwrap_or_default()
|
|
{
|
|
collector.push(iname, ipath);
|
|
}
|
|
|
|
collect_subfolder_launcher_instances(&mut collector, base_path).await;
|
|
|
|
// Generic fallback: scan versions/ subdirectory and base path for
|
|
// instance.json files (handles .minecraft and other unrecognized launchers).
|
|
if collector.instances.is_empty() {
|
|
for (name, path) in scan_instances_at(base_path, None).await {
|
|
collector.instances.push(ImportableInstance {
|
|
name,
|
|
path: path.to_string_lossy().to_string(),
|
|
compatible_mode: false,
|
|
version_path: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
collector.instances.sort_by(|a, b| a.name.cmp(&b.name));
|
|
Ok(collector.instances)
|
|
}
|
|
|
|
/// Collects instances from launchers with a dedicated instances subfolder
|
|
/// (MultiMC, PrismLauncher, ATLauncher, GDLauncher, CurseForge).
|
|
async fn collect_subfolder_launcher_instances(
|
|
collector: &mut InstanceCollector,
|
|
base_path: &Path,
|
|
) {
|
|
let other_types = [
|
|
(ImportLauncherType::MultiMC, "multimc.cfg"),
|
|
(ImportLauncherType::PrismLauncher, "prismlauncher.cfg"),
|
|
(ImportLauncherType::ATLauncher, "instances"),
|
|
(ImportLauncherType::GDLauncher, "instances"),
|
|
(ImportLauncherType::Curseforge, "Instances"),
|
|
];
|
|
for (lt, marker) in other_types {
|
|
let subpath = match lt {
|
|
ImportLauncherType::MultiMC | ImportLauncherType::PrismLauncher => {
|
|
mmc::get_instances_subpath(base_path.join(marker))
|
|
.await
|
|
.unwrap_or_else(|| "instances".to_string())
|
|
}
|
|
ImportLauncherType::ATLauncher | ImportLauncherType::GDLauncher => {
|
|
"instances".to_string()
|
|
}
|
|
ImportLauncherType::Curseforge => "Instances".to_string(),
|
|
_ => unreachable!(),
|
|
};
|
|
if let Ok(found) =
|
|
get_instances_subfolder_scan(base_path, &subpath, lt).await
|
|
{
|
|
for inst in found {
|
|
collector.push(inst.name, PathBuf::from(inst.path));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs `scan_instances_at` over every source folder and pushes de-duplicated
|
|
/// results into the collector.
|
|
async fn collect_launcher_instances(
|
|
collector: &mut InstanceCollector,
|
|
sources: Vec<(String, String)>,
|
|
) {
|
|
for (name, dir) in sources {
|
|
let game_dir = PathBuf::from(&dir);
|
|
let mut game_dir_instances = Vec::new();
|
|
for (iname, ipath) in scan_instances_at(&game_dir, Some(&name)).await {
|
|
game_dir_instances.push(ImportableInstance {
|
|
name: iname,
|
|
path: ipath.to_string_lossy().to_string(),
|
|
compatible_mode: false,
|
|
version_path: None,
|
|
});
|
|
}
|
|
for mut inst in game_dir_instances {
|
|
check_compatible_mode(&mut inst).await;
|
|
if inst.compatible_mode {
|
|
finalize_compatible_instance(&mut inst);
|
|
}
|
|
collector.push_instance(inst);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Collects importable instances while de-duplicating by resolved path.
|
|
struct InstanceCollector {
|
|
instances: Vec<ImportableInstance>,
|
|
seen: std::collections::HashSet<PathBuf>,
|
|
}
|
|
|
|
impl InstanceCollector {
|
|
fn new() -> Self {
|
|
Self {
|
|
instances: Vec::new(),
|
|
seen: std::collections::HashSet::new(),
|
|
}
|
|
}
|
|
|
|
/// Pushes an instance unless a path with the same resolved path was
|
|
/// already collected.
|
|
fn push(&mut self, name: String, path: PathBuf) {
|
|
self.push_instance(ImportableInstance {
|
|
name,
|
|
path: path.to_string_lossy().to_string(),
|
|
compatible_mode: false,
|
|
version_path: None,
|
|
});
|
|
}
|
|
|
|
fn push_instance(&mut self, instance: ImportableInstance) {
|
|
let key = instance
|
|
.version_path
|
|
.as_ref()
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from(&instance.path));
|
|
if self.seen.insert(key) {
|
|
self.instances.push(instance);
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn scan_instances_at(
|
|
path: &Path,
|
|
prefix: Option<&str>,
|
|
) -> Vec<(String, PathBuf)> {
|
|
if !path.is_dir() {
|
|
return Vec::new();
|
|
}
|
|
let mut instances = Vec::new();
|
|
|
|
// A `versions` container lists its direct children as instances. Keep
|
|
// the name as the plain child folder name so re-resolution does not turn
|
|
// it into `versions/versions/<id>`.
|
|
if path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
|
|
{
|
|
collect_child_instances(&mut instances, path, prefix, false).await;
|
|
return instances;
|
|
}
|
|
|
|
if instance_json::detect(path).is_some() {
|
|
let name = path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "imported".to_string());
|
|
instances.push((
|
|
if let Some(pre) = prefix {
|
|
format!("{pre}:{name}")
|
|
} else {
|
|
name
|
|
},
|
|
path.to_path_buf(),
|
|
));
|
|
}
|
|
let versions_dir = path.join("versions");
|
|
if versions_dir.is_dir() {
|
|
collect_child_instances(&mut instances, &versions_dir, prefix, true)
|
|
.await;
|
|
}
|
|
tracing::debug!(
|
|
"scan_instances_at: path={} prefix={:?} found={}",
|
|
path.display(),
|
|
prefix,
|
|
instances.len()
|
|
);
|
|
instances
|
|
}
|
|
|
|
/// Scans direct child folders of `dir` for importable instances and appends
|
|
/// them to `instances`. Child names are optionally prefixed with the launcher
|
|
/// name and, for a nested `versions/` folder, the `versions/` path segment.
|
|
async fn collect_child_instances(
|
|
instances: &mut Vec<(String, PathBuf)>,
|
|
dir: &Path,
|
|
prefix: Option<&str>,
|
|
is_versions_subdir: bool,
|
|
) {
|
|
let Ok(mut entries) = io::read_dir(dir).await else {
|
|
return;
|
|
};
|
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
let path = entry.path();
|
|
if path.is_dir()
|
|
&& instance_json::detect(&path).is_some()
|
|
&& let Some(name) = path.file_name()
|
|
{
|
|
let name = name.to_string_lossy().to_string();
|
|
let instance_name = if let Some(pre) = prefix {
|
|
if is_versions_subdir {
|
|
format!("{pre}:versions/{name}")
|
|
} else {
|
|
format!("{pre}:{name}")
|
|
}
|
|
} else if is_versions_subdir {
|
|
format!("versions/{name}")
|
|
} else {
|
|
name
|
|
};
|
|
instances.push((instance_name, path));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn split_generic_game_dir_and_version(
|
|
path: &Path,
|
|
) -> (PathBuf, Option<PathBuf>) {
|
|
let path_buf = path.to_path_buf();
|
|
if let Some(versions_dir) = path_buf.parent()
|
|
&& versions_dir
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
|
|
&& let Some(game_dir) = versions_dir.parent()
|
|
{
|
|
return (game_dir.to_path_buf(), Some(path_buf));
|
|
}
|
|
(path_buf, None)
|
|
}
|
|
|
|
/// Everything needed to import one instance, grouped so launcher-specific
|
|
/// importers don't need to thread a dozen parameters through every call.
|
|
struct ImportJob {
|
|
instance_id: String,
|
|
base_path: PathBuf,
|
|
instance_folder: String,
|
|
instance_path: PathBuf,
|
|
reporter: InstallProgressReporter,
|
|
symlink: bool,
|
|
overrides: ImportOverrides,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub(crate) struct ImportOverrides {
|
|
pub game_version: Option<String>,
|
|
pub loader: Option<ModLoader>,
|
|
pub loader_version: Option<String>,
|
|
/// The user's explicit game directory (version isolation choice). When set,
|
|
/// it is the absolute path the instance should use as its working
|
|
/// directory; when `None`, the layout is auto-detected.
|
|
pub game_dir_override: Option<String>,
|
|
}
|
|
|
|
pub(crate) async fn import_instance_with_reporter(
|
|
instance_id: &str,
|
|
launcher_type: ImportLauncherType,
|
|
base_path: PathBuf,
|
|
instance_folder: String,
|
|
instance_path: Option<String>,
|
|
overrides: ImportOverrides,
|
|
reporter: InstallProgressReporter,
|
|
symlink: bool,
|
|
) -> crate::Result<()> {
|
|
let instance_path = PathBuf::from(instance_path.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Import source path is required".to_string(),
|
|
)
|
|
})?);
|
|
if !instance_path.is_dir() {
|
|
return Err(crate::ErrorKind::InputError(format!(
|
|
"Import source path does not exist: {}",
|
|
instance_path.display()
|
|
))
|
|
.into());
|
|
}
|
|
import_instance_inner(
|
|
ImportJob {
|
|
instance_id: instance_id.to_string(),
|
|
base_path,
|
|
instance_folder,
|
|
instance_path,
|
|
reporter,
|
|
symlink,
|
|
overrides,
|
|
},
|
|
launcher_type,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn import_instance_inner(
|
|
job: ImportJob,
|
|
launcher_type: ImportLauncherType,
|
|
) -> crate::Result<()> {
|
|
let instance_id = job.instance_id.clone();
|
|
tracing::debug!(
|
|
"Importing instance from {} (symlink={}, launcher_type={launcher_type})",
|
|
job.instance_folder,
|
|
job.symlink
|
|
);
|
|
let details = InstallPhaseDetails::Import {
|
|
launcher_type,
|
|
instance_folder: job.instance_folder.clone(),
|
|
};
|
|
let res = if launcher_type == ImportLauncherType::Unknown {
|
|
import_unknown_launcher(job).await
|
|
} else {
|
|
import_via_launcher(launcher_type, &job, details).await
|
|
};
|
|
|
|
// If import failed, delete the profile
|
|
match res {
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
tracing::warn!("Import failed: {:?}", e);
|
|
let _ = crate::api::instance::remove(&instance_id).await;
|
|
return Err(e);
|
|
}
|
|
}
|
|
|
|
tracing::debug!("Completed import.");
|
|
Ok(())
|
|
}
|
|
|
|
/// Runs the launcher-specific import step for a known launcher type.
|
|
async fn import_via_launcher(
|
|
launcher_type: ImportLauncherType,
|
|
job: &ImportJob,
|
|
details: InstallPhaseDetails,
|
|
) -> crate::Result<()> {
|
|
let ImportJob {
|
|
instance_id,
|
|
base_path,
|
|
instance_folder,
|
|
instance_path,
|
|
reporter,
|
|
symlink,
|
|
overrides,
|
|
} = job;
|
|
|
|
match launcher_type {
|
|
ImportLauncherType::MultiMC | ImportLauncherType::PrismLauncher => {
|
|
mmc::import_mmc_instance_dir(
|
|
instance_path.clone(),
|
|
Some(base_path.join("icons")),
|
|
instance_id,
|
|
reporter.clone(),
|
|
details,
|
|
*symlink,
|
|
)
|
|
.await
|
|
}
|
|
ImportLauncherType::ATLauncher => {
|
|
atlauncher::import_atlauncher_dir(
|
|
base_path.clone(),
|
|
instance_path.clone(),
|
|
instance_id,
|
|
reporter.clone(),
|
|
details,
|
|
*symlink,
|
|
)
|
|
.await
|
|
}
|
|
ImportLauncherType::GDLauncher => {
|
|
gdlauncher::import_gdlauncher(
|
|
instance_path.clone(),
|
|
instance_id,
|
|
reporter.clone(),
|
|
details,
|
|
*symlink,
|
|
)
|
|
.await
|
|
}
|
|
ImportLauncherType::Curseforge => {
|
|
curseforge::import_curseforge(
|
|
instance_path.clone(),
|
|
instance_id,
|
|
reporter.clone(),
|
|
details,
|
|
*symlink,
|
|
)
|
|
.await
|
|
}
|
|
ImportLauncherType::ModrinthApp => {
|
|
modrinth_app::import_instance(
|
|
base_path.clone(),
|
|
instance_folder.clone(),
|
|
instance_id,
|
|
reporter.clone(),
|
|
details,
|
|
*symlink,
|
|
)
|
|
.await
|
|
}
|
|
ImportLauncherType::PCL2 | ImportLauncherType::PCL2CE => {
|
|
let (game_dir, version_path) =
|
|
split_generic_game_dir_and_version(instance_path);
|
|
generic::import_generic(
|
|
game_dir,
|
|
instance_id,
|
|
reporter.clone(),
|
|
details,
|
|
*symlink,
|
|
overrides,
|
|
version_path,
|
|
)
|
|
.await
|
|
}
|
|
ImportLauncherType::HMCL => {
|
|
let (game_dir, version_path) =
|
|
split_generic_game_dir_and_version(instance_path);
|
|
generic::import_generic(
|
|
game_dir,
|
|
instance_id,
|
|
reporter.clone(),
|
|
details,
|
|
*symlink,
|
|
overrides,
|
|
version_path,
|
|
)
|
|
.await
|
|
}
|
|
ImportLauncherType::Axolotl => {
|
|
axolotl::import_axolotl(
|
|
instance_path.clone(),
|
|
instance_id,
|
|
reporter.clone(),
|
|
details,
|
|
*symlink,
|
|
overrides,
|
|
)
|
|
.await
|
|
}
|
|
ImportLauncherType::Generic => {
|
|
let (game_dir, version_dir) =
|
|
split_generic_game_dir_and_version(instance_path);
|
|
generic::import_generic(
|
|
game_dir,
|
|
instance_id,
|
|
reporter.clone(),
|
|
details,
|
|
*symlink,
|
|
overrides,
|
|
version_dir,
|
|
)
|
|
.await
|
|
}
|
|
ImportLauncherType::Unknown => {
|
|
unreachable!("handled by import_instance_inner")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tries to identify an unknown launcher folder by probing each known
|
|
/// launcher type, then dispatches the matching import.
|
|
async fn import_unknown_launcher(job: ImportJob) -> crate::Result<()> {
|
|
let (game_dir, version_path) =
|
|
split_generic_game_dir_and_version(&job.instance_path);
|
|
generic::import_generic(
|
|
game_dir,
|
|
&job.instance_id,
|
|
job.reporter.clone(),
|
|
InstallPhaseDetails::Import {
|
|
launcher_type: ImportLauncherType::Unknown,
|
|
instance_folder: job.instance_folder,
|
|
},
|
|
job.symlink,
|
|
&job.overrides,
|
|
version_path,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Returns the default path for the given launcher type
|
|
/// None if it can't be found or doesn't exist
|
|
pub fn get_default_launcher_path(
|
|
r#type: ImportLauncherType,
|
|
) -> Option<PathBuf> {
|
|
let path = match r#type {
|
|
ImportLauncherType::MultiMC => {
|
|
return find_multimc_path();
|
|
}
|
|
ImportLauncherType::PrismLauncher => {
|
|
Some(dirs::data_dir()?.join("PrismLauncher"))
|
|
}
|
|
ImportLauncherType::ATLauncher => {
|
|
Some(dirs::data_dir()?.join("ATLauncher"))
|
|
}
|
|
ImportLauncherType::GDLauncher => {
|
|
Some(dirs::data_dir()?.join("gdlauncher_next"))
|
|
}
|
|
ImportLauncherType::Curseforge => {
|
|
let home = dirs::home_dir()?;
|
|
let primary = home.join("curseforge").join("minecraft");
|
|
if primary.exists() {
|
|
return Some(primary);
|
|
}
|
|
Some(dirs::document_dir()?.join("curseforge").join("minecraft"))
|
|
}
|
|
ImportLauncherType::ModrinthApp => {
|
|
Some(dirs::data_dir()?.join("ModrinthApp"))
|
|
}
|
|
ImportLauncherType::PCL2 => {
|
|
if pcl::read_pcl_registry().is_some() {
|
|
dirs::data_dir()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
ImportLauncherType::PCL2CE => {
|
|
if pcl::config_exists() {
|
|
dirs::data_dir()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
ImportLauncherType::Axolotl => None,
|
|
ImportLauncherType::HMCL => None,
|
|
ImportLauncherType::Generic => None,
|
|
ImportLauncherType::Unknown => None,
|
|
};
|
|
let path = path?;
|
|
if path.exists() { Some(path) } else { None }
|
|
}
|
|
|
|
/// Searches common locations for a MultiMC installation.
|
|
/// MultiMC stores data in its own application directory (not a standard data dir)
|
|
fn find_multimc_path() -> Option<PathBuf> {
|
|
let mut candidates: Vec<PathBuf> = Vec::new();
|
|
|
|
// Linux/macOS: ~/.local/share/multimc is the typical location
|
|
if let Some(data_dir) = dirs::data_dir() {
|
|
candidates.push(data_dir.join("multimc"));
|
|
candidates.push(data_dir.join("MultiMC"));
|
|
}
|
|
|
|
// Windows: check common extraction locations
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
if let Some(home) = dirs::home_dir() {
|
|
candidates.push(home.join("MultiMC"));
|
|
candidates.push(home.join("Desktop").join("MultiMC"));
|
|
candidates.push(home.join("Downloads").join("MultiMC"));
|
|
}
|
|
candidates.push(PathBuf::from("C:\\MultiMC"));
|
|
if let Some(program_files) =
|
|
std::env::var_os("ProgramFiles").map(PathBuf::from)
|
|
{
|
|
candidates.push(program_files.join("MultiMC"));
|
|
}
|
|
if let Some(program_files_x86) =
|
|
std::env::var_os("ProgramFiles(x86)").map(PathBuf::from)
|
|
{
|
|
candidates.push(program_files_x86.join("MultiMC"));
|
|
}
|
|
}
|
|
|
|
// macOS: MultiMC is a .app bundle with data inside MultiMC.app/Data/
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
candidates.push(PathBuf::from("/Applications/MultiMC.app/Data"));
|
|
if let Some(home) = dirs::home_dir() {
|
|
candidates.push(
|
|
home.join("Applications").join("MultiMC.app").join("Data"),
|
|
);
|
|
}
|
|
}
|
|
|
|
candidates
|
|
.into_iter()
|
|
.find(|p| p.join("multimc.cfg").exists())
|
|
}
|
|
|
|
/// Checks if this PathBuf is a valid instance for the given launcher type
|
|
|
|
#[tracing::instrument]
|
|
pub async fn is_valid_importable_instance(
|
|
instance_path: PathBuf,
|
|
r#type: ImportLauncherType,
|
|
) -> bool {
|
|
match r#type {
|
|
ImportLauncherType::MultiMC | ImportLauncherType::PrismLauncher => {
|
|
mmc::is_valid_mmc(instance_path).await
|
|
}
|
|
ImportLauncherType::ATLauncher => {
|
|
atlauncher::is_valid_atlauncher(instance_path).await
|
|
}
|
|
ImportLauncherType::GDLauncher => {
|
|
gdlauncher::is_valid_gdlauncher(instance_path).await
|
|
}
|
|
ImportLauncherType::Curseforge => {
|
|
curseforge::is_valid_curseforge(instance_path).await
|
|
}
|
|
ImportLauncherType::ModrinthApp => instance_path.is_dir(),
|
|
ImportLauncherType::Axolotl => {
|
|
instance_path.join("axolotl_config.json").is_file()
|
|
}
|
|
ImportLauncherType::PCL2
|
|
| ImportLauncherType::PCL2CE
|
|
| ImportLauncherType::HMCL
|
|
| ImportLauncherType::Generic => instance_path.is_dir(),
|
|
ImportLauncherType::Unknown => false,
|
|
}
|
|
}
|
|
|
|
/// Caches an image file in the filesystem into the cache directory, and returns the path to the cached file.
|
|
|
|
#[tracing::instrument]
|
|
pub async fn recache_icon(
|
|
icon_path: PathBuf,
|
|
) -> crate::Result<Option<PathBuf>> {
|
|
let state = crate::State::get().await?;
|
|
|
|
let bytes = tokio::fs::read(&icon_path).await;
|
|
if let Ok(bytes) = bytes {
|
|
let bytes = bytes::Bytes::from(bytes);
|
|
let cache_dir = &state.directories.caches_dir();
|
|
let semaphore = &state.io_semaphore;
|
|
Ok(Some(
|
|
fetch::write_cached_icon(
|
|
&icon_path.to_string_lossy(),
|
|
cache_dir,
|
|
bytes,
|
|
semaphore,
|
|
)
|
|
.await?,
|
|
))
|
|
} else {
|
|
// could not find icon (for instance, prism default icon, etc)
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn copy_dotminecraft_with_reporter(
|
|
instance_id: &str,
|
|
content_source: Option<PathBuf>,
|
|
version_dir: Option<PathBuf>,
|
|
isolated: bool,
|
|
io_semaphore: &IoSemaphore,
|
|
reporter: InstallProgressReporter,
|
|
details: InstallPhaseDetails,
|
|
) -> crate::Result<()> {
|
|
let instance_path =
|
|
crate::api::instance::get_full_path(instance_id).await?;
|
|
|
|
let mut files: Vec<(PathBuf, PathBuf)> = Vec::new();
|
|
|
|
if let Some(content_root) = &content_source {
|
|
// Copy the shared content (mods/saves/config/…). When a specific
|
|
// version folder is in play, every sibling under `versions/` belongs to
|
|
// a different instance and must not be cloned here.
|
|
//
|
|
// - shared import: keep the selected version, drop the rest;
|
|
// - isolated import (甲): drop the whole `versions/` tree here — the
|
|
// selected version is copied separately below and merged into the
|
|
// instance root.
|
|
let keep_version = if isolated {
|
|
None
|
|
} else {
|
|
version_dir.as_deref()
|
|
};
|
|
let mut content_files =
|
|
collect_dotminecraft_files(content_root, keep_version, isolated)
|
|
.await?;
|
|
files.append(&mut content_files);
|
|
}
|
|
|
|
if isolated && let Some(version) = &version_dir {
|
|
// Merge the selected version files (`<name>.json`, `<name>.jar`, and any
|
|
// nested `mods/`, `config/`, … that live inside the version folder)
|
|
// directly into the instance root so the instance directory becomes a
|
|
// self-contained game dir.
|
|
let mut version_files = collect_version_files(version).await?;
|
|
files.append(&mut version_files);
|
|
}
|
|
|
|
let total = files.len() as u64;
|
|
if total == 0 {
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::PreparingInstance,
|
|
Some(InstallProgress {
|
|
current: 0,
|
|
total: 0,
|
|
secondary: None,
|
|
}),
|
|
details,
|
|
)
|
|
.await?;
|
|
return Ok(());
|
|
}
|
|
|
|
copy_files_with_progress(
|
|
&instance_path,
|
|
files,
|
|
io_semaphore,
|
|
reporter,
|
|
details,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Collects all files under `.minecraft`, excluding launcher metadata files
|
|
/// at the source root (`<dirname>.json` and `<dirname>.jar`).
|
|
async fn collect_dotminecraft_files(
|
|
dotminecraft: &Path,
|
|
keep_version: Option<&Path>,
|
|
isolated: bool,
|
|
) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
|
|
// Collect all files recursively
|
|
let files = get_all_subfiles(dotminecraft, false).await?;
|
|
|
|
// Filter out launcher metadata files at the source root:
|
|
// <dirname>.json (instance config), <dirname>.jar (custom jar override)
|
|
let dirname = dotminecraft
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
let skip_json = format!("{dirname}.json");
|
|
let skip_jar = format!("{dirname}.jar");
|
|
|
|
// When a specific version folder is requested from a shared `.minecraft`
|
|
// root, every other entry under `versions/` belongs to a different
|
|
// instance. Resolve the relative keep-path once so the loop can compare
|
|
// cheaply (e.g. `versions/1.21.1-NeoForge_21.1.250`).
|
|
let keep_relative = keep_version
|
|
.and_then(|version| version.strip_prefix(dotminecraft).ok())
|
|
.map(|rel| rel.to_path_buf());
|
|
|
|
let mut collected = Vec::new();
|
|
for abs_path in files {
|
|
let metadata = tokio::fs::symlink_metadata(&abs_path)
|
|
.await
|
|
.map_err(|error| IOError::with_path(error, &abs_path))?;
|
|
if crate::util::io::is_symlink_or_reparse(&metadata) {
|
|
tracing::warn!(
|
|
path = %abs_path.display(),
|
|
"Skipping nested symlink or reparse point while copying an instance"
|
|
);
|
|
continue;
|
|
}
|
|
let Some(rel) = abs_path
|
|
.strip_prefix(dotminecraft)
|
|
.ok()
|
|
.map(Path::to_path_buf)
|
|
else {
|
|
continue;
|
|
};
|
|
|
|
// In the isolated strategy the whole `versions/` tree is handled
|
|
// separately (only the selected version is copied, and it is merged
|
|
// into the instance root), so skip it entirely here to avoid cloning
|
|
// sibling versions.
|
|
if isolated {
|
|
if rel.components().next().map(|c| c.as_os_str())
|
|
== Some("versions".as_ref())
|
|
{
|
|
continue;
|
|
}
|
|
} else if let Some(keep) = &keep_relative
|
|
&& is_other_version_entry(&rel, keep)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if rel
|
|
.parent()
|
|
.is_some_and(|path| !path.as_os_str().is_empty())
|
|
{
|
|
collected.push((abs_path, rel));
|
|
continue;
|
|
}
|
|
let name = rel.file_name().and_then(|name| name.to_str()).unwrap_or("");
|
|
if name != skip_json && name != skip_jar {
|
|
collected.push((abs_path, rel));
|
|
}
|
|
}
|
|
Ok(collected)
|
|
}
|
|
|
|
/// Collects every file inside a selected `versions/<name>` folder, mapping each
|
|
/// path relative to that folder so the contents merge directly into the
|
|
/// instance root (the instance becomes a self-contained, version-isolated game
|
|
/// dir).
|
|
async fn collect_version_files(
|
|
version_dir: &Path,
|
|
) -> crate::Result<Vec<(PathBuf, PathBuf)>> {
|
|
let files = get_all_subfiles(version_dir, false).await?;
|
|
let mut collected = Vec::new();
|
|
for abs_path in files {
|
|
let metadata = tokio::fs::symlink_metadata(&abs_path)
|
|
.await
|
|
.map_err(|error| IOError::with_path(error, &abs_path))?;
|
|
if crate::util::io::is_symlink_or_reparse(&metadata) {
|
|
tracing::warn!(
|
|
path = %abs_path.display(),
|
|
"Skipping nested symlink or reparse point while copying a version folder"
|
|
);
|
|
continue;
|
|
}
|
|
if let Ok(rel) = abs_path.strip_prefix(version_dir) {
|
|
let rel = rel.to_path_buf();
|
|
collected.push((abs_path, rel));
|
|
}
|
|
}
|
|
Ok(collected)
|
|
}
|
|
|
|
/// True if `rel` lives under `versions/` but does not belong to the selected
|
|
/// version folder `keep` (which is itself relative to the `.minecraft` root,
|
|
/// e.g. `versions/1.21.1-NeoForge_21.1.250`).
|
|
///
|
|
/// `rel` may be the version directory itself, a file directly inside it, or a
|
|
/// path nested deeper. Anything sharing the first two path components with
|
|
/// `keep` is kept; every other `versions/<other>` entry is excluded.
|
|
fn is_other_version_entry(rel: &Path, keep: &Path) -> bool {
|
|
let mut rel_components = rel.components();
|
|
let mut keep_components = keep.components();
|
|
|
|
// Both must start with the literal `versions` component.
|
|
if rel_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref())
|
|
{
|
|
return false;
|
|
}
|
|
if keep_components.next().map(|c| c.as_os_str())
|
|
!= Some("versions".as_ref())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let rel_version = rel_components.next().map(|c| c.as_os_str());
|
|
let keep_version = keep_components.next().map(|c| c.as_os_str());
|
|
|
|
// `rel` is always a *file* path relative to the `.minecraft` root. A file
|
|
// that sits directly under `versions/` (e.g. `versions/version_manifest.json`)
|
|
// has exactly two components and is shared metadata, not a version folder:
|
|
// leave it alone. Only when there is at least a third component
|
|
// (`versions/<name>/<file>`) can the second component be treated as a
|
|
// version directory name.
|
|
let rel_is_inside_version_dir = rel_components.next().is_some();
|
|
|
|
match (rel_version, keep_version) {
|
|
(Some(rel_v), Some(keep_v)) if rel_is_inside_version_dir => {
|
|
rel_v != keep_v
|
|
}
|
|
// A file directly under `versions/`, shared metadata: keep it.
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Copies the collected files into the instance profile concurrently, bounded
|
|
/// by the I/O semaphore, reporting progress after every completed file.
|
|
async fn copy_files_with_progress(
|
|
instance_path: &Path,
|
|
files: Vec<(PathBuf, PathBuf)>,
|
|
io_semaphore: &IoSemaphore,
|
|
reporter: InstallProgressReporter,
|
|
details: InstallPhaseDetails,
|
|
) -> crate::Result<()> {
|
|
let total = files.len() as u64;
|
|
|
|
// Build (src, dst) pairs, then copy concurrently bounded by IoSemaphore
|
|
let mut copy_tasks: FuturesUnordered<_> = files
|
|
.into_iter()
|
|
.map(|(src, rel)| {
|
|
let dst = instance_path.join(&rel);
|
|
(src, dst)
|
|
})
|
|
.map(|(src, dst)| {
|
|
async move {
|
|
// Skip copying if destination file exists and is identical
|
|
if tokio::fs::metadata(&dst).await.is_ok()
|
|
&& let (Ok(src_meta), Ok(dst_meta)) = (
|
|
tokio::fs::metadata(&src).await,
|
|
tokio::fs::metadata(&dst).await,
|
|
)
|
|
{
|
|
// If files have identical size and modification time, skip copying
|
|
if src_meta.len() == dst_meta.len()
|
|
&& src_meta.modified().ok() == dst_meta.modified().ok()
|
|
{
|
|
return Ok::<_, crate::Error>(());
|
|
}
|
|
}
|
|
|
|
// Proceed with copy
|
|
fetch::copy(&src, &dst, io_semaphore).await?;
|
|
Ok(())
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let mut completed: u64 = 0;
|
|
while let Some(result) = copy_tasks.next().await {
|
|
result?;
|
|
completed += 1;
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::PreparingInstance,
|
|
Some(InstallProgress {
|
|
current: completed,
|
|
total,
|
|
secondary: None,
|
|
}),
|
|
details.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// Final 100% report (ensures the bar fills even if reporter throttles the last update)
|
|
reporter
|
|
.update(
|
|
InstallPhaseId::PreparingInstance,
|
|
Some(InstallProgress {
|
|
current: total,
|
|
total,
|
|
secondary: None,
|
|
}),
|
|
details,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Determines the real game working directory for an import whose source is a
|
|
/// `versions/<name>` folder.
|
|
///
|
|
/// The game body jar always lives at `<root>/versions/<name>/<name>.jar`
|
|
/// regardless of version isolation (that layout has been stable since 1.6), so
|
|
/// the jar alone cannot tell the two layouts apart — `dir_has_game_body` only
|
|
/// proves "this is a Minecraft game root". The discriminator is where the game
|
|
/// *content* (mods/saves/config/…) actually sits:
|
|
///
|
|
/// - inside the source folder → version-isolated: the version folder itself is
|
|
/// the game dir (`<root>/versions/<name>` with its own mods/saves/config);
|
|
/// - at an ancestor (normally the `.minecraft` root) → shared install: the
|
|
/// `.minecraft` root is the game dir.
|
|
///
|
|
/// A source with no content anywhere (a fresh, never-launched instance) falls
|
|
/// back to the source folder itself: the game creates the content folders
|
|
/// there on first run, and for imports the user's explicit game-dir choice
|
|
/// (or no override, i.e. the managed symlink) decides the rest.
|
|
pub(crate) fn resolve_import_game_root(source: &Path) -> PathBuf {
|
|
// The source is itself the game root: either a whole Minecraft folder that
|
|
// carries a game body, or any folder that already holds game content
|
|
// (a version-isolated `versions/<name>` with mods/saves/config inside).
|
|
if source.is_dir()
|
|
&& (dir_has_game_content(source) || dir_has_game_body(source))
|
|
{
|
|
return source.to_path_buf();
|
|
}
|
|
|
|
// Otherwise the source is a `versions/<name>` subfolder of a shared
|
|
// install: resolve to the nearest ancestor holding the game content
|
|
// (normally the `.minecraft` root). Never climb past `.minecraft`.
|
|
let mut cursor = source.parent();
|
|
while let Some(dir) = cursor {
|
|
if dir_has_game_content(dir) {
|
|
return dir.to_path_buf();
|
|
}
|
|
if dir.file_name().and_then(|n| n.to_str()) == Some(".minecraft") {
|
|
break;
|
|
}
|
|
cursor = dir.parent();
|
|
}
|
|
|
|
source.to_path_buf()
|
|
}
|
|
|
|
/// True if `root` is a Minecraft game root: it has at least one `.jar` game body
|
|
/// under `versions/<name>/` (e.g. `versions/1.20.1/1.20.1.jar`).
|
|
fn dir_has_game_body(root: &Path) -> bool {
|
|
let versions = root.join("versions");
|
|
let Ok(entries) = std::fs::read_dir(&versions) else {
|
|
return false;
|
|
};
|
|
for version_entry in entries.flatten() {
|
|
if !version_entry.path().is_dir() {
|
|
continue;
|
|
}
|
|
let Ok(version_files) = std::fs::read_dir(version_entry.path()) else {
|
|
continue;
|
|
};
|
|
for file in version_files.flatten() {
|
|
if file.path().extension().is_some_and(|ext| ext == "jar") {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// True if `root` directly holds game *content* — the folders/files the running
|
|
/// game creates and writes in its working directory (mods, saves, config,
|
|
/// resourcepacks, …). Unlike the game body jar (which sits at
|
|
/// `<root>/versions/<name>/<name>.jar` in every layout), content appears only in
|
|
/// the folder that actually acts as the game dir, so this is what distinguishes
|
|
/// a version-isolated `versions/<name>` folder from a shared `.minecraft` root.
|
|
fn dir_has_game_content(root: &Path) -> bool {
|
|
[
|
|
"mods",
|
|
"saves",
|
|
"config",
|
|
"resourcepacks",
|
|
"datapacks",
|
|
"shaderpacks",
|
|
"logs",
|
|
"crash-reports",
|
|
]
|
|
.iter()
|
|
.any(|dir| root.join(dir).is_dir())
|
|
|| root.join("options.txt").is_file()
|
|
|| root.join("servers.dat").is_file()
|
|
}
|
|
|
|
pub(crate) async fn finish_import(
|
|
instance_id: &str,
|
|
content_source: Option<PathBuf>,
|
|
version_dir: Option<PathBuf>,
|
|
isolated: bool,
|
|
io_semaphore: &IoSemaphore,
|
|
reporter: InstallProgressReporter,
|
|
details: InstallPhaseDetails,
|
|
symlink: bool,
|
|
) -> crate::Result<()> {
|
|
// The directory the game body / version JSON lives in, used to discover the
|
|
// local runtime source. Prefer the selected version folder, else the
|
|
// content root.
|
|
let primary_source = version_dir
|
|
.clone()
|
|
.or_else(|| content_source.clone())
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Import has no content source".to_string(),
|
|
)
|
|
})?;
|
|
let local_source = LocalRuntimeSource::discover(&primary_source);
|
|
|
|
let state = crate::state::State::get().await?;
|
|
let pool = &state.pool;
|
|
let existing_override =
|
|
instance_rows::get_instance_path_and_game_dir_override_by_id(
|
|
instance_id,
|
|
pool,
|
|
)
|
|
.await?
|
|
.map(|(_, override_dir)| override_dir)
|
|
.unwrap_or(None);
|
|
if existing_override.is_none() && symlink {
|
|
// For a symlinked import the game dir is the referenced source root,
|
|
// not the empty managed instance folder. Record it so the instance
|
|
// launches from the real location.
|
|
if let Some(content_root) = &content_source {
|
|
crate::state::edit_instance(
|
|
instance_id,
|
|
crate::state::EditInstance {
|
|
game_dir_override: Some(Some(
|
|
content_root.to_string_lossy().to_string(),
|
|
)),
|
|
..Default::default()
|
|
},
|
|
pool,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
if symlink {
|
|
let source_root = content_source.clone().ok_or_else(|| {
|
|
crate::ErrorKind::InputError(
|
|
"Symlink import requires a content source".to_string(),
|
|
)
|
|
})?;
|
|
let state = State::get().await?;
|
|
let relative_path =
|
|
instance_rows::get_instance_path_by_id(instance_id, &state.pool)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError("Unknown instance".to_string())
|
|
})?;
|
|
// The instance's managed folder lives at instances_dir/<path>. This is
|
|
// where the symlink is created; it must NOT go through the game-dir
|
|
// override (which points at the external source root).
|
|
let instance_path =
|
|
state.directories.instances_dir().join(&relative_path);
|
|
|
|
if instance_path.exists() {
|
|
// The instance folder is registered with the file watcher as soon
|
|
// as the instance row is created. On Windows an active watch keeps
|
|
// an open directory handle, so renaming the folder fails with
|
|
// ERROR_ACCESS_DENIED. Unwatch it first, then re-register once the
|
|
// symlink is in place (or the backup has been restored).
|
|
unwatch_instance_folder(
|
|
&relative_path,
|
|
&instance_path,
|
|
&state.file_watcher,
|
|
)
|
|
.await;
|
|
|
|
// Never delete the existing instance directory before the new
|
|
// symlink is in place: a failure between the two would lose the
|
|
// original data. Move it aside, create the link, then clean up
|
|
// the backup; on failure the backup is moved back.
|
|
let name = instance_path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("instance");
|
|
let timestamp = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis())
|
|
.unwrap_or_default();
|
|
let backup_path = instance_path
|
|
.parent()
|
|
.unwrap_or_else(|| std::path::Path::new(""))
|
|
.join(format!("{name}.bak-{timestamp}"));
|
|
|
|
if let Err(error) =
|
|
rename_instance_dir_for_symlink(&instance_path, &backup_path)
|
|
.await
|
|
{
|
|
watch_instance_folder(
|
|
instance_id,
|
|
&relative_path,
|
|
&instance_path,
|
|
&state.file_watcher,
|
|
)
|
|
.await;
|
|
return Err(error.into());
|
|
}
|
|
if let Err(error) =
|
|
io::create_symlink(&source_root, &instance_path).await
|
|
{
|
|
let _ = io::rename_or_move(&backup_path, &instance_path).await;
|
|
watch_instance_folder(
|
|
instance_id,
|
|
&relative_path,
|
|
&instance_path,
|
|
&state.file_watcher,
|
|
)
|
|
.await;
|
|
return Err(error.into());
|
|
}
|
|
let _ = io::remove_dir_all(&backup_path).await;
|
|
watch_instance_folder(
|
|
instance_id,
|
|
&relative_path,
|
|
&instance_path,
|
|
&state.file_watcher,
|
|
)
|
|
.await;
|
|
} else {
|
|
io::create_symlink(&source_root, &instance_path).await?;
|
|
}
|
|
|
|
crate::state::edit_instance(
|
|
instance_id,
|
|
crate::state::EditInstance {
|
|
symlink_target: Some(Some(
|
|
source_root.to_string_lossy().to_string(),
|
|
)),
|
|
..Default::default()
|
|
},
|
|
&crate::state::State::get().await?.pool,
|
|
)
|
|
.await?;
|
|
} else {
|
|
copy_dotminecraft_with_reporter(
|
|
instance_id,
|
|
content_source,
|
|
version_dir,
|
|
isolated,
|
|
io_semaphore,
|
|
reporter.clone(),
|
|
details,
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
crate::launcher::install_minecraft_for_instance_id_with_local_source(
|
|
instance_id,
|
|
local_source,
|
|
false,
|
|
Some(reporter),
|
|
crate::launcher::InstanceCompletionPolicy::DeferToInstallJob,
|
|
)
|
|
.await?;
|
|
|
|
let recognition_instance_id = instance_id.to_string();
|
|
tokio::spawn(async move {
|
|
if let Err(error) = crate::api::curseforge::recognize_instance_files(
|
|
&recognition_instance_id,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
instance_id = %recognition_instance_id,
|
|
%error,
|
|
"CurseForge file recognition after import failed"
|
|
);
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Moves a pre-existing instance directory aside so a symlink can take its
|
|
/// place.
|
|
///
|
|
/// The folder is unwatched just before this runs, but the watcher closes its
|
|
/// directory handles asynchronously; on Windows a rename attempted in that
|
|
/// window fails with `ERROR_ACCESS_DENIED`. Retry briefly before failing.
|
|
async fn rename_instance_dir_for_symlink(
|
|
from: &Path,
|
|
to: &Path,
|
|
) -> eyre::Result<()> {
|
|
let mut last_error = None;
|
|
for attempt in 1..=8 {
|
|
match io::rename_or_move(from, to).await {
|
|
Ok(()) => return Ok(()),
|
|
Err(error) if is_busy_dir_error(&error) => {
|
|
tracing::debug!(
|
|
"Instance directory {from:?} still busy, retrying rename ({attempt}/8)"
|
|
);
|
|
last_error = Some(error);
|
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
|
}
|
|
Err(error) => return Err(error),
|
|
}
|
|
}
|
|
Err(last_error
|
|
.unwrap_or_else(|| eyre::eyre!("Instance directory rename failed")))
|
|
}
|
|
|
|
/// Whether a rename error means the destination directory is still busy and
|
|
/// the operation may succeed shortly (Windows `ERROR_ACCESS_DENIED` and
|
|
/// `ERROR_SHARING_VIOLATION`).
|
|
fn is_busy_dir_error(error: &eyre::Report) -> bool {
|
|
#[cfg(windows)]
|
|
{
|
|
error.chain().any(|cause| {
|
|
cause
|
|
.downcast_ref::<std::io::Error>()
|
|
.is_some_and(|io_error| {
|
|
matches!(io_error.raw_os_error(), Some(5 | 32))
|
|
})
|
|
})
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
let _ = error;
|
|
false
|
|
}
|
|
}
|
|
|
|
#[async_recursion::async_recursion]
|
|
#[tracing::instrument]
|
|
pub async fn get_all_subfiles(
|
|
src: &Path,
|
|
include_empty_dirs: bool,
|
|
) -> crate::Result<Vec<PathBuf>> {
|
|
let meta = tokio::fs::symlink_metadata(src)
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, src))?;
|
|
|
|
// Symlink / reparse point: never follow, treat as leaf file.
|
|
if crate::util::io::is_symlink_or_reparse(&meta) {
|
|
return Ok(vec![src.to_path_buf()]);
|
|
}
|
|
|
|
if meta.is_file() {
|
|
return Ok(vec![src.to_path_buf()]);
|
|
}
|
|
|
|
let mut files = Vec::new();
|
|
let mut dir = io::read_dir(&src).await?;
|
|
|
|
let mut has_files = false;
|
|
while let Some(child) = dir
|
|
.next_entry()
|
|
.await
|
|
.map_err(|e| IOError::with_path(e, src))?
|
|
{
|
|
has_files = true;
|
|
let src_child = child.path();
|
|
files.append(
|
|
&mut get_all_subfiles(&src_child, include_empty_dirs).await?,
|
|
);
|
|
}
|
|
|
|
if !has_files && include_empty_dirs {
|
|
files.push(src.to_path_buf());
|
|
}
|
|
|
|
Ok(files)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod import_game_root_tests {
|
|
use super::{dir_has_game_body, resolve_import_game_root};
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use tempfile::tempdir;
|
|
|
|
fn write_game_body(mc: &Path, name: &str) {
|
|
let version_dir = mc.join("versions").join(name);
|
|
fs::create_dir_all(&version_dir).unwrap();
|
|
fs::write(version_dir.join(format!("{name}.jar")), "game").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_minecraft_root_when_game_body_is_there() {
|
|
let root = tempdir().unwrap();
|
|
let mc = root.path().join(".minecraft");
|
|
let version = mc.join("versions").join("My Pack");
|
|
// Shared install, played as vanilla: game body under the `.minecraft`
|
|
// root and content (saves) there too — a mod-less instance has no
|
|
// `mods/*.jar`, so the content location is what resolves the root.
|
|
write_game_body(&mc, "My Pack");
|
|
fs::create_dir_all(mc.join("saves")).unwrap();
|
|
|
|
let resolved = resolve_import_game_root(&version);
|
|
assert_eq!(resolved, mc);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_version_dir_when_it_has_the_content() {
|
|
let root = tempdir().unwrap();
|
|
let mc = root.path().join(".minecraft");
|
|
let version = mc.join("versions").join("My Pack");
|
|
// Version-isolated instance: the game body jar ALSO lives under the
|
|
// `.minecraft` root, but the content (mods) is inside the version
|
|
// folder — that folder is the real game dir.
|
|
write_game_body(&mc, "My Pack");
|
|
fs::create_dir_all(version.join("mods")).unwrap();
|
|
|
|
let resolved = resolve_import_game_root(&version);
|
|
assert_eq!(resolved, version);
|
|
}
|
|
|
|
#[test]
|
|
fn resolves_minecraft_root_for_shared_mods() {
|
|
let root = tempdir().unwrap();
|
|
let mc = root.path().join(".minecraft");
|
|
let version = mc.join("versions").join("My Pack");
|
|
write_game_body(&mc, "My Pack");
|
|
// Shared install with mods at the `.minecraft` root only.
|
|
fs::create_dir_all(mc.join("mods")).unwrap();
|
|
fs::write(mc.join("mods/mod-a.jar"), "a").unwrap();
|
|
|
|
let resolved = resolve_import_game_root(&version);
|
|
assert_eq!(resolved, mc);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_version_dir_for_fresh_isolated_instance() {
|
|
let root = tempdir().unwrap();
|
|
let mc = root.path().join(".minecraft");
|
|
let version = mc.join("versions").join("My Pack");
|
|
// Fresh instance, never launched: only the game body exists, no content
|
|
// folders anywhere. The jar's location is ambiguous (shared vs
|
|
// isolated), so fall back to the innermost folder — the launcher will
|
|
// create content there on first run.
|
|
write_game_body(&mc, "My Pack");
|
|
|
|
let resolved = resolve_import_game_root(&version);
|
|
assert_eq!(resolved, version);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_minecraft_root_when_source_is_root() {
|
|
let root = tempdir().unwrap();
|
|
let mc = root.path().join(".minecraft");
|
|
write_game_body(&mc, "My Pack");
|
|
|
|
let resolved = resolve_import_game_root(&mc);
|
|
assert_eq!(resolved, mc);
|
|
}
|
|
|
|
#[test]
|
|
fn no_game_body_falls_back_to_source() {
|
|
let root = tempdir().unwrap();
|
|
let version = root.path().join(".minecraft/versions/My Pack");
|
|
fs::create_dir_all(&version).unwrap();
|
|
|
|
let resolved = resolve_import_game_root(&version);
|
|
assert_eq!(resolved, version);
|
|
}
|
|
|
|
#[test]
|
|
fn detects_game_body_in_versions() {
|
|
let root = tempdir().unwrap();
|
|
let mc = root.path().join(".minecraft");
|
|
assert!(!dir_has_game_body(&mc));
|
|
|
|
write_game_body(&mc, "My Pack");
|
|
assert!(dir_has_game_body(&mc));
|
|
|
|
// A mods/ jar alone is not a game body signal.
|
|
let empty = root.path().join("no-versions");
|
|
fs::create_dir_all(empty.join("mods")).unwrap();
|
|
fs::write(empty.join("mods/mod.jar"), "mod").unwrap();
|
|
assert!(!dir_has_game_body(&empty));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod generic_instance_scan_tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[tokio::test]
|
|
async fn finds_versions_inside_a_selected_folders_minecraft_directory() {
|
|
let folder = tempdir().unwrap();
|
|
let version_dir = folder
|
|
.path()
|
|
.join(".minecraft")
|
|
.join("versions")
|
|
.join("1.20.1-fabric");
|
|
std::fs::create_dir_all(&version_dir).unwrap();
|
|
std::fs::write(
|
|
version_dir.join("1.20.1-fabric.json"),
|
|
r#"{"id":"1.20.1-fabric","inheritsFrom":"1.20.1"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let instances = get_generic_instances(folder.path()).await.unwrap();
|
|
|
|
assert_eq!(instances.len(), 1);
|
|
assert_eq!(instances[0].name, "versions/1.20.1-fabric");
|
|
assert_eq!(instances[0].path, version_dir.to_string_lossy());
|
|
}
|
|
}
|