From 984f3ddbd6855dcb5bc51e04f97e2ff646de29c1 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sat, 19 Sep 2026 18:49:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=8E=20.minecraft?= =?UTF-8?q?=20=E5=AF=BC=E5=85=A5=E5=AE=9E=E4=BE=8B=E7=9A=84=E5=A4=9A?= =?UTF-8?q?=E9=A1=B9=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复从 .minecraft / PCL / HMCL 等启动器导入实例时的 6 个问题: - 复制模式不再克隆 versions/ 下的所有实例,只保留选中的版本 - 版本隔离导入时把 .minecraft 根内容(mods/saves/config)与选中 versions/ 合并进实例目录,不再得到纯原版实例 - 符号链接导入不再因 game_dir_override 被丢弃而在错误目录创建 游戏数据,实例也不会在失败后被误删 - 不再将用户选择的版本隔离模式强行改为共享(InstallRequest:: ImportInstance 的 game_dir_override 现在正确透传到导入逻辑) - 清洗非标准加载器坐标(如 net.neoforged:neoforge:21.1.250:client] 中的 :client] 后缀),并补充回归测试 涉及 api/pack/import 下的 generic/mod/instance_json 及各启动器 导入器,以及 install/runner 的调用点。 --- .../app-lib/src/api/pack/import/atlauncher.rs | 4 +- .../app-lib/src/api/pack/import/axolotl.rs | 4 +- .../app-lib/src/api/pack/import/curseforge.rs | 4 +- .../app-lib/src/api/pack/import/gdlauncher.rs | 4 +- .../app-lib/src/api/pack/import/generic.rs | 161 ++++++++++++--- .../src/api/pack/import/instance_json.rs | 65 ++++++- packages/app-lib/src/api/pack/import/mmc.rs | 4 +- packages/app-lib/src/api/pack/import/mod.rs | 183 +++++++++++++++--- .../src/api/pack/import/modrinth_app.rs | 4 +- packages/app-lib/src/install/runner.rs | 17 +- 10 files changed, 386 insertions(+), 64 deletions(-) diff --git a/packages/app-lib/src/api/pack/import/atlauncher.rs b/packages/app-lib/src/api/pack/import/atlauncher.rs index f5664d9..0ba3b5d 100644 --- a/packages/app-lib/src/api/pack/import/atlauncher.rs +++ b/packages/app-lib/src/api/pack/import/atlauncher.rs @@ -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, diff --git a/packages/app-lib/src/api/pack/import/axolotl.rs b/packages/app-lib/src/api/pack/import/axolotl.rs index a9ddb6f..c6f0f7e 100644 --- a/packages/app-lib/src/api/pack/import/axolotl.rs +++ b/packages/app-lib/src/api/pack/import/axolotl.rs @@ -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, diff --git a/packages/app-lib/src/api/pack/import/curseforge.rs b/packages/app-lib/src/api/pack/import/curseforge.rs index 9df8a73..6d742d4 100644 --- a/packages/app-lib/src/api/pack/import/curseforge.rs +++ b/packages/app-lib/src/api/pack/import/curseforge.rs @@ -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, diff --git a/packages/app-lib/src/api/pack/import/gdlauncher.rs b/packages/app-lib/src/api/pack/import/gdlauncher.rs index 67efa59..8480fca 100644 --- a/packages/app-lib/src/api/pack/import/gdlauncher.rs +++ b/packages/app-lib/src/api/pack/import/gdlauncher.rs @@ -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, diff --git a/packages/app-lib/src/api/pack/import/generic.rs b/packages/app-lib/src/api/pack/import/generic.rs index 4e6e259..d5e2602 100644 --- a/packages/app-lib/src/api/pack/import/generic.rs +++ b/packages/app-lib/src/api/pack/import/generic.rs @@ -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, // For compatible mode: path to versions// ) -> 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/` 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/) 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/` +/// 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/" isolation strategy this is still the + /// root, but its content is copied *into* the instance (which then becomes + /// the game dir). + content_source: Option, + /// Selected `versions/` folder, when the source is a shared root. + version_dir: Option, + /// 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/`) 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/` 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) diff --git a/packages/app-lib/src/api/pack/import/instance_json.rs b/packages/app-lib/src/api/pack/import/instance_json.rs index eee442d..6e334a8 100644 --- a/packages/app-lib/src/api/pack/import/instance_json.rs +++ b/packages/app-lib/src/api/pack/import/instance_json.rs @@ -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 { 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"), + ); + } } diff --git a/packages/app-lib/src/api/pack/import/mmc.rs b/packages/app-lib/src/api/pack/import/mmc.rs index 459fa89..37bd50c 100644 --- a/packages/app-lib/src/api/pack/import/mmc.rs +++ b/packages/app-lib/src/api/pack/import/mmc.rs @@ -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, diff --git a/packages/app-lib/src/api/pack/import/mod.rs b/packages/app-lib/src/api/pack/import/mod.rs index 2734126..2a3311e 100644 --- a/packages/app-lib/src/api/pack/import/mod.rs +++ b/packages/app-lib/src/api/pack/import/mod.rs @@ -661,6 +661,10 @@ pub(crate) struct ImportOverrides { pub game_version: Option, pub loader: Option, pub loader_version: Option, + /// 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, } 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, + version_dir: Option, + 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 (`.json`, `.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 (`.json` and `.jar`). async fn collect_dotminecraft_files( dotminecraft: &Path, + keep_version: Option<&Path>, + isolated: bool, ) -> crate::Result> { // 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/` 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> { + 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/` 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//`) 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/` 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, + version_dir: Option, + 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/` - // 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/. 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, diff --git a/packages/app-lib/src/api/pack/import/modrinth_app.rs b/packages/app-lib/src/api/pack/import/modrinth_app.rs index 1e614d8..f82f4c9 100644 --- a/packages/app-lib/src/api/pack/import/modrinth_app.rs +++ b/packages/app-lib/src/api/pack/import/modrinth_app.rs @@ -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, diff --git a/packages/app-lib/src/install/runner.rs b/packages/app-lib/src/install/runner.rs index d88a09c..1e6b7d5 100644 --- a/packages/app-lib/src/install/runner.rs +++ b/packages/app-lib/src/install/runner.rs @@ -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,