修复从 .minecraft / PCL / HMCL 等启动器导入实例时的 6 个问题: - 复制模式不再克隆 versions/ 下的所有实例,只保留选中的版本 - 版本隔离导入时把 .minecraft 根内容(mods/saves/config)与选中 versions/<name> 合并进实例目录,不再得到纯原版实例 - 符号链接导入不再因 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 的调用点。
443 lines
14 KiB
Rust
443 lines
14 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize, de};
|
|
|
|
use crate::{
|
|
State,
|
|
install::{InstallPhaseDetails, InstallProgressReporter},
|
|
pack::{
|
|
import::{self, finish_import},
|
|
install_from::{self, CreatePackDescription, PackDependency},
|
|
},
|
|
util::io,
|
|
};
|
|
|
|
// instance.cfg
|
|
// https://github.com/PrismLauncher/PrismLauncher/blob/develop/launcher/minecraft/MinecraftInstance.cpp
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
#[serde(untagged)]
|
|
enum MMCInstanceEnum {
|
|
General(MMCInstanceGeneral),
|
|
Instance(MMCInstance),
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct MMCInstanceGeneral {
|
|
pub general: MMCInstance,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
pub struct MMCInstance {
|
|
pub java_path: Option<String>,
|
|
pub jvm_args: Option<String>,
|
|
|
|
#[serde(default)]
|
|
#[serde(deserialize_with = "deserialize_optional_bool")]
|
|
pub managed_pack: Option<bool>,
|
|
|
|
#[serde(rename = "ManagedPackID")]
|
|
pub managed_pack_id: Option<String>,
|
|
pub managed_pack_type: Option<MMCManagedPackType>,
|
|
#[serde(rename = "ManagedPackVersionID")]
|
|
pub managed_pack_version_id: Option<String>,
|
|
pub managed_pack_version_name: Option<String>,
|
|
|
|
#[serde(rename = "iconKey")]
|
|
pub icon_key: Option<String>,
|
|
#[serde(rename = "name")]
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
// serde_ini reads 'true' and 'false' as strings, so we need to convert them to booleans
|
|
fn deserialize_optional_bool<'de, D>(
|
|
deserializer: D,
|
|
) -> Result<Option<bool>, D::Error>
|
|
where
|
|
D: de::Deserializer<'de>,
|
|
{
|
|
let s = Option::<String>::deserialize(deserializer)?;
|
|
match s {
|
|
Some(string) => match string.as_str() {
|
|
"true" => Ok(Some(true)),
|
|
"false" => Ok(Some(false)),
|
|
_ => Err(de::Error::custom("expected 'true' or 'false'")),
|
|
},
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum MMCManagedPackType {
|
|
Modrinth,
|
|
Flame,
|
|
ATLauncher,
|
|
#[serde(other)]
|
|
Unknown,
|
|
}
|
|
|
|
// mmc-pack.json
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MMCPack {
|
|
components: Vec<MMCComponent>,
|
|
format_version: u32,
|
|
}
|
|
|
|
// https://github.com/PrismLauncher/PrismLauncher/blob/develop/launcher/minecraft/Component.h
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MMCComponent {
|
|
pub uid: String,
|
|
|
|
#[serde(default)]
|
|
pub version: Option<String>,
|
|
#[serde(default)]
|
|
pub dependency_only: bool,
|
|
|
|
#[serde(default)]
|
|
pub important: bool,
|
|
#[serde(default)]
|
|
pub disabled: bool,
|
|
|
|
pub cached_name: Option<String>,
|
|
pub cached_version: Option<String>,
|
|
|
|
#[serde(default)]
|
|
pub cached_requires: Vec<MMCComponentRequirement>,
|
|
#[serde(default)]
|
|
pub cached_conflicts: Vec<MMCComponentRequirement>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct MMCComponentRequirement {
|
|
pub uid: String,
|
|
pub equals_version: Option<String>,
|
|
pub suggests: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
#[serde(untagged)]
|
|
enum MMCLauncherEnum {
|
|
General(MMCLauncherGeneral),
|
|
Instance(MMCLauncher),
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct MMCLauncherGeneral {
|
|
pub general: MMCLauncher,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
pub struct MMCLauncher {
|
|
instance_dir: String,
|
|
}
|
|
|
|
// Checks if if its a folder, and the folder contains instance.cfg and mmc-pack.json, and they both parse
|
|
#[tracing::instrument]
|
|
pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool {
|
|
let instance_cfg = instance_folder.join("instance.cfg");
|
|
let mmc_pack = instance_folder.join("mmc-pack.json");
|
|
|
|
let Ok((mmc_pack, _)) = io::read_any_encoding_to_string(&mmc_pack).await
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
load_instance_cfg(&instance_cfg).await.is_ok()
|
|
&& serde_json::from_str::<MMCPack>(&mmc_pack).is_ok()
|
|
}
|
|
|
|
#[tracing::instrument]
|
|
pub async fn get_instances_subpath(config: PathBuf) -> Option<String> {
|
|
let launcher = io::read_any_encoding_to_string(&config).await.ok()?.0;
|
|
let launcher: MMCLauncherEnum = serde_ini::from_str(&launcher).ok()?;
|
|
match launcher {
|
|
MMCLauncherEnum::General(p) => Some(p.general.instance_dir),
|
|
MMCLauncherEnum::Instance(p) => Some(p.instance_dir),
|
|
}
|
|
}
|
|
|
|
// Loading the INI (instance.cfg) file
|
|
async fn load_instance_cfg(file_path: &Path) -> crate::Result<MMCInstance> {
|
|
match serde_ini::from_str::<MMCInstanceEnum>(
|
|
&io::read_any_encoding_to_string(file_path).await?.0,
|
|
)? {
|
|
MMCInstanceEnum::General(instance_cfg) => Ok(instance_cfg.general),
|
|
MMCInstanceEnum::Instance(instance_cfg) => Ok(instance_cfg),
|
|
}
|
|
}
|
|
|
|
// #[tracing::instrument]
|
|
pub(crate) async fn import_mmc_instance_dir(
|
|
mmc_instance_path: PathBuf,
|
|
icons_dir: Option<PathBuf>,
|
|
instance_id: &str,
|
|
reporter: InstallProgressReporter,
|
|
details: InstallPhaseDetails,
|
|
symlink: bool,
|
|
) -> crate::Result<()> {
|
|
let mmc_pack = serde_json::from_str::<MMCPack>(
|
|
&io::read_any_encoding_to_string(
|
|
&mmc_instance_path.join("mmc-pack.json"),
|
|
)
|
|
.await?
|
|
.0,
|
|
)?;
|
|
|
|
let instance_cfg =
|
|
load_instance_cfg(&mmc_instance_path.join("instance.cfg")).await?;
|
|
|
|
// Re-cache icon
|
|
let icon = if let Some(icon_key) = instance_cfg.icon_key {
|
|
let mut icon = None;
|
|
for icon_dir in
|
|
icons_dir.iter().chain(std::iter::once(&mmc_instance_path))
|
|
{
|
|
let icon_path = icon_dir.join(&icon_key);
|
|
icon = import::recache_icon(icon_path).await?;
|
|
if icon.is_none() {
|
|
let icon_path = icon_dir.join(format!("{icon_key}.png"));
|
|
icon = import::recache_icon(icon_path).await?;
|
|
}
|
|
if icon.is_some() {
|
|
break;
|
|
}
|
|
}
|
|
icon
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Create description from instance.cfg
|
|
let mut description = CreatePackDescription {
|
|
icon,
|
|
override_title: instance_cfg.name,
|
|
project_id: None,
|
|
version_id: None,
|
|
instance_id: instance_id.to_string(),
|
|
source_filename: None,
|
|
};
|
|
|
|
let mut minecraft_folder = mmc_instance_path.join("minecraft");
|
|
if !minecraft_folder.is_dir() {
|
|
minecraft_folder = mmc_instance_path.join(".minecraft");
|
|
if !minecraft_folder.is_dir() {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Instance is missing Minecraft directory".to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
}
|
|
|
|
// Managed pack
|
|
if instance_cfg.managed_pack.unwrap_or(false) {
|
|
match instance_cfg.managed_pack_type {
|
|
Some(MMCManagedPackType::Modrinth) => {
|
|
description.project_id = instance_cfg.managed_pack_id;
|
|
description.version_id = instance_cfg.managed_pack_version_id;
|
|
|
|
// Modrinth Managed Pack
|
|
// Kept separate as we may in the future want to add special handling for modrinth managed packs
|
|
import_mmc_unmanaged(instance_id, minecraft_folder, "Imported Modrinth Modpack".to_string(), description, mmc_pack, reporter, details, symlink).await?;
|
|
}
|
|
Some(MMCManagedPackType::Flame | MMCManagedPackType::ATLauncher) => {
|
|
// For flame/atlauncher managed packs
|
|
// Treat as unmanaged, but with 'minecraft' folder instead of '.minecraft'
|
|
import_mmc_unmanaged(instance_id, minecraft_folder, "Imported Modpack".to_string(), description, mmc_pack, reporter, details, symlink).await?;
|
|
},
|
|
Some(_) => {
|
|
// For managed packs that aren't modrinth, flame, atlauncher
|
|
// Treat as unmanaged
|
|
import_mmc_unmanaged(instance_id, minecraft_folder, "ImportedModpack".to_string(), description, mmc_pack, reporter, details, symlink).await?;
|
|
},
|
|
_ => return Err(crate::ErrorKind::InputError("Instance is managed, but managed pack type not specified in instance.cfg".to_string()).into())
|
|
}
|
|
} else {
|
|
// Directly import unmanaged pack
|
|
import_mmc_unmanaged(
|
|
instance_id,
|
|
minecraft_folder,
|
|
"Imported Modpack".to_string(),
|
|
description,
|
|
mmc_pack,
|
|
reporter,
|
|
details,
|
|
symlink,
|
|
)
|
|
.await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn import_mmc_unmanaged(
|
|
instance_id: &str,
|
|
minecraft_folder: PathBuf,
|
|
backup_name: String,
|
|
description: CreatePackDescription,
|
|
mmc_pack: MMCPack,
|
|
reporter: InstallProgressReporter,
|
|
details: InstallPhaseDetails,
|
|
symlink: bool,
|
|
) -> crate::Result<()> {
|
|
let dependencies = mmc_dependencies(&mmc_pack)?;
|
|
|
|
install_from::set_instance_information(
|
|
instance_id.to_string(),
|
|
&description,
|
|
&backup_name,
|
|
None,
|
|
&dependencies,
|
|
false,
|
|
)
|
|
.await?;
|
|
|
|
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
|
|
let state = State::get().await?;
|
|
finish_import(
|
|
instance_id,
|
|
Some(minecraft_folder),
|
|
None,
|
|
false,
|
|
&state.io_semaphore,
|
|
reporter,
|
|
details,
|
|
symlink,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn mmc_dependencies(
|
|
mmc_pack: &MMCPack,
|
|
) -> crate::Result<std::collections::HashMap<PackDependency, String>> {
|
|
let mut dependencies = std::collections::HashMap::new();
|
|
let has_legacy_fabric = mmc_pack.components.iter().any(|component| {
|
|
component
|
|
.uid
|
|
.to_ascii_lowercase()
|
|
.starts_with("net.legacyfabric")
|
|
});
|
|
let has_cleanroom = mmc_pack.components.iter().any(|component| {
|
|
component
|
|
.uid
|
|
.to_ascii_lowercase()
|
|
.starts_with("com.cleanroommc")
|
|
});
|
|
for component in &mmc_pack.components {
|
|
let uid = component.uid.to_ascii_lowercase();
|
|
if uid.contains("labymod") {
|
|
return Err(crate::ErrorKind::InputError(
|
|
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
|
|
.to_string(),
|
|
)
|
|
.into());
|
|
}
|
|
let dependency = if uid.starts_with("net.fabricmc.fabric-loader") {
|
|
Some(if has_legacy_fabric {
|
|
PackDependency::LegacyFabric
|
|
} else {
|
|
PackDependency::FabricLoader
|
|
})
|
|
} else if uid.starts_with("net.legacyfabric") {
|
|
Some(PackDependency::LegacyFabric)
|
|
} else if uid.starts_with("net.minecraftforge") {
|
|
(!has_cleanroom).then_some(PackDependency::Forge)
|
|
} else if uid.starts_with("net.neoforged") {
|
|
Some(PackDependency::NeoForge)
|
|
} else if uid.starts_with("org.quiltmc.quilt-loader") {
|
|
Some(PackDependency::QuiltLoader)
|
|
} else if uid.starts_with("com.cleanroommc") {
|
|
Some(PackDependency::Cleanroom)
|
|
} else if uid.contains("liteloader") {
|
|
Some(PackDependency::LiteLoader)
|
|
} else if uid.contains("optifabric") {
|
|
Some(PackDependency::OptiFabric)
|
|
} else if uid.contains("optifine") {
|
|
Some(PackDependency::OptiFine)
|
|
} else if uid.starts_with("net.minecraft") {
|
|
Some(PackDependency::Minecraft)
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(dependency) = dependency {
|
|
let version = component
|
|
.version
|
|
.clone()
|
|
.filter(|v| !v.is_empty())
|
|
.ok_or_else(|| {
|
|
crate::ErrorKind::InputError(format!(
|
|
"MultiMC component {} is missing its version",
|
|
component.uid
|
|
))
|
|
})?;
|
|
dependencies.insert(dependency, version);
|
|
}
|
|
}
|
|
Ok(dependencies)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn component(uid: &str, version: &str) -> MMCComponent {
|
|
MMCComponent {
|
|
uid: uid.to_string(),
|
|
version: Some(version.to_string()),
|
|
dependency_only: false,
|
|
important: true,
|
|
disabled: false,
|
|
cached_name: None,
|
|
cached_version: None,
|
|
cached_requires: Vec::new(),
|
|
cached_conflicts: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dependencies_preserve_legacy_fabric_and_optifine_components() {
|
|
let pack = MMCPack {
|
|
format_version: 1,
|
|
components: vec![
|
|
component("net.minecraft", "1.8.9"),
|
|
component("net.legacyfabric.intermediary", "1.8.9"),
|
|
component("net.fabricmc.fabric-loader", "0.13.1.4"),
|
|
component("optifine.OptiFine", "1.8.9_HD_U_M6_pre2"),
|
|
component("optifabric.OptiFabric", "1.13.16"),
|
|
],
|
|
};
|
|
let dependencies = mmc_dependencies(&pack).unwrap();
|
|
|
|
assert_eq!(
|
|
dependencies.get(&PackDependency::LegacyFabric),
|
|
Some(&"0.13.1.4".to_string())
|
|
);
|
|
assert_eq!(
|
|
dependencies.get(&PackDependency::OptiFine),
|
|
Some(&"1.8.9_HD_U_M6_pre2".to_string())
|
|
);
|
|
assert_eq!(
|
|
dependencies.get(&PackDependency::OptiFabric),
|
|
Some(&"1.13.16".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dependencies_reject_labymod_components() {
|
|
let pack = MMCPack {
|
|
format_version: 1,
|
|
components: vec![component("net.labymod.LabyMod", "4.4.20")],
|
|
};
|
|
|
|
assert!(mmc_dependencies(&pack).is_err());
|
|
}
|
|
}
|