Compare commits
2 Commits
a477b1fb9a
...
ffe529df57
| Author | SHA1 | Date | |
|---|---|---|---|
| ffe529df57 | |||
| 984f3ddbd6 |
@ -266,7 +266,9 @@ async fn import_atlauncher_unmanaged(
|
||||
let state = State::get().await?;
|
||||
finish_import(
|
||||
instance_id,
|
||||
minecraft_folder,
|
||||
Some(minecraft_folder),
|
||||
None,
|
||||
false,
|
||||
&state.io_semaphore,
|
||||
reporter,
|
||||
details,
|
||||
|
||||
@ -170,7 +170,9 @@ pub(crate) async fn import_axolotl(
|
||||
|
||||
finish_import(
|
||||
instance_id,
|
||||
source_path,
|
||||
Some(source_path),
|
||||
None,
|
||||
false,
|
||||
&state.io_semaphore,
|
||||
reporter,
|
||||
details,
|
||||
|
||||
@ -221,7 +221,9 @@ pub async fn import_curseforge(
|
||||
let state = State::get().await?;
|
||||
finish_import(
|
||||
instance_id,
|
||||
curseforge_instance_folder,
|
||||
Some(curseforge_instance_folder),
|
||||
None,
|
||||
false,
|
||||
&state.io_semaphore,
|
||||
reporter,
|
||||
details,
|
||||
|
||||
@ -118,7 +118,9 @@ pub async fn import_gdlauncher(
|
||||
let state = State::get().await?;
|
||||
finish_import(
|
||||
instance_id,
|
||||
gdlauncher_instance_folder,
|
||||
Some(gdlauncher_instance_folder),
|
||||
None,
|
||||
false,
|
||||
&state.io_semaphore,
|
||||
reporter,
|
||||
details,
|
||||
|
||||
@ -3,7 +3,7 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use super::{ImportOverrides, instance_json};
|
||||
use super::{ImportOverrides, instance_json, resolve_import_game_root};
|
||||
use crate::{
|
||||
State,
|
||||
install::{InstallPhaseDetails, InstallProgressReporter},
|
||||
@ -29,29 +29,129 @@ pub async fn import_generic(
|
||||
overrides: &ImportOverrides,
|
||||
instance_path: Option<PathBuf>, // For compatible mode: path to versions/<version>/
|
||||
) -> crate::Result<()> {
|
||||
let (name, dotminecraft, json_path) = if let Some(ref inst_path) =
|
||||
instance_path
|
||||
{
|
||||
let name = inst_path
|
||||
// Resolve the source layout. Three inputs describe the same import from
|
||||
// different angles and must be reconciled consistently:
|
||||
//
|
||||
// - `instance_folder`: the game root chosen by the caller (normally the
|
||||
// `.minecraft` root for a PCL/HMCL install, or the folder itself).
|
||||
// - `instance_path`: when present, the specific `versions/<name>` folder
|
||||
// the user selected. A `.minecraft` root can hold many versions; only
|
||||
// this one belongs to the instance being imported.
|
||||
// - `overrides.game_dir_override`: the user's explicit version-isolation
|
||||
// choice.
|
||||
//
|
||||
// The old behaviour copied/symlinked the whole `.minecraft` root and let
|
||||
// the version folder dangle, which produced vanilla-only copies (mods
|
||||
// stayed in versions/<name>) and cloned every sibling version too.
|
||||
let layout = resolve_import_layout(
|
||||
&instance_folder,
|
||||
instance_path.as_deref(),
|
||||
overrides.game_dir_override.as_deref(),
|
||||
);
|
||||
|
||||
let info = detect_instance_info(&layout.json_source, overrides).await?;
|
||||
register_instance(instance_id, &layout.name, &info).await?;
|
||||
copy_instance_files(instance_id, &layout, reporter, details, symlink)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The resolved source layout for a generic import.
|
||||
///
|
||||
/// `content_source` holds the shared game content (mods/saves/config) that
|
||||
/// belongs to the instance; `version_dir` is the selected `versions/<name>`
|
||||
/// folder. Both are merged into the instance directory by the copy/symlink
|
||||
/// stage, so a version-isolated import keeps the root-level mods it used to
|
||||
/// leave behind.
|
||||
struct ImportLayout {
|
||||
/// Display name for the instance (the version folder name when isolated,
|
||||
/// otherwise the game root folder name).
|
||||
name: String,
|
||||
/// Directory the version JSON is detected from.
|
||||
json_source: PathBuf,
|
||||
/// Directory whose game content (mods/saves/config) belongs to the
|
||||
/// instance. For a shared root this is the root itself; for the "move the
|
||||
/// root content into versions/<name>" isolation strategy this is still the
|
||||
/// root, but its content is copied *into* the instance (which then becomes
|
||||
/// the game dir).
|
||||
content_source: Option<PathBuf>,
|
||||
/// Selected `versions/<name>` folder, when the source is a shared root.
|
||||
version_dir: Option<PathBuf>,
|
||||
/// Whether the instance uses version isolation.
|
||||
isolated: bool,
|
||||
}
|
||||
|
||||
/// Reconciles the three import inputs into one layout.
|
||||
///
|
||||
/// Rules:
|
||||
/// - When `instance_path` is given it is the authoritative version folder; the
|
||||
/// instance is version-isolated unless the user explicitly asked to share.
|
||||
/// - When the user asked to share, the `.minecraft` root is the game dir.
|
||||
/// - Without a selected version folder, fall back to the old auto-detection so
|
||||
/// direct folder imports keep working.
|
||||
fn resolve_import_layout(
|
||||
instance_folder: &Path,
|
||||
selected_version: Option<&Path>,
|
||||
game_dir_override: Option<&str>,
|
||||
) -> ImportLayout {
|
||||
let root_name = instance_folder
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "imported".to_string());
|
||||
|
||||
// Explicit "version shared" choice: copy the whole `.minecraft` root.
|
||||
let shared_forced = game_dir_override
|
||||
.map(|dir| {
|
||||
let normalized = dir.trim_end_matches(['/', '\\']);
|
||||
normalized.eq_ignore_ascii_case(
|
||||
instance_folder
|
||||
.to_string_lossy()
|
||||
.trim_end_matches(['/', '\\']),
|
||||
)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(version_dir) = selected_version {
|
||||
let version_name = version_dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "imported".to_string());
|
||||
tracing::debug!(
|
||||
"import_generic: compatible mode - dotminecraft={}, json_path={}",
|
||||
instance_folder.display(),
|
||||
inst_path.display()
|
||||
);
|
||||
(name, instance_folder.to_path_buf(), inst_path.to_path_buf())
|
||||
} else {
|
||||
let (name, dotminecraft) = resolve_dotminecraft(&instance_folder);
|
||||
let json_path = dotminecraft.clone(); // JSON detection will scan dotminecraft
|
||||
(name, dotminecraft, json_path)
|
||||
};
|
||||
.unwrap_or_else(|| root_name.clone());
|
||||
|
||||
let info = detect_instance_info(&json_path, overrides).await?;
|
||||
register_instance(instance_id, &name, &info).await?;
|
||||
copy_instance_files(instance_id, &dotminecraft, reporter, details, symlink)
|
||||
.await
|
||||
if shared_forced {
|
||||
// User explicitly chose to share the `.minecraft` root even though
|
||||
// a version folder was selected.
|
||||
return ImportLayout {
|
||||
name: root_name,
|
||||
json_source: version_dir.to_path_buf(),
|
||||
content_source: Some(instance_folder.to_path_buf()),
|
||||
version_dir: Some(version_dir.to_path_buf()),
|
||||
isolated: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Version-isolated strategy (甲): the instance becomes the game dir.
|
||||
// The version files (`versions/<name>`) and the shared root content
|
||||
// (mods/saves/config) are both merged into the instance, so mods that
|
||||
// live at the `.minecraft` root survive the import instead of being
|
||||
// left behind.
|
||||
return ImportLayout {
|
||||
name: version_name,
|
||||
json_source: version_dir.to_path_buf(),
|
||||
content_source: Some(instance_folder.to_path_buf()),
|
||||
version_dir: Some(version_dir.to_path_buf()),
|
||||
isolated: true,
|
||||
};
|
||||
}
|
||||
|
||||
// No explicit version folder: fall back to auto-detection.
|
||||
let (name, dotminecraft) = resolve_dotminecraft(instance_folder);
|
||||
let game_root = resolve_import_game_root(&dotminecraft);
|
||||
ImportLayout {
|
||||
name,
|
||||
json_source: dotminecraft.clone(),
|
||||
content_source: Some(game_root.clone()),
|
||||
version_dir: None,
|
||||
isolated: game_root != dotminecraft,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stage 1 — resolve the name and the `.minecraft` directory of an imported
|
||||
@ -325,21 +425,30 @@ async fn resolve_loader_version(
|
||||
}
|
||||
|
||||
/// Stage 4 — copy (or symlink) the source files into the instance profile.
|
||||
///
|
||||
/// Uses the reconciled [`ImportLayout`]: the shared content root (mods/saves/
|
||||
/// config) and the selected `versions/<name>` folder are both merged into the
|
||||
/// instance directory, so a version-isolated import keeps root-level content.
|
||||
async fn copy_instance_files(
|
||||
instance_id: &str,
|
||||
dotminecraft: &Path,
|
||||
layout: &ImportLayout,
|
||||
reporter: InstallProgressReporter,
|
||||
details: InstallPhaseDetails,
|
||||
symlink: bool,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
tracing::debug!(
|
||||
"import_generic: finishing import for instance_id={}",
|
||||
instance_id
|
||||
"import_generic: finishing import for instance_id={} content_source={:?} version_dir={:?} isolated={}",
|
||||
instance_id,
|
||||
layout.content_source,
|
||||
layout.version_dir,
|
||||
layout.isolated
|
||||
);
|
||||
finish_import(
|
||||
instance_id,
|
||||
dotminecraft.to_path_buf(),
|
||||
layout.content_source.clone(),
|
||||
layout.version_dir.clone(),
|
||||
layout.isolated,
|
||||
&state.io_semaphore,
|
||||
reporter,
|
||||
details,
|
||||
@ -399,6 +508,7 @@ mod tests {
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
loader: Some(ModLoader::Fabric),
|
||||
loader_version: Some("0.15.11".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let info = detect_instance_info(directory.path(), &overrides)
|
||||
@ -418,6 +528,7 @@ mod tests {
|
||||
game_version: Some("1.20.1".to_string()),
|
||||
loader: Some(ModLoader::Fabric),
|
||||
loader_version: Some(loader_version.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let info = detect_instance_info(directory.path(), &overrides)
|
||||
|
||||
@ -196,7 +196,8 @@ pub(crate) fn normalize_imported_loader_version(
|
||||
game_version: &str,
|
||||
detected_version: &str,
|
||||
) -> String {
|
||||
let detected_version = detected_version.trim();
|
||||
let detected_version = sanitize_loader_version(detected_version);
|
||||
let detected_version = detected_version.as_str();
|
||||
let without_family = match loader {
|
||||
"fabric" | "legacy_fabric" => detected_version
|
||||
.strip_prefix("fabric-loader-")
|
||||
@ -212,7 +213,7 @@ pub(crate) fn normalize_imported_loader_version(
|
||||
}
|
||||
.unwrap_or(detected_version);
|
||||
|
||||
match loader {
|
||||
let normalized = match loader {
|
||||
"fabric" | "legacy_fabric" | "quilt" => without_family
|
||||
.strip_suffix(&format!("-{game_version}"))
|
||||
.unwrap_or(without_family)
|
||||
@ -232,7 +233,8 @@ pub(crate) fn normalize_imported_loader_version(
|
||||
.to_string()
|
||||
}
|
||||
_ => without_family.to_string(),
|
||||
}
|
||||
};
|
||||
sanitize_loader_version(&normalized)
|
||||
}
|
||||
|
||||
fn extract_version(
|
||||
@ -575,6 +577,11 @@ fn detect_adjuncts(
|
||||
|
||||
/// Extracts the loader version string from JSON content by finding a needle
|
||||
/// and reading until a terminator character.
|
||||
///
|
||||
/// The terminator set includes `:` `]` `[` and whitespace because non-standard
|
||||
/// launcher JSONs (notably PCL) may embed the loader coordinate in a composite
|
||||
/// string such as `net.neoforged:neoforge:21.1.250:client]`, where the real
|
||||
/// version ends at the first extra `:` rather than at the closing quote.
|
||||
fn try_extract_version_from_needle(
|
||||
content: &str,
|
||||
needle: &str,
|
||||
@ -582,17 +589,32 @@ fn try_extract_version_from_needle(
|
||||
) -> Option<String> {
|
||||
let pos = content.find(needle)?;
|
||||
let after = &content[pos + needle.len()..];
|
||||
let end = after.find(&['"', ',', '\n', '}'] as &[char])?;
|
||||
let end = after
|
||||
.find(&['"', ',', '\n', '}', ']', '[', ':', ' '] as &[char])?;
|
||||
let ver = &after[..end];
|
||||
if let Some(ch) = split_at
|
||||
&& let Some(pos) = ver.rfind(ch)
|
||||
{
|
||||
Some(ver[pos + 1..].to_string())
|
||||
Some(sanitize_loader_version(&ver[pos + 1..]))
|
||||
} else {
|
||||
Some(ver.to_string())
|
||||
Some(sanitize_loader_version(ver))
|
||||
}
|
||||
}
|
||||
|
||||
/// Trims junk that non-standard launcher JSONs append to a loader coordinate
|
||||
/// (e.g. `21.1.250:client]`, `44.0.3 ` or `0.15.11\n`). Keeps only the leading
|
||||
/// version token so the metadata resolver receives a clean id.
|
||||
fn sanitize_loader_version(raw: &str) -> String {
|
||||
let trimmed = raw.trim();
|
||||
// Cut at the first character that cannot appear in a loader version id.
|
||||
let end = trimmed
|
||||
.find(|ch: char| {
|
||||
!(ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' || ch == '+')
|
||||
})
|
||||
.unwrap_or(trimmed.len());
|
||||
trimmed[..end].trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -837,4 +859,35 @@ mod tests {
|
||||
assert_eq!(info.loader.as_deref(), Some("fabric"));
|
||||
assert_eq!(info.loader_version.as_deref(), Some("0.15.11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_loader_version_with_launcher_suffix() {
|
||||
// PCL and other launchers may embed composite coordinates such as
|
||||
// `net.neoforged:neoforge:21.1.250:client]`; the extracted version must
|
||||
// stop at the first extra `:` instead of swallowing `:client]`.
|
||||
assert_eq!(sanitize_loader_version("21.1.250:client]"), "21.1.250");
|
||||
assert_eq!(sanitize_loader_version("44.0.3 "), "44.0.3");
|
||||
assert_eq!(sanitize_loader_version("0.15.11\n"), "0.15.11");
|
||||
assert_eq!(sanitize_loader_version("1.21.1-52.0.0"), "1.21.1-52.0.0");
|
||||
assert_eq!(
|
||||
sanitize_loader_version("1.7.10-10.13.4.1614-1.7.10"),
|
||||
"1.7.10-10.13.4.1614-1.7.10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_loader_version_stops_at_extra_colon() {
|
||||
assert_loader(
|
||||
r#"{
|
||||
"id": "1.21.1-neoforge-21.1.250",
|
||||
"libraries": [
|
||||
{
|
||||
"name": "net.neoforged:neoforge:21.1.250:client]"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
"neoforge",
|
||||
Some("21.1.250"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -303,7 +303,9 @@ async fn import_mmc_unmanaged(
|
||||
let state = State::get().await?;
|
||||
finish_import(
|
||||
instance_id,
|
||||
minecraft_folder,
|
||||
Some(minecraft_folder),
|
||||
None,
|
||||
false,
|
||||
&state.io_semaphore,
|
||||
reporter,
|
||||
details,
|
||||
|
||||
@ -661,6 +661,10 @@ 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(
|
||||
@ -1045,7 +1049,9 @@ pub async fn recache_icon(
|
||||
|
||||
pub(crate) async fn copy_dotminecraft_with_reporter(
|
||||
instance_id: &str,
|
||||
dotminecraft: PathBuf,
|
||||
content_source: Option<PathBuf>,
|
||||
version_dir: Option<PathBuf>,
|
||||
isolated: bool,
|
||||
io_semaphore: &IoSemaphore,
|
||||
reporter: InstallProgressReporter,
|
||||
details: InstallPhaseDetails,
|
||||
@ -1053,7 +1059,36 @@ pub(crate) async fn copy_dotminecraft_with_reporter(
|
||||
let instance_path =
|
||||
crate::api::instance::get_full_path(instance_id).await?;
|
||||
|
||||
let files = collect_dotminecraft_files(&dotminecraft).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 {
|
||||
@ -1085,6 +1120,8 @@ pub(crate) async fn copy_dotminecraft_with_reporter(
|
||||
/// 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?;
|
||||
@ -1098,6 +1135,14 @@ async fn collect_dotminecraft_files(
|
||||
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)
|
||||
@ -1117,6 +1162,23 @@ async fn collect_dotminecraft_files(
|
||||
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())
|
||||
@ -1132,6 +1194,70 @@ async fn collect_dotminecraft_files(
|
||||
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) {
|
||||
collected.push((abs_path, rel.to_path_buf()));
|
||||
}
|
||||
}
|
||||
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(
|
||||
@ -1225,7 +1351,7 @@ async fn copy_files_with_progress(
|
||||
/// 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.
|
||||
fn resolve_import_game_root(source: &Path) -> PathBuf {
|
||||
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).
|
||||
@ -1300,19 +1426,27 @@ fn dir_has_game_content(root: &Path) -> bool {
|
||||
|
||||
pub(crate) async fn finish_import(
|
||||
instance_id: &str,
|
||||
dotminecraft: PathBuf,
|
||||
content_source: Option<PathBuf>,
|
||||
version_dir: Option<PathBuf>,
|
||||
isolated: bool,
|
||||
io_semaphore: &IoSemaphore,
|
||||
reporter: InstallProgressReporter,
|
||||
details: InstallPhaseDetails,
|
||||
symlink: bool,
|
||||
) -> crate::Result<()> {
|
||||
let local_source = LocalRuntimeSource::discover(&dotminecraft);
|
||||
// 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);
|
||||
|
||||
// Respect an explicitly chosen game-dir override (the user's isolated /
|
||||
// not-isolated selection, already stored on the instance row at creation).
|
||||
// Only fall back to auto-detection for symlink imports that did not carry
|
||||
// an explicit override, so copy imports always stay built-in (no override)
|
||||
// and the frontend's choice is never clobbered.
|
||||
let state = crate::state::State::get().await?;
|
||||
let pool = &state.pool;
|
||||
let existing_override =
|
||||
@ -1324,17 +1458,15 @@ pub(crate) async fn finish_import(
|
||||
.map(|(_, override_dir)| override_dir)
|
||||
.unwrap_or(None);
|
||||
if existing_override.is_none() && symlink {
|
||||
// For a non-version-isolated import the game content (mods, saves, config)
|
||||
// lives in the `.minecraft` root, not in the detected `versions/<name>`
|
||||
// subfolder. Detect that and record the override so the instance uses the
|
||||
// real game root directly instead of an empty version subfolder.
|
||||
let game_root = resolve_import_game_root(&dotminecraft);
|
||||
if game_root != dotminecraft {
|
||||
// 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(
|
||||
game_root.to_string_lossy().to_string(),
|
||||
content_root.to_string_lossy().to_string(),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
@ -1345,6 +1477,11 @@ pub(crate) async fn finish_import(
|
||||
}
|
||||
|
||||
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)
|
||||
@ -1354,7 +1491,7 @@ pub(crate) async fn finish_import(
|
||||
})?;
|
||||
// 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 .minecraft root).
|
||||
// override (which points at the external source root).
|
||||
let instance_path =
|
||||
state.directories.instances_dir().join(&relative_path);
|
||||
|
||||
@ -1402,7 +1539,7 @@ pub(crate) async fn finish_import(
|
||||
return Err(error.into());
|
||||
}
|
||||
if let Err(error) =
|
||||
io::create_symlink(&dotminecraft, &instance_path).await
|
||||
io::create_symlink(&source_root, &instance_path).await
|
||||
{
|
||||
let _ = io::rename_or_move(&backup_path, &instance_path).await;
|
||||
watch_instance_folder(
|
||||
@ -1423,14 +1560,14 @@ pub(crate) async fn finish_import(
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
io::create_symlink(&dotminecraft, &instance_path).await?;
|
||||
io::create_symlink(&source_root, &instance_path).await?;
|
||||
}
|
||||
|
||||
crate::state::edit_instance(
|
||||
instance_id,
|
||||
crate::state::EditInstance {
|
||||
symlink_target: Some(Some(
|
||||
dotminecraft.to_string_lossy().to_string(),
|
||||
source_root.to_string_lossy().to_string(),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
@ -1440,7 +1577,9 @@ pub(crate) async fn finish_import(
|
||||
} else {
|
||||
copy_dotminecraft_with_reporter(
|
||||
instance_id,
|
||||
dotminecraft,
|
||||
content_source,
|
||||
version_dir,
|
||||
isolated,
|
||||
io_semaphore,
|
||||
reporter.clone(),
|
||||
details,
|
||||
|
||||
@ -210,7 +210,9 @@ pub async fn import_instance(
|
||||
let state = State::get().await?;
|
||||
finish_import(
|
||||
instance_id,
|
||||
source,
|
||||
Some(source),
|
||||
None,
|
||||
false,
|
||||
&state.io_semaphore,
|
||||
reporter,
|
||||
details,
|
||||
|
||||
@ -1529,10 +1529,10 @@ async fn run_request(
|
||||
game_version,
|
||||
loader,
|
||||
loader_version,
|
||||
game_dir_override: _,
|
||||
game_dir_override,
|
||||
} => {
|
||||
tracing::debug!(
|
||||
"InstallRequest::ImportInstance: launcher_type={launcher_type} base_path={} instance_folder={instance_folder} symlink={symlink}",
|
||||
"InstallRequest::ImportInstance: launcher_type={launcher_type} base_path={} instance_folder={instance_folder} symlink={symlink} game_dir_override={game_dir_override:?}",
|
||||
base_path.display()
|
||||
);
|
||||
let Some(instance_id) = current_instance_id(job_state) else {
|
||||
@ -1562,6 +1562,7 @@ async fn run_request(
|
||||
game_version,
|
||||
loader,
|
||||
loader_version,
|
||||
game_dir_override,
|
||||
},
|
||||
// TODO(B2): apply overrides to launcher-specific importers
|
||||
// (MultiMC/Prism/ATLauncher/GDLauncher/Curseforge/ModrinthApp);
|
||||
@ -1590,8 +1591,12 @@ async fn run_request(
|
||||
let state = State::get().await?;
|
||||
crate::api::pack::import::copy_dotminecraft_with_reporter(
|
||||
&instance_id,
|
||||
crate::api::instance::get_full_path(&source_instance_id)
|
||||
.await?,
|
||||
Some(
|
||||
crate::api::instance::get_full_path(&source_instance_id)
|
||||
.await?,
|
||||
),
|
||||
None,
|
||||
false,
|
||||
&state.io_semaphore,
|
||||
InstallProgressReporter::new(job_id, job_state.clone()),
|
||||
InstallPhaseDetails::Empty,
|
||||
@ -2526,7 +2531,9 @@ async fn copy_physical_instance_contents(
|
||||
)?;
|
||||
crate::api::pack::import::copy_dotminecraft_with_reporter(
|
||||
target_instance_id,
|
||||
source_path,
|
||||
Some(source_path),
|
||||
None,
|
||||
false,
|
||||
&state.io_semaphore,
|
||||
InstallProgressReporter::new(job_id, job_state.clone()),
|
||||
InstallPhaseDetails::Empty,
|
||||
|
||||
Reference in New Issue
Block a user