feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,293 @@
//! Shared helpers for extracting content from local modpack archives.
use std::path::{Component, Path, PathBuf};
use super::detect::decode_zip_entry_name;
use crate::util::io;
use tokio_util::sync::CancellationToken;
const EXTRACTION_SIZE_LIMIT: u64 = 8 * 1024 * 1024 * 1024;
fn archive_error(error: zip::result::ZipError) -> crate::Error {
crate::ErrorKind::InputError(format!("Modpack archive is invalid: {error}"))
.into()
}
pub(crate) fn safe_relative_path(value: &str) -> crate::Result<String> {
let path = Path::new(value);
if value.is_empty()
|| path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(crate::ErrorKind::InputError(
"Modpack archive contains an invalid file path".to_string(),
)
.into());
}
Ok(path.to_string_lossy().replace('\\', "/"))
}
/// Extracts every file under `prefix` in the archive into `target_dir`,
/// preserving the directory structure below the prefix. Returns the number of
/// files written.
pub(crate) async fn extract_archive_subdir(
archive_path: PathBuf,
prefix: String,
target_dir: PathBuf,
) -> crate::Result<u32> {
tokio::task::spawn_blocking(move || {
extract_archive_subdir_sync(&archive_path, &prefix, &target_dir, None)
})
.await?
}
pub(crate) async fn extract_archive_subdir_for_instance(
instance_id: String,
cancellation: CancellationToken,
archive_path: PathBuf,
prefix: String,
target_dir: PathBuf,
) -> crate::Result<u32> {
run_blocking_instance_write(
instance_id,
cancellation,
move |cancellation| {
extract_archive_subdir_sync(
&archive_path,
&prefix,
&target_dir,
Some(cancellation),
)
},
)
.await
}
pub(crate) async fn run_blocking_instance_write<T, F>(
instance_id: String,
cancellation: CancellationToken,
operation: F,
) -> crate::Result<T>
where
T: Send + 'static,
F: FnOnce(&CancellationToken) -> crate::Result<T> + Send + 'static,
{
let state = crate::State::get().await?;
let instance_lock =
state.lock_instance_content_exclusive(&instance_id).await;
tokio::task::spawn_blocking(move || {
let _instance_lock = instance_lock;
operation(&cancellation)
})
.await?
}
fn extract_archive_subdir_sync(
archive_path: &Path,
prefix: &str,
target_dir: &Path,
cancellation: Option<&CancellationToken>,
) -> crate::Result<u32> {
let file = std::fs::File::open(archive_path)
.map_err(|error| io::IOError::with_path(error, archive_path))?;
let mut archive = zip::ZipArchive::new(file).map_err(archive_error)?;
let mut files_written = 0_u32;
let mut total_size = 0_u64;
for index in 0..archive.len() {
check_cancellation(cancellation)?;
let mut entry = archive.by_index(index).map_err(archive_error)?;
let entry_name = decode_zip_entry_name(entry.name_raw());
if entry.is_dir() || !entry_name.starts_with(prefix) {
continue;
}
let relative = &entry_name[prefix.len()..];
if relative.is_empty() {
continue;
}
total_size = total_size.saturating_add(entry.size());
if total_size > EXTRACTION_SIZE_LIMIT {
return Err(crate::ErrorKind::InputError(
"Modpack archive contents exceed the extraction limit"
.to_string(),
)
.into());
}
let target = target_dir.join(safe_relative_path(relative)?);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| io::IOError::with_path(error, parent))?;
}
let mut output = std::fs::File::create(&target)
.map_err(|error| io::IOError::with_path(error, &target))?;
copy_with_cancellation(&mut entry, &mut output, cancellation, &target)?;
files_written = files_written.saturating_add(1);
}
Ok(files_written)
}
pub(crate) fn check_cancellation(
cancellation: Option<&CancellationToken>,
) -> crate::Result<()> {
if cancellation.is_some_and(CancellationToken::is_cancelled) {
return Err(crate::ErrorKind::OtherError(
"Install was canceled".to_string(),
)
.into());
}
Ok(())
}
pub(crate) fn copy_with_cancellation<R, W>(
reader: &mut R,
writer: &mut W,
cancellation: Option<&CancellationToken>,
target: &Path,
) -> crate::Result<u64>
where
R: std::io::Read,
W: std::io::Write,
{
let mut buffer = [0_u8; 64 * 1024];
let mut written = 0_u64;
loop {
check_cancellation(cancellation)?;
let count = reader
.read(&mut buffer)
.map_err(|error| io::IOError::with_path(error, target))?;
if count == 0 {
return Ok(written);
}
check_cancellation(cancellation)?;
writer
.write_all(&buffer[..count])
.map_err(|error| io::IOError::with_path(error, target))?;
written = written.saturating_add(count as u64);
}
}
/// Extracts a single archive entry to the given target file path.
pub(crate) async fn extract_archive_entry_to_file(
archive_path: PathBuf,
entry_name: String,
target: PathBuf,
) -> crate::Result<()> {
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&archive_path)
.map_err(|error| io::IOError::with_path(error, &archive_path))?;
let mut archive = zip::ZipArchive::new(file).map_err(archive_error)?;
let index = (0..archive.len())
.find(|&index| {
archive
.by_index_raw(index)
.map(|entry| {
decode_zip_entry_name(entry.name_raw()) == entry_name
})
.unwrap_or(false)
})
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Modpack archive is missing {entry_name}"
))
})?;
let mut entry = archive.by_index(index).map_err(archive_error)?;
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| io::IOError::with_path(error, parent))?;
}
let mut output = std::fs::File::create(&target)
.map_err(|error| io::IOError::with_path(error, &target))?;
std::io::copy(&mut entry, &mut output)
.map_err(|error| io::IOError::with_path(error, &target))?;
Ok(())
})
.await?
}
/// Reads a single archive entry into a string, tolerating GB18030-encoded
/// file contents produced by Chinese packaging tools.
pub(crate) async fn read_archive_entry_to_string(
archive_path: PathBuf,
entry_name: String,
) -> crate::Result<String> {
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&archive_path)
.map_err(|error| io::IOError::with_path(error, &archive_path))?;
let mut archive = zip::ZipArchive::new(file).map_err(archive_error)?;
let index = super::detect::find_entry_index(&mut archive, &entry_name)?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Modpack archive is missing {entry_name}"
))
})?;
let mut entry = archive.by_index(index).map_err(archive_error)?;
let mut contents = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut contents)?;
Ok(match String::from_utf8(contents) {
Ok(value) => value,
Err(error) => {
let (decoded, _, _) =
encoding_rs::GB18030.decode(error.as_bytes());
decoded.into_owned()
}
})
})
.await?
}
/// Allocates a unique scratch directory for extracting nested pack content.
pub(crate) async fn create_import_scratch_dir(
state: &crate::State,
) -> crate::Result<PathBuf> {
let dir = state
.directories
.caches_dir()
.join("modpack-import")
.join(uuid::Uuid::new_v4().to_string());
io::create_dir_all(&dir).await?;
Ok(dir)
}
#[cfg(test)]
mod tests {
use super::*;
struct CancelOnRead {
cancellation: CancellationToken,
read: bool,
}
impl std::io::Read for CancelOnRead {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
if self.read {
return Ok(0);
}
self.read = true;
self.cancellation.cancel();
let count = buffer.len().min(1024);
buffer[..count].fill(1);
Ok(count)
}
}
#[test]
fn blocking_copy_stops_before_writing_after_cancellation() {
let cancellation = CancellationToken::new();
let mut reader = CancelOnRead {
cancellation: cancellation.clone(),
read: false,
};
let mut output = Vec::new();
let error = copy_with_cancellation(
&mut reader,
&mut output,
Some(&cancellation),
Path::new("override.bin"),
)
.unwrap_err();
assert!(error.to_string().contains("Install was canceled"));
assert!(output.is_empty());
}
}

View File

@ -0,0 +1,464 @@
//! Local modpack file format detection.
//!
//! Detects the modpack format of a local archive by inspecting its contents
//! rather than its file extension, checking both the archive root and a single
//! wrapping folder, mirroring the detection behavior of PCL.
use std::io::Read;
use std::path::Path;
use tracing::debug;
pub const MRPACK_MANIFEST: &str = "modrinth.index.json";
pub const CURSEFORGE_MANIFEST: &str = "manifest.json";
pub const MCBBS_MANIFEST: &str = "mcbbs.packmeta";
pub const HMCL_MANIFEST: &str = "modpack.json";
pub const MMC_MANIFEST: &str = "mmc-pack.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalPackFormat {
Mrpack,
CurseForge,
Mcbbs,
Hmcl,
MmcExport,
LauncherBundled,
PlainArchive,
InstanceFolder,
}
impl LocalPackFormat {
pub fn as_str(&self) -> &'static str {
match self {
Self::Mrpack => "Modrinth",
Self::CurseForge => "CurseForge",
Self::Mcbbs => "MCBBS",
Self::Hmcl => "HMCL",
Self::MmcExport => "MultiMC",
Self::LauncherBundled => "launcher bundle",
Self::PlainArchive => "game folder archive",
Self::InstanceFolder => "instance folder",
}
}
}
#[derive(Debug, Clone)]
pub struct DetectedLocalPack {
pub format: LocalPackFormat,
/// Prefix of the folder containing the pack's key files, either empty or
/// a single path segment ending in `/`.
pub base_folder: String,
/// For [`LocalPackFormat::LauncherBundled`], the archive entry of the
/// nested modpack file.
pub inner_pack_entry: Option<String>,
/// For [`LocalPackFormat::PlainArchive`], the version id matched under
/// `versions/<id>/<id>.json`.
pub plain_version_id: Option<String>,
}
/// Decodes a zip entry name, tolerating archives produced by Chinese tools
/// that store GB18030-encoded names without the UTF-8 flag.
pub fn decode_zip_entry_name(raw: &[u8]) -> String {
match std::str::from_utf8(raw) {
Ok(name) => name.to_string(),
Err(_) => {
let (decoded, _, _) = encoding_rs::GB18030.decode(raw);
decoded.into_owned()
}
}
}
pub async fn detect_local_pack(
path: &Path,
) -> crate::Result<DetectedLocalPack> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || detect_local_pack_sync(&path)).await?
}
fn open_error(path: &Path, error: impl std::fmt::Display) -> crate::Error {
if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("rar"))
{
crate::ErrorKind::InputError(
"RAR modpack archives are not supported; please repackage the modpack as a zip file".to_string(),
)
.into()
} else {
crate::ErrorKind::InputError(format!(
"Failed to open modpack archive: {error}"
))
.into()
}
}
pub fn detect_local_pack_sync(path: &Path) -> crate::Result<DetectedLocalPack> {
let file =
std::fs::File::open(path).map_err(|error| open_error(path, error))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|error| open_error(path, error))?;
let total_entries = archive.len();
debug!(
"detect_local_pack_sync: path={} total_zip_entries={}",
path.display(),
total_entries
);
let mut names = Vec::with_capacity(archive.len());
for index in 0..archive.len() {
let entry = archive.by_index_raw(index).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Failed to read modpack archive entry: {error}"
))
})?;
if entry.encrypted() {
return Err(crate::ErrorKind::InputError(
"Encrypted modpack archives are not supported".to_string(),
)
.into());
}
let name = decode_zip_entry_name(entry.name_raw());
debug!(
"detect_local_pack_sync: scanning entry[{}] name={}",
index, name
);
names.push(name);
}
let bases = candidate_bases(&names);
debug!(
"detect_local_pack_sync: path={} candidate_bases={:?}",
path.display(),
bases
);
for base in &bases {
debug!(
"detect_local_pack_sync: path={} trying base={:?}",
path.display(),
base
);
if let Some(detected) = detect_at_base(&mut archive, &names, base)? {
debug!(
"detect_local_pack_sync: path={} base={:?} matched format={:?}",
path.display(),
base,
detected.format
);
return Ok(detected);
}
debug!(
"detect_local_pack_sync: path={} base={:?} no format matched",
path.display(),
base
);
}
debug!(
"detect_local_pack_sync: path={} trying detect_plain_archive",
path.display()
);
if let Some(detected) = detect_plain_archive(&names) {
debug!(
"detect_local_pack_sync: path={} matched PlainArchive version_id={:?}",
path.display(),
detected.plain_version_id
);
return Ok(detected);
}
debug!(
"detect_local_pack_sync: path={} trying detect_instance_folder",
path.display()
);
if let Some(detected) = detect_instance_folder(&names) {
debug!(
"detect_local_pack_sync: path={} matched InstanceFolder",
path.display()
);
return Ok(detected);
}
debug!(
"detect_local_pack_sync: path={} no format matched at all",
path.display()
);
Err(crate::ErrorKind::InputError(
"Unrecognized modpack format: no known pack manifest was found in the archive"
.to_string(),
)
.into())
}
/// The archive root, followed by each distinct single wrapping folder.
fn candidate_bases(names: &[String]) -> Vec<String> {
let mut bases = vec![String::new()];
for name in names {
if let Some((first, rest)) = name.split_once('/')
&& !rest.is_empty()
&& !rest.contains('/')
{
let base = format!("{first}/");
if !bases.contains(&base) {
bases.push(base);
}
}
}
bases
}
pub(crate) fn detect_at_base<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
names: &[String],
base: &str,
) -> crate::Result<Option<DetectedLocalPack>> {
let has = |file: &str| -> bool {
let target = format!("{base}{file}");
let found = names.iter().any(|name| name == &target);
debug!(
"detect_at_base: base={:?} has({}) target={} result={}",
base, file, target, found
);
found
};
let detected = |format: LocalPackFormat| DetectedLocalPack {
format,
base_folder: base.to_string(),
inner_pack_entry: None,
plain_version_id: None,
};
// MCBBS and MultiMC packs may also contain a manifest.json, so both must
// be checked before the CurseForge manifest.
if has(MCBBS_MANIFEST) {
debug!(
"detect_at_base: matched MCBBS via mcbbs.packmeta at base={:?}",
base
);
return Ok(Some(detected(LocalPackFormat::Mcbbs)));
}
if has(MMC_MANIFEST) {
debug!(
"detect_at_base: matched MmcExport via mmc-pack.json at base={:?}",
base
);
return Ok(Some(detected(LocalPackFormat::MmcExport)));
}
if has(MRPACK_MANIFEST) {
debug!(
"detect_at_base: matched Mrpack via modrinth.index.json at base={:?}",
base
);
return Ok(Some(detected(LocalPackFormat::Mrpack)));
}
if has(CURSEFORGE_MANIFEST) {
// A manifest.json with an `addons` array is the MCBBS variant. An
// unreadable manifest.json (e.g. an unrelated mod config inside a
// zipped game folder) does not abort detection of other formats.
match read_entry_json(archive, &format!("{base}{CURSEFORGE_MANIFEST}"))
{
Ok(manifest) => {
let has_addons = manifest
.get("addons")
.is_some_and(|value| !value.is_null());
debug!(
"detect_at_base: matched CurseForge/MCBBS via manifest.json at base={:?} addons={}",
base, has_addons
);
return Ok(Some(detected(if has_addons {
LocalPackFormat::Mcbbs
} else {
LocalPackFormat::CurseForge
})));
}
Err(error) => {
tracing::warn!(
"Ignoring unparsable manifest.json at {base:?} during modpack detection: {error}"
);
}
}
}
if has(HMCL_MANIFEST) {
debug!(
"detect_at_base: matched Hmcl via modpack.json at base={:?}",
base
);
return Ok(Some(detected(LocalPackFormat::Hmcl)));
}
for inner in ["modpack.zip", "modpack.mrpack"] {
if has(inner) {
debug!(
"detect_at_base: matched LauncherBundled via {} at base={:?}",
inner, base
);
return Ok(Some(DetectedLocalPack {
format: LocalPackFormat::LauncherBundled,
base_folder: base.to_string(),
inner_pack_entry: Some(format!("{base}{inner}")),
plain_version_id: None,
}));
}
}
debug!("detect_at_base: no format matched at base={:?}", base);
Ok(None)
}
/// Finds an entry index by its decoded name, so lookups stay consistent with
/// [`decode_zip_entry_name`] even for archives with GB18030-encoded names.
pub(crate) fn find_entry_index<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
entry_name: &str,
) -> crate::Result<Option<usize>> {
for index in 0..archive.len() {
let entry = archive.by_index_raw(index).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Failed to read modpack archive entry: {error}"
))
})?;
if decode_zip_entry_name(entry.name_raw()).replace('\\', "/")
== entry_name
{
return Ok(Some(index));
}
}
Ok(None)
}
fn read_entry_json<R: std::io::Read + std::io::Seek>(
archive: &mut zip::ZipArchive<R>,
entry_name: &str,
) -> crate::Result<serde_json::Value> {
let index = find_entry_index(archive, entry_name)?.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Modpack archive is missing {entry_name}"
))
})?;
let mut entry = archive.by_index(index).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Failed to read {entry_name} from modpack archive: {error}"
))
})?;
let mut contents = Vec::new();
entry.read_to_end(&mut contents)?;
// Windows tools often prepend a UTF-8 BOM; serde_json rejects it.
let contents = contents
.strip_prefix(&[0xEF, 0xBB, 0xBF])
.unwrap_or(&contents);
Ok(serde_json::from_slice(contents)?)
}
/// Looks for a `versions/<id>/<id>.json` structure marking a zipped-up game
/// folder, returning the prefix of the folder containing `versions`.
fn detect_plain_archive(names: &[String]) -> Option<DetectedLocalPack> {
for name in names {
let segments: Vec<&str> = name.split('/').collect();
if segments.len() < 3 {
continue;
}
let json = segments[segments.len() - 1];
let version = segments[segments.len() - 2];
let marker = segments[segments.len() - 3];
let is_match = marker == "versions"
&& !version.is_empty()
&& json
.strip_suffix(".json")
.is_some_and(|stem| stem == version);
debug!(
"detect_plain_archive: checking entry={} marker={} version={} json={} is_match={}",
name, marker, version, json, is_match
);
if is_match {
let base = segments[..segments.len() - 3].join("/");
let base = if base.is_empty() {
base
} else {
format!("{base}/")
};
debug!(
"detect_plain_archive: matched version={} base={:?}",
version, base
);
return Some(DetectedLocalPack {
format: LocalPackFormat::PlainArchive,
base_folder: base,
inner_pack_entry: None,
plain_version_id: Some(version.to_string()),
});
}
}
debug!("detect_plain_archive: no versions pattern matched");
None
}
/// Looks for a `mods` folder containing `.jar` files, marking a simple instance folder.
fn detect_instance_folder(names: &[String]) -> Option<DetectedLocalPack> {
// First, collect all paths that contain a "mods" segment
let mut mods_folders = std::collections::HashSet::new();
for name in names {
let segments: Vec<&str> = name.split('/').collect();
for i in 0..segments.len() {
if segments[i] == "mods" {
let base = segments[..i].join("/");
let base = if base.is_empty() {
base
} else {
format!("{base}/")
};
debug!(
"detect_instance_folder: found mods folder at entry={} base={:?}",
name, base
);
mods_folders.insert(base);
}
}
}
debug!(
"detect_instance_folder: found {} unique mods folder(s)",
mods_folders.len()
);
if mods_folders.is_empty() {
debug!("detect_instance_folder: no mods folder found");
return None;
}
// Now check each mods folder to see if there's at least one .jar file in it
for base in &mods_folders {
let mut jar_files: Vec<String> = Vec::new();
for name in names {
let name_without_base =
name.strip_prefix(base.as_str()).unwrap_or(name);
let segments: Vec<&str> = name_without_base.split('/').collect();
if segments.len() == 2
&& segments[0] == "mods"
&& segments[1].to_lowercase().ends_with(".jar")
{
jar_files.push(name.clone());
}
}
let has_jar = !jar_files.is_empty();
debug!(
"detect_instance_folder: base={:?} has_jar={} jar_files={:?}",
base, has_jar, jar_files
);
if has_jar {
debug!(
"detect_instance_folder: matched InstanceFolder at base={:?}",
base
);
return Some(DetectedLocalPack {
format: LocalPackFormat::InstanceFolder,
base_folder: base.clone(),
inner_pack_entry: None,
plain_version_id: None,
});
}
}
debug!("detect_instance_folder: no mods folder with .jar files found");
None
}

View File

@ -0,0 +1,277 @@
use std::{collections::HashMap, path::PathBuf};
use serde::{Deserialize, Serialize};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
pack::{
self,
import::{self, finish_import},
install_from::CreatePackDescription,
},
prelude::ModLoader,
state::{
AppliedContentSetPatch, EditInstance, InstanceInstallStage,
InstanceLink,
},
util::io,
};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ATInstance {
pub id: String, // minecraft version id ie: 1.12.1, not a name
pub launcher: ATLauncher,
pub java_version: ATJavaVersion,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ATLauncher {
pub name: String,
pub pack: String,
pub version: String, // ie: 1.6
pub loader_version: ATLauncherLoaderVersion,
pub modrinth_project: Option<ATLauncherModrinthProject>,
pub modrinth_version: Option<ATLauncherModrinthVersion>,
pub modrinth_manifest: Option<pack::install_from::PackFormat>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ATJavaVersion {
pub major_version: u8,
pub component: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ATLauncherLoaderVersion {
pub r#type: String,
pub version: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ATLauncherModrinthProject {
pub id: String,
pub slug: String,
pub project_type: String,
pub team: String,
pub client_side: Option<String>,
pub server_side: Option<String>,
pub categories: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct ATLauncherModrinthVersion {
pub id: String,
pub project_id: String,
pub name: String,
pub version_number: String,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ATLauncherModrinthVersionFile {
pub hashes: HashMap<String, String>,
pub url: String,
pub filename: String,
pub primary: bool,
pub size: u64,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ATLauncherModrinthVersionDependency {
pub project_id: Option<String>,
pub version_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ATLauncherMod {
pub name: String,
pub version: String,
pub file: String,
pub modrinth_project: Option<ATLauncherModrinthProject>,
pub modrinth_version: Option<ATLauncherModrinthVersion>,
}
// Check if folder has a instance.json that parses
pub async fn is_valid_atlauncher(instance_folder: PathBuf) -> bool {
let instance = serde_json::from_str::<ATInstance>(
&io::read_any_encoding_to_string(
&instance_folder.join("instance.json"),
)
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
);
if let Err(e) = instance {
tracing::warn!(
"Could not parse instance.json at {}: {}",
instance_folder.display(),
e
);
false
} else {
true
}
}
#[tracing::instrument]
pub async fn import_atlauncher_dir(
atlauncher_base_path: PathBuf,
atlauncher_instance_path: PathBuf,
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let atinstance = serde_json::from_str::<ATInstance>(
&io::read_any_encoding_to_string(
&atlauncher_instance_path.join("instance.json"),
)
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
)?;
let icon_path_primary = atlauncher_instance_path.join("instance.png");
let safe_pack_name = atinstance
.launcher
.pack
.replace(|c: char| !c.is_alphanumeric(), "")
.to_lowercase();
let icon_path_secondary = atlauncher_base_path
.join("configs")
.join("images")
.join(safe_pack_name + ".png");
let icon = match (icon_path_primary.exists(), icon_path_secondary.exists())
{
(true, _) => import::recache_icon(icon_path_primary).await?,
(_, true) => import::recache_icon(icon_path_secondary).await?,
_ => None,
};
let description = CreatePackDescription {
icon,
override_title: Some(atinstance.launcher.name.clone()),
project_id: None,
version_id: None,
instance_id: instance_id.to_string(),
source_filename: None,
};
let backup_name = format!(
"ATLauncher-{}",
atlauncher_instance_path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default()
);
let minecraft_folder = atlauncher_instance_path;
import_atlauncher_unmanaged(
instance_id,
minecraft_folder,
backup_name,
description,
atinstance,
reporter,
details,
symlink,
)
.await?;
Ok(())
}
async fn import_atlauncher_unmanaged(
instance_id: &str,
minecraft_folder: PathBuf,
backup_name: String,
description: CreatePackDescription,
atinstance: ATInstance,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let mod_loader = format!(
"\"{}\"",
atinstance.launcher.loader_version.r#type.to_lowercase()
);
let mod_loader: ModLoader = serde_json::from_str::<ModLoader>(&mod_loader)
.map_err(|_| {
crate::ErrorKind::InputError(format!(
"Could not parse mod loader type: {mod_loader}"
))
})?;
let game_version = atinstance.id;
let loader_version = if mod_loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&game_version,
mod_loader,
Some(&atinstance.launcher.loader_version.version),
)
.await?
} else {
None
};
let link = match (&description.project_id, &description.version_id) {
(Some(project_id), Some(version_id)) => {
Some(InstanceLink::ModrinthModpack {
project_id: project_id.clone(),
version_id: version_id.clone(),
})
}
_ => None,
};
crate::api::instance::edit(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
description
.override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
description
.icon
.clone()
.map(|x| x.to_string_lossy().to_string()),
),
link,
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version.clone().map(|x| x.id)),
}),
..EditInstance::default()
},
)
.await?;
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
let state = State::get().await?;
finish_import(
instance_id,
minecraft_folder,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await?;
Ok(())
}

View File

@ -0,0 +1,178 @@
use std::path::PathBuf;
use serde::Deserialize;
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
state::{
AppliedContentSetPatch, ContentSourceKind, EditInstance,
InstanceInstallStage, InstanceLaunchOverridesPatch, ModLoader,
ReleaseChannel, instances::InstanceLaunchOverridesData,
},
util::io,
};
use super::{ImportOverrides, finish_import, generic, recache_icon};
const CONFIG_FILE_NAME: &str = "axolotl_config.json";
const CONFIG_SCHEMA_VERSION: u32 = 1;
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub struct AxolotlConfigFile {
pub schema_version: u32,
pub instance_id: String,
pub path: String,
pub generated_at: chrono::DateTime<chrono::Utc>,
pub name: String,
pub icon_path: Option<String>,
pub update_channel: String,
pub symlink_target: Option<String>,
pub groups: Vec<String>,
pub content_set: AxolotlContentSet,
pub link: crate::state::instances::InstanceLink,
pub launch_overrides: InstanceLaunchOverridesData,
}
#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub struct AxolotlContentSet {
pub source_kind: String,
pub game_version: String,
pub protocol_version: Option<u32>,
pub loader: String,
pub loader_version: Option<String>,
}
/// Imports an Axolotl instance by reading `axolotl_config.json` and applying
/// the migratable fields to the new profile. Invalid or unsupported configs
/// fall back to the generic instance import so the game files still arrive.
pub(crate) async fn import_axolotl(
source_path: PathBuf,
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
overrides: &ImportOverrides,
) -> crate::Result<()> {
let config_path = source_path.join(CONFIG_FILE_NAME);
let content = io::read_any_encoding_to_string(&config_path)
.await
.unwrap_or_else(|error| {
tracing::warn!(
"Axolotl import: could not read {}: {error}; falling back to generic import",
config_path.display()
);
(String::new(), encoding_rs::UTF_8)
})
.0;
let config = match serde_json::from_str::<AxolotlConfigFile>(&content) {
Ok(config) => config,
Err(error) => {
tracing::warn!(
"Axolotl import: invalid config {}: {error}; falling back to generic import",
config_path.display()
);
return generic::import_generic(
source_path,
instance_id,
reporter,
details,
symlink,
overrides,
None,
)
.await;
}
};
tracing::debug!(
"Axolotl import: config instance_id={} path={} generated_at={} symlink_target={:?}",
config.instance_id,
config.path,
config.generated_at,
config.symlink_target
);
if config.schema_version != CONFIG_SCHEMA_VERSION
|| config.content_set.game_version.trim().is_empty()
{
tracing::warn!(
"Axolotl import: unsupported schema or missing game version for {}; falling back to generic import",
config_path.display()
);
return generic::import_generic(
source_path,
instance_id,
reporter,
details,
symlink,
overrides,
None,
)
.await;
}
let icon = match config.icon_path.as_ref() {
Some(path) => recache_icon(source_path.join(path)).await?,
None => None,
};
let source_kind =
ContentSourceKind::from_str(&config.content_set.source_kind)
.unwrap_or(ContentSourceKind::Local);
let loader = ModLoader::try_from_string(&config.content_set.loader)?;
let update_channel = ReleaseChannel::from_key(&config.update_channel);
let state = State::get().await?;
crate::state::edit_instance(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(config.name.clone()),
icon_path: Some(icon.map(|p| p.to_string_lossy().to_string())),
update_channel: Some(update_channel),
groups: Some(config.groups.clone()),
link: Some(config.link.clone()),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: Some(source_kind),
game_version: Some(config.content_set.game_version.clone()),
protocol_version: Some(config.content_set.protocol_version),
loader: Some(loader),
loader_version: Some(config.content_set.loader_version.clone()),
}),
launch_overrides: Some(InstanceLaunchOverridesPatch {
java_path: Some(config.launch_overrides.java_path.clone()),
extra_launch_args: Some(
config.launch_overrides.extra_launch_args.clone(),
),
custom_env_vars: Some(
config.launch_overrides.custom_env_vars.clone(),
),
memory: Some(config.launch_overrides.memory),
force_fullscreen: Some(
config.launch_overrides.force_fullscreen,
),
maximize_window: Some(config.launch_overrides.maximize_window),
game_resolution: Some(config.launch_overrides.game_resolution),
launch_preparation_timeout: Some(
config.launch_overrides.launch_preparation_timeout,
),
hooks: Some(config.launch_overrides.hooks.clone()),
}),
..EditInstance::default()
},
&state.pool,
)
.await?;
finish_import(
instance_id,
source_path,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await
}

View File

@ -0,0 +1,275 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
prelude::ModLoader,
state::{AppliedContentSetPatch, EditInstance, InstanceInstallStage},
util::{
fetch::{fetch, write_cached_icon},
io,
},
};
use super::{finish_import, instance_json, recache_icon};
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftInstance {
pub name: Option<String>,
pub base_mod_loader: Option<MinecraftInstanceModLoader>,
pub profile_image_path: Option<PathBuf>,
pub installed_modpack: Option<InstalledModpack>,
pub game_version: String, // Minecraft game version. Non-prioritized, use this if Vanilla
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftInstanceModLoader {
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct InstalledModpack {
pub thumbnail_url: Option<String>,
}
fn parse_curseforge_loader(
loader_name: &str,
game_version: &str,
) -> Option<(ModLoader, String)> {
let loader_name = loader_name.trim();
if loader_name.eq_ignore_ascii_case("labymod")
|| loader_name.to_ascii_lowercase().starts_with("labymod-")
{
return None;
}
let family = crate::api::curseforge::loader_family(loader_name);
let loader = match family {
"forge" => ModLoader::Forge,
"fabric" => ModLoader::Fabric,
"quilt" => ModLoader::Quilt,
"neo" | "neoforge" => ModLoader::NeoForge,
_ => return None,
};
let detected_version =
loader_name.strip_prefix(family)?.strip_prefix('-')?.trim();
if detected_version.is_empty() {
return None;
}
let version = instance_json::normalize_imported_loader_version(
loader.as_str(),
game_version,
detected_version,
);
(!version.is_empty()).then_some((loader, version))
}
// Check if folder has a minecraftinstance.json that parses
pub async fn is_valid_curseforge(instance_folder: PathBuf) -> bool {
let minecraft_instance = serde_json::from_str::<MinecraftInstance>(
&io::read_any_encoding_to_string(
&instance_folder.join("minecraftinstance.json"),
)
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
);
minecraft_instance.is_ok()
}
pub async fn import_curseforge(
curseforge_instance_folder: PathBuf, // instance's folder
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
// Load minecraftinstance.json
let minecraft_instance = serde_json::from_str::<MinecraftInstance>(
&io::read_any_encoding_to_string(
&curseforge_instance_folder.join("minecraftinstance.json"),
)
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
)?;
let override_title = minecraft_instance.name;
let backup_name = format!(
"Curseforge-{}",
curseforge_instance_folder
.file_name()
.map_or("Unknown".to_string(), |a| a.to_string_lossy().to_string())
);
let state = State::get().await?;
// Recache Curseforge Icon if it exists
let mut icon = None;
if let Some(icon_path) = minecraft_instance.profile_image_path.clone() {
icon = recache_icon(icon_path).await?;
} else if let Some(InstalledModpack {
thumbnail_url: Some(thumbnail_url),
}) = minecraft_instance.installed_modpack.clone()
{
let icon_bytes = fetch(
&thumbnail_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
let filename = thumbnail_url.rsplit('/').next_back();
if let Some(filename) = filename {
icon = Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
);
}
}
// base mod loader is always None for vanilla
if let Some(instance_mod_loader) = minecraft_instance.base_mod_loader {
let game_version = minecraft_instance.game_version;
let parsed_loader =
parse_curseforge_loader(&instance_mod_loader.name, &game_version);
let (mod_loader, requested_loader_version) = parsed_loader.ok_or_else(|| {
let loader_name = instance_mod_loader.name.trim();
let message = if loader_name.eq_ignore_ascii_case("labymod")
|| loader_name.to_ascii_lowercase().starts_with("labymod-")
{
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances".to_string()
} else {
format!(
"Unsupported loader {loader_name}: the instance was not imported as Vanilla"
)
};
crate::ErrorKind::InputError(message)
})?;
let loader_version = crate::launcher::get_loader_version_from_profile(
&game_version,
mod_loader,
Some(&requested_loader_version),
)
.await?;
if loader_version.is_none() {
return Err(crate::ErrorKind::InputError(format!(
"CurseForge instance loader version {requested_loader_version} is not available for {} {game_version}",
mod_loader.as_str(),
))
.into());
}
crate::api::instance::edit(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
icon.clone().map(|x| x.to_string_lossy().to_string()),
),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version.clone().map(|x| x.id)),
}),
..EditInstance::default()
},
)
.await?;
} else {
crate::api::instance::edit(
instance_id,
EditInstance {
name: Some(
override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
icon.clone().map(|x| x.to_string_lossy().to_string()),
),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(minecraft_instance.game_version.clone()),
protocol_version: Some(None),
loader: Some(ModLoader::Vanilla),
loader_version: Some(None),
}),
..EditInstance::default()
},
)
.await?;
}
// Copy in contained folders as overrides
let state = State::get().await?;
finish_import(
instance_id,
curseforge_instance_folder,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_supported_curseforge_instance_loaders() {
for (name, game_version, loader, version) in [
("forge-47.4.22", "1.20.1", ModLoader::Forge, "47.4.22"),
(
"fabric-0.16.10-1.21.1",
"1.21.1",
ModLoader::Fabric,
"0.16.10",
),
("quilt-0.26.4-1.20.1", "1.20.1", ModLoader::Quilt, "0.26.4"),
(
"neoforge-21.4.157",
"1.21.4",
ModLoader::NeoForge,
"21.4.157",
),
] {
assert_eq!(
parse_curseforge_loader(name, game_version),
Some((loader, version.to_string())),
"{name}"
);
}
}
#[test]
fn rejects_unknown_curseforge_instance_loader() {
assert_eq!(parse_curseforge_loader("unknown-1.0", "1.20.1"), None);
}
#[test]
fn rejects_labymod_curseforge_instance_loader() {
assert_eq!(parse_curseforge_loader("labymod-4.4.20", "1.20.1"), None);
}
}

View File

@ -0,0 +1,948 @@
use std::path::{Path, PathBuf};
use serde_json::Value;
use super::{ImportLauncherType, generic, hmcl, instance_json, pcl};
use crate::state::ModLoader;
const TEMP_IMPORT_DIR: &str = "axolotl-launcher-import";
#[derive(Clone, Debug)]
pub(crate) struct ResolvedDirectLink {
pub launcher: ImportLauncherType,
pub launcher_root: PathBuf,
pub dot_minecraft: PathBuf,
/// Resolved `versions/<id>` directory of the linked installation; carried
/// as part of the resolution contract even though current consumers
/// derive their paths from `dot_minecraft`/`version_json` directly.
#[allow(dead_code)]
pub version_dir: PathBuf,
pub version_json: PathBuf,
pub version_id: String,
pub game_version: String,
pub loader: ModLoader,
/// Detected loader version of the linked document. Directly associated
/// instances display no loader version (the loader is managed by the
/// external launcher), so this is not persisted anywhere yet.
#[allow(dead_code)]
pub loader_version: Option<String>,
}
/// The launcher's dialect and root used to resolve a traditional
/// `.minecraft/versions/<id>` directory. The root is intentionally separate
/// from the game directory: PCL stores its settings beside the executable,
/// while its `.minecraft` may be a sibling directory.
#[derive(Clone, Debug)]
pub(crate) struct DirectLinkSource {
pub launcher: ImportLauncherType,
pub launcher_root: PathBuf,
}
/// Identifies the launcher that owns an externally selected version folder.
///
/// Direct-link Settings receives `.minecraft` roots rather than launcher
/// executables, so the normal import scanner cannot infer PCL from its
/// executable alone. Probe the selected root and its parent, where portable
/// PCL/PCL-CE installations keep their executable and `PCL` configuration.
/// HMCL is only selected when its configuration actually references the
/// supplied game directory; an unrelated `.hmcl` folder must not relabel a
/// generic Minecraft installation.
pub(crate) fn detect_direct_link_source(
dot_minecraft: &Path,
version_dir: &Path,
) -> DirectLinkSource {
for launcher_root in [
dot_minecraft.parent().map(Path::to_path_buf),
Some(dot_minecraft.to_path_buf()),
]
.into_iter()
.flatten()
{
if super::pe_info::folder_has_product(
&launcher_root,
"Plain Craft Launcher",
) {
let launcher =
if launcher_root.join("PCL").join("config.v1.yml").is_file() {
ImportLauncherType::PCL2CE
} else {
ImportLauncherType::PCL2
};
return DirectLinkSource {
launcher,
launcher_root,
};
}
if super::hmcl::config_exists(&launcher_root)
&& super::hmcl::configured_game_dir(
&launcher_root,
dot_minecraft,
version_dir,
)
.is_some()
{
return DirectLinkSource {
launcher: ImportLauncherType::HMCL,
launcher_root,
};
}
}
DirectLinkSource {
launcher: ImportLauncherType::Generic,
launcher_root: dot_minecraft.to_path_buf(),
}
}
/// Returns the stable user-facing group for a directly linked `.minecraft`
/// root. The complete normalized path is used as the group name so two roots
/// with the same display folder name remain distinct.
pub(crate) fn direct_link_group(dot_minecraft: &Path) -> Option<String> {
let path = dot_minecraft.to_string_lossy().trim().to_string();
(!path.is_empty()).then_some(path)
}
impl ResolvedDirectLink {
pub(crate) fn launcher_key(&self) -> &'static str {
launcher_key(self.launcher)
.expect("resolved direct links always use a supported launcher")
}
}
/// Resolves the same launcher identity used by the existing import flow into
/// persistent paths for a read-only direct association.
///
/// The conventional repository layout follows HMCL
/// `DefaultGameRepositoryLayout.getInstanceRoot/getInstanceJson` at commit
/// `083dbb18ade1c935e2e56d0bdefcd718be1e2ed6`: shared root plus
/// `versions/<id>/<id>.json`. PCL's fallback follows
/// `ModMinecraft.McInstance.GetJsonPath` at commit
/// `639de1b48a44326cbd5465579295cecf23d9056a`: prefer the same-name JSON,
/// otherwise inspect JSON files in the version directory. PCL-CE keeps the
/// same version-folder model in `Modules/Minecraft/McInstance.cs` at commit
/// `aa3b81c6afb3cd1896dda271578b002066512177`.
pub(crate) async fn resolve_direct_link(
launcher_type: ImportLauncherType,
base_path: PathBuf,
instance_folder: String,
instance_path: Option<String>,
) -> crate::Result<ResolvedDirectLink> {
if launcher_key(launcher_type).is_none()
&& launcher_type != ImportLauncherType::Unknown
{
return Err(unsupported_launcher(launcher_type));
}
reject_temporary_import_path(&base_path)?;
if let Some(instance_path) = instance_path.as_deref() {
reject_temporary_import_path(Path::new(instance_path))?;
}
if launcher_type == ImportLauncherType::Unknown {
return resolve_unknown(
base_path,
instance_folder,
instance_path.as_deref(),
)
.await;
}
resolve_known(
launcher_type,
base_path,
&instance_folder,
instance_path.as_deref(),
)
}
async fn resolve_unknown(
base_path: PathBuf,
instance_folder: String,
instance_path: Option<&str>,
) -> crate::Result<ResolvedDirectLink> {
// Match the selected scan result before assigning a dialect. Merely having
// HMCL/PCL configuration beside a Generic version must not relabel that
// version and route it through the wrong launch merger.
if let Ok(instances) = Box::pin(super::get_importable_instances(
ImportLauncherType::HMCL,
base_path.clone(),
))
.await
&& selection_matches(&instances, &instance_folder, instance_path)
&& let Ok(resolved) = resolve_known(
ImportLauncherType::HMCL,
base_path.clone(),
&instance_folder,
instance_path,
)
{
return Ok(resolved);
}
// The existing PCL scanner intentionally merges legacy PCL and PCL-CE
// config sources. Recover the source dialect after matching the selected
// scan item so PCL-CE is not always mislabeled as the first PCL variant.
if let Ok(instances) = Box::pin(super::get_importable_instances(
ImportLauncherType::PCL2,
base_path.clone(),
))
.await
&& selection_matches(&instances, &instance_folder, instance_path)
{
let launcher_type = pcl_dialect(&instance_folder, instance_path);
if let Ok(resolved) = resolve_known(
launcher_type,
base_path.clone(),
&instance_folder,
instance_path,
) {
return Ok(resolved);
}
}
resolve_known(
ImportLauncherType::Generic,
base_path,
&instance_folder,
instance_path,
)
.map_err(|_| {
crate::ErrorKind::InputError(
"Could not resolve a traditional .minecraft instance as HMCL, PCL2, PCL2CE, or Generic"
.to_string(),
)
.into()
})
}
fn selection_matches(
instances: &[super::ImportableInstance],
instance_folder: &str,
instance_path: Option<&str>,
) -> bool {
instances.iter().any(|candidate| {
if let Some(selected) = instance_path {
paths_match(Path::new(selected), Path::new(&candidate.path))
|| candidate.version_path.as_deref().is_some_and(
|version_path| {
paths_match(
Path::new(selected),
Path::new(version_path),
)
},
)
} else {
candidate.name == instance_folder
}
})
}
fn pcl_dialect(
instance_folder: &str,
instance_path: Option<&str>,
) -> ImportLauncherType {
let pcl_sources = pcl::get_pcl_instances();
let pcl_ce_sources = pcl::get_pclce_instances();
pcl_dialect_from_sources(
instance_folder,
instance_path,
&pcl_sources,
&pcl_ce_sources,
)
}
fn pcl_dialect_from_sources(
instance_folder: &str,
instance_path: Option<&str>,
pcl_sources: &[(String, String)],
pcl_ce_sources: &[(String, String)],
) -> ImportLauncherType {
let config_name = split_config_name(instance_folder).0;
let source_matches = |sources: &[(String, String)]| {
sources.iter().any(|(name, path)| {
if let Some(selected) = instance_path {
path_contains(Path::new(path), Path::new(selected))
} else {
name == config_name
}
})
};
let pcl_matches = source_matches(pcl_sources);
let pcl_ce_matches = source_matches(pcl_ce_sources);
if pcl_ce_matches && !pcl_matches {
ImportLauncherType::PCL2CE
} else {
// Preserve the existing importer's legacy-PCL-first precedence for
// duplicate config names and the launcher's local `.minecraft` entry.
ImportLauncherType::PCL2
}
}
fn resolve_known(
launcher_type: ImportLauncherType,
base_path: PathBuf,
instance_folder: &str,
instance_path: Option<&str>,
) -> crate::Result<ResolvedDirectLink> {
let launcher_root = canonicalize_checked(&base_path)?;
let (dot_minecraft, version_dir) = if let Some(instance_path) =
instance_path
{
let version_dir = canonicalize_checked(Path::new(instance_path))?;
let dot_minecraft = compatible_game_dir(&base_path, &version_dir)?;
(canonicalize_checked(&dot_minecraft)?, version_dir)
} else {
let source =
resolve_source_path(launcher_type, &base_path, instance_folder)?;
resolve_repository_paths(&source, instance_folder)?
};
reject_temporary_import_path(&launcher_root)?;
reject_temporary_import_path(&dot_minecraft)?;
reject_temporary_import_path(&version_dir)?;
let version_json = discover_version_json(&version_dir)?;
let version_json = canonicalize_checked(&version_json)?;
let version_id = version_json
.file_stem()
.and_then(|stem| stem.to_str())
.filter(|stem| !stem.is_empty())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Version JSON has no usable file stem: {}",
version_json.display()
))
.as_error()
})?
.to_string();
let info = instance_json::detect(&version_dir).ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Could not detect Minecraft version from {}",
version_json.display()
))
.as_error()
})?;
let loader = info
.loader
.as_deref()
.map(ModLoader::try_from_string)
.transpose()?
.unwrap_or(ModLoader::Vanilla);
Ok(ResolvedDirectLink {
launcher: launcher_type,
launcher_root,
dot_minecraft,
version_dir,
version_json,
version_id,
game_version: info.vanilla_name,
loader,
loader_version: info.loader_version,
})
}
fn resolve_source_path(
launcher_type: ImportLauncherType,
base_path: &Path,
instance_folder: &str,
) -> crate::Result<PathBuf> {
let (config_name, rest) = split_config_name(instance_folder);
let target = if rest.is_empty() { config_name } else { rest };
let game_dir = match launcher_type {
ImportLauncherType::HMCL => {
hmcl::get_instance_path(base_path, config_name)
.map(PathBuf::from)
.map(|path| {
if path.is_absolute() {
path
} else {
base_path.join(path)
}
})
.unwrap_or_else(|| base_path.to_path_buf())
}
ImportLauncherType::PCL2 | ImportLauncherType::PCL2CE => {
find_pcl_source(config_name, &pcl::get_pcl_instances())
.or_else(|| {
find_pcl_source(config_name, &pcl::get_pclce_instances())
})
.map(PathBuf::from)
.or_else(|| {
(config_name == ".minecraft")
.then(|| base_path.join(".minecraft"))
.filter(|path| path.is_dir())
})
.unwrap_or_else(|| base_path.to_path_buf())
}
ImportLauncherType::Generic => base_path.to_path_buf(),
_ => return Err(unsupported_launcher(launcher_type)),
};
Ok(resolve_instance_path(&game_dir, target))
}
fn resolve_repository_paths(
source: &Path,
instance_folder: &str,
) -> crate::Result<(PathBuf, PathBuf)> {
let source = canonicalize_checked(source)?;
if source
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
{
let dot_minecraft = source
.parent()
.and_then(Path::parent)
.ok_or_else(|| invalid_version_directory(&source))?;
return Ok((canonicalize_checked(dot_minecraft)?, source));
}
let (_, dot_minecraft) = generic::resolve_dotminecraft(&source);
let dot_minecraft = canonicalize_checked(&dot_minecraft)?;
let target = split_config_name(instance_folder).1;
let target = if target.is_empty() {
Path::new(instance_folder)
.strip_prefix("versions")
.unwrap_or_else(|_| Path::new(instance_folder))
} else {
Path::new(target)
.strip_prefix("versions")
.unwrap_or_else(|_| Path::new(target))
};
let version_dir = dot_minecraft.join("versions").join(target);
if !version_dir.is_dir() {
return Err(invalid_version_directory(&version_dir));
}
Ok((dot_minecraft, canonicalize_checked(&version_dir)?))
}
fn compatible_game_dir(
base_path: &Path,
version_dir: &Path,
) -> crate::Result<PathBuf> {
if version_dir
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
{
return version_dir
.parent()
.and_then(Path::parent)
.map(Path::to_path_buf)
.ok_or_else(|| invalid_version_directory(version_dir));
}
let (_, dot_minecraft) = generic::resolve_dotminecraft(base_path);
Ok(dot_minecraft)
}
/// Returns whether a file is a Minecraft version manifest rather than a JSON
/// sidecar produced by the game or a launcher. Version folders often contain
/// files such as `usercache.json`; those must not make a copied PCL instance
/// appear ambiguous.
fn is_minecraft_version_manifest(path: &Path) -> bool {
let Ok(contents) = std::fs::read_to_string(path) else {
return false;
};
let Ok(value) = serde_json::from_str::<Value>(&contents) else {
return false;
};
let Some(object) = value.as_object() else {
return false;
};
object
.get("id")
.and_then(Value::as_str)
.is_some_and(|id| !id.trim().is_empty())
&& [
"arguments",
"assetIndex",
"assets",
"clientVersion",
"downloads",
"inheritsFrom",
"jar",
"libraries",
"mainClass",
"minecraftArguments",
]
.iter()
.any(|field| object.contains_key(*field))
}
/// Returns whether a version directory contains at least one usable Minecraft
/// version manifest. This lets a root scan ignore launcher bookkeeping folders
/// that are not launchable instances.
pub(crate) fn has_minecraft_version_manifest(version_dir: &Path) -> bool {
std::fs::read_dir(version_dir).is_ok_and(|entries| {
entries.flatten().map(|entry| entry.path()).any(|path| {
path.is_file()
&& path.extension().is_some_and(|extension| {
extension.eq_ignore_ascii_case("json")
})
&& is_minecraft_version_manifest(&path)
})
})
}
/// Finds the actual manifest selected by upstream launchers: a valid
/// same-name manifest first, then the sole valid manifest in the version
/// directory. Ambiguous folders are rejected instead of guessing which
/// manifest the UI intended.
pub(crate) fn discover_version_json(
version_dir: &Path,
) -> crate::Result<PathBuf> {
let folder_name = version_dir
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| invalid_version_directory(version_dir))?;
let same_name = version_dir.join(format!("{folder_name}.json"));
if same_name.is_file() && is_minecraft_version_manifest(&same_name) {
return Ok(same_name);
}
let mut json_files = std::fs::read_dir(version_dir)
.map_err(|error| {
crate::ErrorKind::FSError(format!(
"Failed to inspect version directory {}: {error}",
version_dir.display()
))
})?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.is_file()
&& path.extension().is_some_and(|extension| {
extension.eq_ignore_ascii_case("json")
})
&& is_minecraft_version_manifest(path)
})
.collect::<Vec<_>>();
json_files.sort();
match json_files.as_slice() {
[only] => Ok(only.clone()),
[] => Err(crate::ErrorKind::InputError(format!(
"No Minecraft version JSON found in {}",
version_dir.display()
))
.into()),
_ => Err(crate::ErrorKind::InputError(format!(
"Multiple Minecraft version JSON files found in {}; expected a same-name JSON or one unique fallback",
version_dir.display()
))
.into()),
}
}
fn launcher_key(launcher_type: ImportLauncherType) -> Option<&'static str> {
match launcher_type {
ImportLauncherType::HMCL => Some("hmcl"),
ImportLauncherType::PCL2 => Some("pcl2"),
ImportLauncherType::PCL2CE => Some("pcl2_ce"),
ImportLauncherType::Generic => Some("generic"),
_ => None,
}
}
fn reject_temporary_import_path(path: &Path) -> crate::Result<()> {
let temporary_root = std::env::temp_dir().join(TEMP_IMPORT_DIR);
if path.starts_with(&temporary_root) {
return Err(crate::ErrorKind::InputError(
"Direct association is unavailable for extracted launcher archives because the temporary folder is deleted after import"
.to_string(),
)
.into());
}
Ok(())
}
fn canonicalize_checked(path: &Path) -> crate::Result<PathBuf> {
let canonical = crate::util::io::canonicalize(path)?;
reject_temporary_import_path(&canonical)?;
Ok(canonical)
}
fn paths_match(left: &Path, right: &Path) -> bool {
match (
crate::util::io::canonicalize(left),
crate::util::io::canonicalize(right),
) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
fn path_contains(root: &Path, selected: &Path) -> bool {
match (
crate::util::io::canonicalize(root),
crate::util::io::canonicalize(selected),
) {
(Ok(root), Ok(selected)) => selected.starts_with(root),
_ => selected.starts_with(root),
}
}
fn unsupported_launcher(launcher_type: ImportLauncherType) -> crate::Error {
crate::ErrorKind::InputError(format!(
"Direct association does not support launcher {launcher_type}; expected HMCL, PCL2, PCL2CE, Generic, or Unknown"
))
.into()
}
fn invalid_version_directory(path: &Path) -> crate::Error {
crate::ErrorKind::InputError(format!(
"Expected a traditional .minecraft version directory at {}",
path.display()
))
.into()
}
/// Splits an instance folder identity like `name` or `Version:1.12.2` into
/// (config name, version part); PCL and HMCL instance identities use this
/// shape.
fn split_config_name(name: &str) -> (&str, &str) {
name.split_once(':').unwrap_or((name, ""))
}
/// Resolves the folder of an instance from a base path and its scan identity.
fn resolve_instance_path(base_path: &Path, instance_folder: &str) -> PathBuf {
if let Ok(rest) = Path::new(instance_folder).strip_prefix("versions") {
return base_path.join("versions").join(rest);
}
if base_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.as_deref()
== Some(instance_folder)
{
base_path.to_path_buf()
} else {
base_path.join(instance_folder)
}
}
/// Finds the game directory of a PCL/PCL-CE instance from its scan sources
/// (registry entries or CE config).
fn find_pcl_source(
instance_name: &str,
sources: &[(String, String)],
) -> Option<PathBuf> {
sources
.iter()
.find(|(name, _)| name == instance_name)
.map(|(_, path)| PathBuf::from(path))
.filter(|path| path.is_dir())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
fn write_json(version_dir: &Path, file_stem: &str) {
std::fs::create_dir_all(version_dir).unwrap();
std::fs::write(
version_dir.join(format!("{file_stem}.json")),
serde_json::to_vec_pretty(&json!({
"id": file_stem,
"mainClass": "net.minecraft.client.main.Main",
"type": "release"
}))
.unwrap(),
)
.unwrap();
}
#[tokio::test]
async fn resolves_normal_versions_layout() {
let root = TempDir::new().unwrap();
let version_dir = root.path().join("versions/1.20.1");
write_json(&version_dir, "1.20.1");
let resolved = resolve_direct_link(
ImportLauncherType::Generic,
root.path().to_path_buf(),
"versions/1.20.1".to_string(),
None,
)
.await
.unwrap();
assert_eq!(resolved.launcher, ImportLauncherType::Generic);
assert_eq!(
resolved.dot_minecraft,
crate::util::io::canonicalize(root.path()).unwrap()
);
assert_eq!(resolved.version_id, "1.20.1");
assert_eq!(
resolved.version_dir,
crate::util::io::canonicalize(&version_dir).unwrap()
);
}
#[test]
fn identifies_hmcl_only_when_it_owns_the_game_directory() {
let root = TempDir::new().unwrap();
let game_dir = root.path().join("game");
std::fs::create_dir_all(game_dir.join("versions")).unwrap();
let hmcl_dir = root.path().join(".hmcl");
std::fs::create_dir_all(&hmcl_dir).unwrap();
std::fs::write(
hmcl_dir.join("hmcl.json"),
serde_json::to_vec(&json!({
"configurations": {
"default": { "gameDir": game_dir.to_string_lossy() }
}
}))
.unwrap(),
)
.unwrap();
let source = detect_direct_link_source(
&game_dir,
&game_dir.join("versions").join("demo"),
);
assert_eq!(source.launcher, ImportLauncherType::HMCL);
assert_eq!(source.launcher_root, root.path());
}
#[tokio::test]
async fn resolves_compatible_mode_from_game_dir_and_version_path() {
let root = TempDir::new().unwrap();
let version_dir = root.path().join("versions/1.12.2-forge");
write_json(&version_dir, "1.12.2-forge");
let resolved = resolve_direct_link(
ImportLauncherType::PCL2,
root.path().to_path_buf(),
"Friendly PCL Name".to_string(),
Some(version_dir.to_string_lossy().to_string()),
)
.await
.unwrap();
assert_eq!(resolved.launcher_key(), "pcl2");
assert_eq!(
resolved.dot_minecraft,
crate::util::io::canonicalize(root.path()).unwrap()
);
assert_eq!(resolved.version_id, "1.12.2-forge");
}
#[tokio::test]
async fn unique_json_fallback_uses_actual_stem_not_display_name() {
let root = TempDir::new().unwrap();
let version_dir = root.path().join("versions/ui-folder");
std::fs::create_dir_all(&version_dir).unwrap();
std::fs::write(
version_dir.join("actual-version-id.json"),
serde_json::to_vec_pretty(&json!({
"id": "actual-version-id",
"clientVersion": "1.20.1",
"mainClass": "net.minecraft.client.main.Main",
"type": "release"
}))
.unwrap(),
)
.unwrap();
let resolved = resolve_direct_link(
ImportLauncherType::Generic,
root.path().to_path_buf(),
"versions/ui-folder".to_string(),
None,
)
.await
.unwrap();
assert_eq!(resolved.version_id, "actual-version-id");
assert!(resolved.version_json.ends_with("actual-version-id.json"));
}
#[test]
fn ignores_runtime_json_when_finding_a_copied_pcl_instance() {
let root = TempDir::new().unwrap();
let version_dir =
root.path().join("versions").join("1.19.2 - 64bit - copy");
write_json(&version_dir, "1.19.2 - 64bit");
std::fs::write(
version_dir.join("usercache.json"),
r#"[{"name":"player","uuid":"example"}]"#,
)
.unwrap();
assert!(has_minecraft_version_manifest(&version_dir));
assert_eq!(
discover_version_json(&version_dir).unwrap(),
version_dir.join("1.19.2 - 64bit.json")
);
}
#[test]
fn rejects_a_pcl_bookkeeping_directory_without_a_version_manifest() {
let root = TempDir::new().unwrap();
let version_dir = root.path().join("versions").join("Sodium Plus");
std::fs::create_dir_all(version_dir.join("PCL")).unwrap();
std::fs::write(
version_dir.join("PCL").join("config.v1.yml"),
"VersionVanilla: 1.21.1\n",
)
.unwrap();
assert!(!has_minecraft_version_manifest(&version_dir));
}
#[tokio::test]
async fn rejects_temporary_launcher_import_directory() {
let error = resolve_direct_link(
ImportLauncherType::Generic,
std::env::temp_dir().join(TEMP_IMPORT_DIR).join("extracted"),
"versions/1.20.1".to_string(),
None,
)
.await
.unwrap_err();
assert!(error.to_string().contains("temporary"));
}
#[tokio::test]
async fn rejects_unsupported_launcher_before_touching_paths() {
let error = resolve_direct_link(
ImportLauncherType::MultiMC,
PathBuf::from("missing"),
"instance".to_string(),
None,
)
.await
.unwrap_err();
assert!(error.to_string().contains("does not support"));
}
#[test]
fn maps_pcl_and_hmcl_to_distinct_persistent_dialects() {
assert_eq!(launcher_key(ImportLauncherType::HMCL), Some("hmcl"));
assert_eq!(launcher_key(ImportLauncherType::PCL2), Some("pcl2"));
assert_eq!(launcher_key(ImportLauncherType::PCL2CE), Some("pcl2_ce"));
}
#[test]
fn groups_direct_links_by_their_complete_path() {
assert_eq!(
direct_link_group(&Path::new("A").join(".minecraft")),
Some(
Path::new("A")
.join(".minecraft")
.to_string_lossy()
.to_string()
)
);
assert_eq!(
direct_link_group(Path::new(".minecraft")),
Some(".minecraft".to_string())
);
}
#[test]
fn unknown_recovers_pcl_ce_dialect_from_selected_source_path() {
let root = TempDir::new().unwrap();
let pcl_root = root.path().join("pcl");
let pcl_ce_root = root.path().join("pcl-ce");
let selected = pcl_ce_root.join("versions/1.21.1");
std::fs::create_dir_all(&pcl_root).unwrap();
std::fs::create_dir_all(&selected).unwrap();
let pcl_sources = vec![(
"Legacy".to_string(),
pcl_root.to_string_lossy().to_string(),
)];
let pcl_ce_sources = vec![(
"Community".to_string(),
pcl_ce_root.to_string_lossy().to_string(),
)];
assert_eq!(
pcl_dialect_from_sources(
"1.21.1",
Some(selected.to_string_lossy().as_ref()),
&pcl_sources,
&pcl_ce_sources,
),
ImportLauncherType::PCL2CE
);
}
#[tokio::test]
async fn unknown_prefers_detected_hmcl_over_generic() {
let root = TempDir::new().unwrap();
let game_dir = root.path().join("game");
let version_dir = game_dir.join("versions/1.20.4");
write_json(&version_dir, "1.20.4");
std::fs::create_dir_all(root.path().join(".hmcl")).unwrap();
std::fs::write(
root.path().join(".hmcl/hmcl.json"),
serde_json::to_vec_pretty(&json!({
"configurations": {
"HMCL Profile": { "gameDir": game_dir }
}
}))
.unwrap(),
)
.unwrap();
let resolved = resolve_direct_link(
ImportLauncherType::Unknown,
root.path().to_path_buf(),
"HMCL Profile:versions/1.20.4".to_string(),
None,
)
.await
.unwrap();
assert_eq!(resolved.launcher, ImportLauncherType::HMCL);
assert_eq!(resolved.launcher_key(), "hmcl");
}
#[tokio::test]
async fn unknown_does_not_relabel_unmatched_generic_as_hmcl() {
let root = TempDir::new().unwrap();
let hmcl_game = root.path().join("hmcl-game");
write_json(&hmcl_game.join("versions/hmcl"), "1.20.1");
std::fs::create_dir_all(root.path().join(".hmcl")).unwrap();
std::fs::write(
root.path().join(".hmcl/hmcl.json"),
serde_json::to_vec_pretty(&json!({
"configurations": {
"HMCL Profile": { "gameDir": hmcl_game }
}
}))
.unwrap(),
)
.unwrap();
let generic_version = root.path().join("versions/generic");
write_json(&generic_version, "1.20.4");
let resolved = resolve_direct_link(
ImportLauncherType::Unknown,
root.path().to_path_buf(),
// Deliberately collide with the HMCL candidate's display identity;
// the explicitly selected path must take precedence.
"HMCL Profile:versions/hmcl".to_string(),
Some(generic_version.to_string_lossy().to_string()),
)
.await
.unwrap();
assert_eq!(resolved.launcher, ImportLauncherType::Generic);
assert_eq!(resolved.launcher_key(), "generic");
}
}

View File

@ -0,0 +1,130 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
prelude::ModLoader,
state::{AppliedContentSetPatch, EditInstance, InstanceInstallStage},
util::io,
};
use super::{finish_import, recache_icon};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GDLauncherConfig {
pub background: Option<String>,
pub loader: GDLauncherLoader,
// pub mods: Vec<GDLauncherMod>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GDLauncherLoader {
pub loader_type: ModLoader,
pub loader_version: Option<String>,
pub mc_version: String,
pub source: Option<String>,
pub source_name: Option<String>,
}
// Check if folder has a config.json that parses
pub async fn is_valid_gdlauncher(instance_folder: PathBuf) -> bool {
let config = serde_json::from_str::<GDLauncherConfig>(
&io::read_any_encoding_to_string(&instance_folder.join("config.json"))
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
);
config.is_ok()
}
pub async fn import_gdlauncher(
gdlauncher_instance_folder: PathBuf, // instance's folder
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
// Load config.json
let config = serde_json::from_str::<GDLauncherConfig>(
&io::read_any_encoding_to_string(
&gdlauncher_instance_folder.join("config.json"),
)
.await
.unwrap_or(("".into(), encoding_rs::UTF_8))
.0,
)?;
let override_title = config.loader.source_name;
let backup_name = format!(
"GDLauncher-{}",
gdlauncher_instance_folder
.file_name()
.map_or("Unknown".to_string(), |a| a.to_string_lossy().to_string())
);
// Re-cache icon
let icon = config
.background
.clone()
.map(|b| gdlauncher_instance_folder.join(b));
let icon = if let Some(icon) = icon {
recache_icon(icon).await?
} else {
None
};
let game_version = config.loader.mc_version;
let mod_loader = config.loader.loader_type;
let loader_version = config.loader.loader_version;
let loader_version = if mod_loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&game_version,
mod_loader,
loader_version.as_deref(),
)
.await?
} else {
None
};
crate::api::instance::edit(
instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: Some(
icon.clone().map(|x| x.to_string_lossy().to_string()),
),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version.clone().map(|x| x.id)),
}),
..EditInstance::default()
},
)
.await?;
// Copy in contained folders as overrides
let state = State::get().await?;
finish_import(
instance_id,
gdlauncher_instance_folder,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await?;
Ok(())
}

View File

@ -0,0 +1,465 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use super::{ImportOverrides, instance_json};
use crate::{
State,
install::{InstallPhaseDetails, InstallProgressReporter},
launcher::get_loader_version_from_profile,
pack::{
import::finish_import,
install_from::{self, CreatePackDescription, PackDependency},
},
state::ModLoader,
};
/// Import a generic launcher instance folder into an Axolotl profile.
///
/// Runs in four stages: resolve the source folder, validate that it contains
/// a detectable Minecraft version, register the instance metadata, then copy
/// (or symlink) the files into the profile.
pub async fn import_generic(
instance_folder: PathBuf,
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
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
.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)
};
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
}
/// Stage 1 — resolve the name and the `.minecraft` directory of an imported
/// instance folder. Falls back to the folder itself when there is no nested
/// `.minecraft` subdirectory.
pub(crate) fn resolve_dotminecraft(
instance_folder: &Path,
) -> (String, PathBuf) {
let name = instance_folder
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "imported".to_string());
let dotminecraft = instance_folder.join(".minecraft");
if dotminecraft.is_dir() {
tracing::debug!(
"import_generic: using .minecraft subdir at {}",
dotminecraft.display()
);
(name, dotminecraft)
} else {
tracing::debug!(
"import_generic: using folder directly at {}",
instance_folder.display()
);
(name, instance_folder.to_path_buf())
}
}
/// Stage 2 — validate the folder contains a Minecraft version JSON.
async fn detect_instance_info(
dotminecraft: &Path,
overrides: &ImportOverrides,
) -> crate::Result<instance_json::InstanceInfo> {
tracing::debug!(
"import_generic: about to detect instance_json at dotminecraft={}",
dotminecraft.display()
);
let Some(mut info) = instance_json::detect(dotminecraft) else {
let Some(game_version) = overrides
.game_version
.as_ref()
.filter(|version| !version.trim().is_empty())
else {
tracing::warn!(
"import_generic: instance_json::detect returned None for {}",
dotminecraft.display()
);
return Err(crate::ErrorKind::InputError(
"Could not detect Minecraft version. Make sure the folder contains a valid version JSON."
.into(),
)
.into());
};
return Ok(instance_json::InstanceInfo {
vanilla_name: game_version.clone(),
loader: overrides
.loader
.filter(|loader| *loader != ModLoader::Vanilla)
.map(|loader| loader.as_str().to_string()),
loader_version: overrides
.loader_version
.clone()
.filter(|version| !version.is_empty() && version != "latest"),
adjuncts: Vec::new(),
});
};
if let Some(game_version) = overrides
.game_version
.as_ref()
.filter(|version| !version.trim().is_empty())
{
info.vanilla_name.clone_from(game_version);
}
if let Some(loader) = overrides.loader {
if loader == ModLoader::Vanilla {
info.loader = None;
info.loader_version = None;
info.adjuncts.clear();
} else {
info.loader = Some(loader.as_str().to_string());
info.loader_version = None;
}
}
if let Some(loader_version) = overrides
.loader_version
.as_ref()
.filter(|version| !version.is_empty() && *version != "latest")
{
info.loader_version = Some(loader_version.clone());
}
Ok(info)
}
/// Stage 3 — register the instance metadata (name, game version, loaders)
/// with the app database.
async fn register_instance(
instance_id: &str,
name: &str,
info: &instance_json::InstanceInfo,
) -> crate::Result<()> {
tracing::debug!(
"import_generic: detect result: vanilla_name={} loader={:?} loader_version={:?}",
info.vanilla_name,
info.loader,
info.loader_version
);
let description = CreatePackDescription {
icon: None,
override_title: Some(name.to_string()),
project_id: None,
version_id: None,
instance_id: instance_id.to_string(),
source_filename: None,
};
let dependencies = build_dependencies(info).await?;
tracing::debug!(
"import_generic: setting instance info with dependencies={:?}",
dependencies
);
install_from::set_instance_information(
instance_id.to_string(),
&description,
"Imported from folder",
None,
&dependencies,
false,
)
.await?;
Ok(())
}
/// Builds the dependency map from the detected game version and loader,
/// resolving the loader version from the metadata API when it is missing.
async fn build_dependencies(
info: &instance_json::InstanceInfo,
) -> crate::Result<HashMap<PackDependency, String>> {
let mut dependencies =
HashMap::from([(PackDependency::Minecraft, info.vanilla_name.clone())]);
let Some(ref loader) = info.loader else {
tracing::debug!("import_generic: no loader detected, will be Vanilla");
return Ok(dependencies);
};
let components = std::iter::once((
loader.as_str(),
info.loader_version.as_ref(),
))
.chain(info.adjuncts.iter().filter_map(|(loader, version)| {
(loader != "optifabric").then_some((loader.as_str(), version.as_ref()))
}));
for (loader, version) in components {
if loader.eq_ignore_ascii_case("labymod") {
return Err(crate::ErrorKind::InputError(
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
.to_string(),
)
.into());
}
let dep = loader_dependency(loader).ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unsupported loader {loader}: the instance was not imported as Vanilla"
))
.as_error()
})?;
let loader_version = resolve_loader_version(
&info.vanilla_name,
loader,
version.map(String::as_str),
)
.await;
match loader_version {
Some(version) => {
dependencies.insert(dep, version);
}
None => {
return Err(crate::ErrorKind::InputError(format!(
"Could not resolve {loader} for Minecraft {}; the instance was not imported as Vanilla",
info.vanilla_name
))
.into());
}
}
}
if let Some((_, version)) = info
.adjuncts
.iter()
.find(|(loader, _)| loader == "optifabric")
{
let version = version.as_ref().ok_or_else(|| {
crate::ErrorKind::InputError(
"Imported OptiFabric component is missing its version"
.to_string(),
)
})?;
dependencies.insert(PackDependency::OptiFabric, version.clone());
}
Ok(dependencies)
}
/// Maps a detected loader name to a dependency the launcher can install.
fn loader_dependency(loader: &str) -> Option<PackDependency> {
match loader {
"forge" => Some(PackDependency::Forge),
"neoforge" => Some(PackDependency::NeoForge),
"fabric" => Some(PackDependency::FabricLoader),
"quilt" => Some(PackDependency::QuiltLoader),
"optifine" => Some(PackDependency::OptiFine),
"cleanroom" => Some(PackDependency::Cleanroom),
"lite_loader" | "liteloader" => Some(PackDependency::LiteLoader),
"legacy_fabric" | "legacyfabric" => Some(PackDependency::LegacyFabric),
_ => None,
}
}
/// Resolves a missing loader version by asking the metadata API for the
/// latest version compatible with the detected game version.
async fn resolve_loader_version(
game_version: &str,
loader: &str,
requested_version: Option<&str>,
) -> Option<String> {
if requested_version
.is_some_and(|version| !version.is_empty() && version != "latest")
{
return requested_version.map(str::to_string);
}
let mod_loader = match loader {
"forge" => Some(ModLoader::Forge),
"neoforge" => Some(ModLoader::NeoForge),
"fabric" => Some(ModLoader::Fabric),
"quilt" => Some(ModLoader::Quilt),
"optifine" => Some(ModLoader::OptiFine),
"cleanroom" => Some(ModLoader::Cleanroom),
"lite_loader" | "liteloader" => Some(ModLoader::LiteLoader),
"legacy_fabric" | "legacyfabric" => Some(ModLoader::LegacyFabric),
_ => None,
}?;
tracing::debug!(
"import_generic: loader={} has no version, resolving latest for game_version={}",
loader,
game_version
);
match get_loader_version_from_profile(game_version, mod_loader, None).await
{
Ok(Some(lv)) => {
tracing::debug!(
"import_generic: resolved latest loader version: {}",
lv.id
);
Some(lv.id)
}
Ok(None) => {
tracing::warn!(
"import_generic: no loader version found for {} {}",
mod_loader.as_str(),
game_version
);
None
}
Err(e) => {
tracing::warn!(
"import_generic: failed to resolve loader version: {e}",
);
None
}
}
}
/// Stage 4 — copy (or symlink) the source files into the instance profile.
async fn copy_instance_files(
instance_id: &str,
dotminecraft: &Path,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let state = State::get().await?;
tracing::debug!(
"import_generic: finishing import for instance_id={}",
instance_id
);
finish_import(
instance_id,
dotminecraft.to_path_buf(),
&state.io_semaphore,
reporter,
details,
symlink,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loader_dependency_maps_supported_loaders() {
assert_eq!(loader_dependency("forge"), Some(PackDependency::Forge));
assert_eq!(
loader_dependency("neoforge"),
Some(PackDependency::NeoForge)
);
assert_eq!(
loader_dependency("fabric"),
Some(PackDependency::FabricLoader)
);
assert_eq!(
loader_dependency("quilt"),
Some(PackDependency::QuiltLoader)
);
assert_eq!(
loader_dependency("optifine"),
Some(PackDependency::OptiFine)
);
assert_eq!(
loader_dependency("cleanroom"),
Some(PackDependency::Cleanroom)
);
assert_eq!(
loader_dependency("lite_loader"),
Some(PackDependency::LiteLoader)
);
assert_eq!(
loader_dependency("legacy_fabric"),
Some(PackDependency::LegacyFabric)
);
}
#[test]
fn loader_dependency_rejects_unsupported_loaders() {
for loader in ["labymod", "unknown", "vanilla"] {
assert_eq!(loader_dependency(loader), None, "{loader}");
}
}
#[tokio::test]
async fn detect_overrides_replace_missing_detection() {
let directory = tempfile::tempdir().unwrap();
let overrides = ImportOverrides {
game_version: Some("1.20.1".to_string()),
loader: Some(ModLoader::Fabric),
loader_version: Some("0.15.11".to_string()),
};
let info = detect_instance_info(directory.path(), &overrides)
.await
.unwrap();
assert_eq!(info.vanilla_name, "1.20.1");
assert_eq!(info.loader.as_deref(), Some("fabric"));
assert_eq!(info.loader_version.as_deref(), Some("0.15.11"));
}
#[tokio::test]
async fn detect_overrides_ignore_blank_and_latest_loader_version() {
let directory = tempfile::tempdir().unwrap();
for loader_version in ["", "latest"] {
let overrides = ImportOverrides {
game_version: Some("1.20.1".to_string()),
loader: Some(ModLoader::Fabric),
loader_version: Some(loader_version.to_string()),
};
let info = detect_instance_info(directory.path(), &overrides)
.await
.unwrap();
assert_eq!(info.loader_version, None);
}
}
#[tokio::test]
async fn build_dependencies_uses_override_values() {
let info = instance_json::InstanceInfo {
vanilla_name: "1.20.1".to_string(),
loader: Some("fabric".to_string()),
loader_version: Some("0.15.11".to_string()),
adjuncts: Vec::new(),
};
let dependencies = build_dependencies(&info).await.unwrap();
assert_eq!(
dependencies.get(&PackDependency::Minecraft),
Some(&"1.20.1".to_string())
);
assert_eq!(
dependencies.get(&PackDependency::FabricLoader),
Some(&"0.15.11".to_string())
);
}
#[tokio::test]
async fn labymod_and_unknown_loaders_never_become_vanilla() {
for loader in ["labymod", "unknown_loader"] {
let info = instance_json::InstanceInfo {
vanilla_name: "1.20.1".to_string(),
loader: Some(loader.to_string()),
loader_version: Some("1.0".to_string()),
adjuncts: Vec::new(),
};
let error = build_dependencies(&info).await.unwrap_err();
assert!(error.to_string().contains("Unsupported loader"));
}
}
}

View File

@ -0,0 +1,116 @@
use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct HmclConfig {
configurations: HashMap<String, HmclConfiguration>,
}
#[derive(Debug, Deserialize)]
struct HmclConfiguration {
#[serde(rename = "gameDir")]
game_dir: String,
}
fn find_config(base_path: &Path) -> Option<std::path::PathBuf> {
let path = base_path.join(".hmcl").join("hmcl.json");
if path.exists() {
return Some(path);
}
None
}
pub fn config_exists(base_path: &Path) -> bool {
find_config(base_path).is_some()
}
pub fn get_instances(base_path: &Path) -> Vec<(String, String)> {
let Some(config_path) = find_config(base_path) else {
return Vec::new();
};
let Ok(content) = std::fs::read_to_string(&config_path) else {
tracing::warn!(
"hmcl: failed to read config at {}",
config_path.display()
);
return Vec::new();
};
let config: HmclConfig = match serde_json::from_str(&content) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
"hmcl: failed to parse config at {}: {e}",
config_path.display()
);
return Vec::new();
}
};
let mut instances = Vec::new();
for (key, entry) in &config.configurations {
let game_dir = PathBuf::from(&entry.game_dir);
let resolved = if game_dir.is_absolute() {
game_dir
} else {
base_path.join(&game_dir)
};
if resolved.is_dir() {
instances
.push((key.clone(), resolved.to_string_lossy().to_string()));
}
}
instances.sort_by(|a, b| a.0.cmp(&b.0));
instances
}
pub fn get_instance_path(
base_path: &Path,
instance_key: &str,
) -> Option<String> {
// Reuse get_instances() to avoid parsing the config file twice.
get_instances(base_path)
.into_iter()
.find(|(key, _)| key == instance_key)
.map(|(_, path)| path)
}
/// Returns the configured HMCL game directory when it explicitly owns either
/// the shared `.minecraft` root or this version directory. An explicit entry
/// wins over content-folder heuristics, which cannot distinguish a newly
/// created isolated instance from a shared one.
pub fn configured_game_dir(
base_path: &Path,
dot_minecraft: &Path,
version_dir: &Path,
) -> Option<PathBuf> {
let game_dirs = get_instances(base_path)
.into_iter()
.map(|(_, game_dir)| PathBuf::from(game_dir))
.collect::<Vec<_>>();
game_dirs
.iter()
.find(|game_dir| paths_match(game_dir, version_dir))
.cloned()
.or_else(|| {
game_dirs
.iter()
.find(|game_dir| paths_match(game_dir, dot_minecraft))
.cloned()
})
}
fn paths_match(left: &Path, right: &Path) -> bool {
match (
crate::util::io::canonicalize(left),
crate::util::io::canonicalize(right),
) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}

View File

@ -0,0 +1,89 @@
//! HMCL data directory discovery.
//!
//! HMCL stores its configuration (`launcher-settings.json`) in a data directory
//! that depends on how the launcher was installed. This module finds that
//! directory's root (the `.hmcl` folder itself, not its `config` subfolder)
//! with a threepriority strategy:
//!
//! 1. Portable mode — `{launcher_dir}/.hmcl/config/launcher-settings.json`
//! 2. System install — platformspecific application data directory
//! 3. Environment variable — `$HMCL_DATA_DIR`
use std::path::{Path, PathBuf};
/// Try to locate the HMCL data directory root by probing the three priority
/// levels in order. Returns the first root that contains
/// `config/launcher-settings.json`, or `None` if none does.
pub fn find_hmcl_data_dir(launcher_dir: &Path) -> Option<PathBuf> {
// 1. Portable mode — sidecar `.hmcl` folder next to the launcher jar
let portable = launcher_dir.join(".hmcl");
if portable.join("config/launcher-settings.json").exists() {
return Some(portable);
}
// 2. System install — standard platform data directory
if let Some(system_dir) = system_data_dir()
&& system_dir.join("config/launcher-settings.json").exists()
{
return Some(system_dir);
}
// 3. Environment variable override
if let Ok(env_dir) = std::env::var("HMCL_DATA_DIR") {
let env_path = PathBuf::from(env_dir);
if env_path.join("config/launcher-settings.json").exists() {
return Some(env_path);
}
}
None
}
/// Return the platformspecific HMCL data directory root for a system
/// install.
fn system_data_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
dirs::data_dir().map(|d| d.join(".hmcl"))
}
#[cfg(target_os = "macos")]
{
dirs::data_dir().map(|d| d.join("hmcl"))
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
dirs::data_dir().map(|d| d.join("hmcl"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_system_data_dir_is_some() {
// On any real OS this should return a path (it may or may not exist).
assert!(system_data_dir().is_some());
}
#[test]
fn test_find_hmcl_data_dir_returns_none_for_bogus_path() {
let bogus = Path::new("/tmp/this-does-not-exist-12345");
assert!(find_hmcl_data_dir(bogus).is_none());
}
#[test]
fn test_portable_mode_returns_hmcl_root() {
let dir = tempdir().expect("temp dir");
let config = dir.path().join(".hmcl/config");
std::fs::create_dir_all(&config).expect("create config dir");
std::fs::write(config.join("launcher-settings.json"), "{}")
.expect("write settings");
assert_eq!(
find_hmcl_data_dir(dir.path()),
Some(dir.path().join(".hmcl"))
);
}
}

View File

@ -0,0 +1,840 @@
use std::path::Path;
use serde_json::Value;
use tracing::debug;
pub struct InstanceInfo {
pub vanilla_name: String,
pub loader: Option<String>,
pub loader_version: Option<String>,
pub adjuncts: Vec<(String, Option<String>)>,
}
fn find_json(path: &Path) -> Option<(String, String)> {
let name = path.file_name()?.to_string_lossy().to_string();
let primary = path.join(format!("{name}.json"));
debug!(
"instance_json: path={} looking for primary={}",
path.display(),
primary.display()
);
if primary.exists() {
debug!(
"instance_json: path={} json={} (by name match)",
path.display(),
primary.display()
);
let content = std::fs::read_to_string(&primary).ok()?;
debug!(
"instance_json: path={} primary content (len={}, first_200={:?})",
path.display(),
content.len(),
&content[..content.len().min(200)]
);
return Some((name, content));
}
debug!(
"instance_json: path={} primary={} NOT FOUND, enumerating directory",
path.display(),
primary.display()
);
let mut json_files = Vec::new();
if let Ok(dir) = std::fs::read_dir(path) {
for entry in dir.flatten() {
let p = entry.path();
debug!(
"instance_json: path={} entry={}",
path.display(),
p.display()
);
if p.extension().map(|e| e == "json").unwrap_or(false) {
json_files.push(p);
}
}
}
debug!(
"instance_json: path={} found {} json files",
path.display(),
json_files.len()
);
if json_files.len() == 1 {
debug!(
"instance_json: path={} json={} (sole json fallback)",
path.display(),
json_files[0].display()
);
let content = std::fs::read_to_string(&json_files[0]).ok()?;
debug!(
"instance_json: path={} sole json content (len={}, first_200={:?})",
path.display(),
content.len(),
&content[..content.len().min(200)]
);
let name = json_files[0]
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or(name);
return Some((name, content));
}
// Multiple JSONs: try each one, return the first with a valid version.
// A single unreadable or malformed candidate must not abort the loop.
for jf in &json_files {
let Ok(content) = std::fs::read_to_string(jf) else {
debug!(
"instance_json: path={} json={} unreadable, trying next",
path.display(),
jf.display()
);
continue;
};
debug!(
"instance_json: path={} trying json={} (len={}, first_200={:?})",
path.display(),
jf.display(),
content.len(),
&content[..content.len().min(200)]
);
let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
else {
debug!(
"instance_json: path={} json={} invalid JSON, trying next",
path.display(),
jf.display()
);
continue;
};
let version = extract_version(&json, &content, None);
if !version.is_empty() {
let fname = jf
.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| name.clone());
debug!(
"instance_json: path={} json={} (multiple-json pick, version={})",
path.display(),
jf.display(),
version
);
return Some((fname, content));
}
}
debug!(
"instance_json: path={} multiple={} json files, none yielded a version",
path.display(),
json_files.len()
);
None
}
pub fn detect(path: &Path) -> Option<InstanceInfo> {
let (name, content) = find_json(path)?;
let json: Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
debug!("instance_json: path={} parse_err={}", path.display(), e);
return None;
}
};
let mut vanilla_name = extract_version(&json, &content, Some(&name));
debug!(
"instance_json: path={} extract_version returned {:?}",
path.display(),
vanilla_name
);
if vanilla_name.is_empty() {
debug!(
"instance_json: path={} version empty or Unknown",
path.display()
);
return None;
}
vanilla_name = normalize_version(&vanilla_name);
let loader = detect_loader(&content, &json).map(|(loader, version)| {
let version = version.map(|version| {
normalize_imported_loader_version(&loader, &vanilla_name, &version)
});
(loader, version)
});
let adjuncts = detect_adjuncts(
&content,
loader.as_ref().map(|(loader, _)| loader.as_str()),
)
.into_iter()
.map(|(loader, version)| {
let version = version.map(|version| {
normalize_imported_loader_version(&loader, &vanilla_name, &version)
});
(loader, version)
})
.collect();
debug!(
"instance_json: path={} version={} loader={:?}",
path.display(),
vanilla_name,
loader.as_ref().map(|(t, _)| t.as_str())
);
Some(InstanceInfo {
vanilla_name,
loader: loader.as_ref().map(|(t, _)| t.clone()),
loader_version: loader.and_then(|(_, v)| v),
adjuncts,
})
}
fn normalize_version(raw: &str) -> String {
let mut v = raw.to_string();
if (v.starts_with("20.") || v.starts_with("21.")) && !v.starts_with("1.") {
v = format!("1.{v}");
}
v = v.replace("_unobfuscated", "");
v = v.replace(" Unobfuscated", "");
v.trim().to_string()
}
pub(crate) fn normalize_imported_loader_version(
loader: &str,
game_version: &str,
detected_version: &str,
) -> String {
let detected_version = detected_version.trim();
let without_family = match loader {
"fabric" | "legacy_fabric" => detected_version
.strip_prefix("fabric-loader-")
.or_else(|| detected_version.strip_prefix("fabric-")),
"quilt" => detected_version
.strip_prefix("quilt-loader-")
.or_else(|| detected_version.strip_prefix("quilt-")),
"forge" => detected_version.strip_prefix("forge-"),
"neoforge" => detected_version
.strip_prefix("neoforge-")
.or_else(|| detected_version.strip_prefix("neo-")),
_ => None,
}
.unwrap_or(detected_version);
match loader {
"fabric" | "legacy_fabric" | "quilt" => without_family
.strip_suffix(&format!("-{game_version}"))
.unwrap_or(without_family)
.to_string(),
"forge" | "neoforge" => {
let stripped = without_family
.strip_prefix(&format!("{game_version}-"))
.unwrap_or(without_family);
// Legacy Forge (e.g. 1.7.10) embeds the MC version as a trailing
// suffix: `1.7.10-10.13.4.1614-1.7.10` -> `10.13.4.1614`. Strip it so
// the id matches the metadata manifest. When the suffix is absent
// (modern `1.20.1-47.4.22`), keep the already prefix-stripped
// version rather than reverting to the raw detected value.
stripped
.strip_suffix(&format!("-{game_version}"))
.unwrap_or(stripped)
.to_string()
}
_ => without_family.to_string(),
}
}
fn extract_version(
json: &Value,
json_str: &str,
folder_name: Option<&str>,
) -> String {
// ① PCL download record clientVersion
if let Some(v) = json.get("clientVersion").and_then(|v| v.as_str())
&& !v.is_empty()
{
debug!("extract_version: method=① clientVersion value={}", v);
return v.to_string();
}
// ② HMCL patches[].version (id == "game")
if let Some(patches) = json.get("patches").and_then(|v| v.as_array()) {
for patch in patches {
if patch.get("id").and_then(|v| v.as_str()) == Some("game")
&& let Some(ver) = patch.get("version").and_then(|v| v.as_str())
&& !ver.is_empty()
{
debug!(
"extract_version: method=② patches.game.version value={}",
ver
);
return ver.to_string();
}
}
}
// ③ arguments.game --fml.mcVersion (Forge/NeoForge)
if let Some(args) = json
.get("arguments")
.and_then(|v| v.get("game"))
.and_then(|v| v.as_array())
{
let mut mark = false;
for arg in args {
if mark && let Some(v) = arg.as_str() {
debug!("extract_version: method=③ --fml.mcVersion value={}", v);
return v.to_string();
}
if arg.as_str() == Some("--fml.mcVersion") {
mark = true;
}
}
}
// ④ inheritsFrom (version inheritance) — must come before the `jar`
// field, which is not always a version name.
if let Some(v) = json.get("inheritsFrom").and_then(|v| v.as_str())
&& !v.is_empty()
{
debug!("extract_version: method=④ inheritsFrom value={}", v);
return v.to_string();
}
// ⑤ libraries string regex fallback (Forge/OptiFine/FabricLike lib versions)
// Use the original JSON string (from find_json) instead of re-serializing
// the parsed Value, which would allocate a fresh string unnecessarily.
if let Some(v) = extract_version_from_libraries(json_str) {
debug!("extract_version: method=⑤ libraries value={}", v);
return v;
}
// ⑥ JSON id field → extract leading version
if let Some(id) = json.get("id").and_then(|v| v.as_str())
&& let Some(v) = extract_version_from_id(id)
{
debug!("extract_version: method=⑥ id id={} value={}", id, v);
return v;
}
// ⑦ jar field (legacy versions store the base game in `jar`)
if let Some(v) = json.get("jar").and_then(|v| v.as_str())
&& !v.is_empty()
{
debug!("extract_version: method=⑦ jar value={}", v);
return v.to_string();
}
// ⑧ folder name fallback (renamed / non-standard instances)
if let Some(name) = folder_name
&& let Some(v) = extract_version_from_id(name)
{
debug!("extract_version: method=⑧ folder_name value={}", v);
return v;
}
debug!("extract_version: method=✗ all methods failed");
String::new()
}
/// Extracts Minecraft version from library artifact coordinates in the JSON string.
/// Matches PCLCE's approach scanning for Forge/OptiFine/FabricLike lib entries.
/// Order: NeoForge before Forge (NeoForge JSON often also contains forge references).
fn extract_version_from_libraries(content: &str) -> Option<String> {
// NeoForge: net.neoforged:neoforge:1.20.1-44.0.3 → "1.20.1"
// Try known Maven coordinate formats (neoforge before forge).
for needle in [
"net.neoforged:neoforge:",
"net.neoforged.neoforge:neoforge:",
"net.neoforged.fml:modern:",
] {
if let Some(pos) = content.find(needle) {
let after = &content[pos + needle.len()..];
if let Some(end) = after.find(&['"', ',', '\n', '}'] as &[char]) {
let ver = &after[..end];
if let Some(dash) = ver.find('-') {
return Some(ver[..dash].to_string());
}
return Some(ver.to_string());
}
}
}
// Forge: minecraftforge:forge:1.8.9-11.15.1.1722 → "1.8.9"
// net.minecraftforge:forge:1.21.1-52.0.0 (modern Forge, 1.13+)
for needle in ["minecraftforge:forge:", "net.minecraftforge:forge:"] {
if let Some(pos) = content.find(needle) {
let after = &content[pos + needle.len()..];
if let Some(end) = after.find(&['"', ',', '\n', '}'] as &[char]) {
let ver = &after[..end];
if let Some(dash) = ver.find('-') {
return Some(ver[..dash].to_string());
}
return Some(ver.to_string());
}
}
}
// OptiFine: optifine:OptiFine:1.8.9_HD_U_H5 → "1.8.9"
if let Some(pos) = content.find("optifine:OptiFine:") {
let after = &content[pos + "optifine:OptiFine:".len()..];
if let Some(end) = after.find(&['"', ',', '\n', '}'] as &[char]) {
let ver = &after[..end];
if let Some(underscore) = ver.find('_') {
return Some(ver[..underscore].to_string());
}
return Some(ver.to_string());
}
}
// Fabric-like: net.fabricmc:fabric-loader:0.15.11-1.20.1 → "1.20.1"
if let Some(pos) = content.find("net.fabricmc:fabric-loader:") {
let after = &content[pos + "net.fabricmc:fabric-loader:".len()..];
if let Some(end) = after.find(&['"', ',', '\n', '}'] as &[char]) {
let ver = &after[..end];
if let Some(dash) = ver.rfind('-') {
return Some(ver[dash + 1..].to_string());
}
}
}
None
}
/// Extracts leading version number from the instance id.
/// e.g. "1.8.9-forge-11.15.1.1722" → "1.8.9"
/// Skips hash-like ids (≥32 chars, no separators).
fn extract_version_from_id(id: &str) -> Option<String> {
let ver = id.trim();
if ver.is_empty() {
return None;
}
if ver.len() >= 32
&& !ver.contains('.')
&& !ver.contains('-')
&& !ver.contains('_')
{
return None;
}
if let Some(first_sep) = ver.find(['-', '_', ' ']) {
let candidate = &ver[..first_sep];
if candidate.starts_with("1.") || candidate.starts_with('2') {
return Some(candidate.to_string());
}
}
if ver.starts_with("1.") || ver.starts_with('2') {
return Some(ver.to_string());
}
None
}
///参考自PCL启动器
fn detect_loader(
content: &str,
json: &Value,
) -> Option<(String, Option<String>)> {
let lower = content.to_lowercase();
// LabyMod
if lower.contains("labymod_data") {
let version = json
.get("labymod_data")
.and_then(|v| v.get("version"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
return Some(("labymod".into(), version));
}
// Legacy Fabric
if lower.contains("net.legacyfabric:intermediary") {
let version = try_extract_version_from_needle(
content,
"net.fabricmc:fabric-loader:",
None,
);
return Some(("legacy_fabric".into(), version));
}
// Fabric
if lower.contains("net.fabricmc:fabric-loader") {
let version = try_extract_version_from_needle(
content,
"net.fabricmc:fabric-loader:",
None,
);
return Some(("fabric".into(), version));
}
// Quilt
if lower.contains("org.quiltmc:quilt-loader") {
let version = try_extract_version_from_needle(
content,
"org.quiltmc:quilt-loader:",
None,
);
return Some(("quilt".into(), version));
}
// Cleanroom
if lower.contains("com.cleanroommc:cleanroom:") {
let version = try_extract_version_from_needle(
content,
"com.cleanroommc:cleanroom:",
None,
);
return Some(("cleanroom".into(), version));
}
// Forge
if lower.contains("minecraftforge") && !lower.contains("net.neoforge") {
let version = try_extract_version_from_needle(
content,
"minecraftforge:forge:",
None,
)
.or_else(|| {
try_extract_version_from_needle(
content,
"net.minecraftforge:forge:",
None,
)
})
.or_else(|| {
try_extract_version_from_needle(
content,
"net.minecraftforge:fmlloader:",
None,
)
});
return Some(("forge".into(), version));
}
// NeoForge
if lower.contains("net.neoforge") {
let version = try_extract_version_from_needle(
content,
"net.neoforged:neoforge:",
None,
)
.or_else(|| {
try_extract_version_from_needle(
content,
"net.neoforged.neoforge:neoforge:",
None,
)
});
return Some(("neoforge".into(), version));
}
// OptiFine
if lower.contains("optifine") {
let version = try_extract_version_from_needle(
content,
"optifine:OptiFine:",
None,
);
if version.is_some() {
return Some(("optifine".into(), version));
}
}
// LiteLoader
if lower.contains("liteloader") {
return Some(("lite_loader".into(), None));
}
debug!("detect_loader: no known loader library found in JSON content");
None
}
fn detect_adjuncts(
content: &str,
primary_loader: Option<&str>,
) -> Vec<(String, Option<String>)> {
let lower = content.to_ascii_lowercase();
let mut adjuncts = Vec::new();
if primary_loader != Some("lite_loader") && lower.contains("liteloader") {
adjuncts.push(("lite_loader".to_string(), None));
}
if primary_loader != Some("optifine") && lower.contains("optifine") {
let version = try_extract_version_from_needle(
content,
"optifine:OptiFine:",
None,
)
.or_else(|| {
try_extract_version_from_needle(&lower, "optifine:optifine:", None)
});
if version.is_some() {
adjuncts.push(("optifine".to_string(), version));
}
}
if lower.contains("optifabric") {
let version = try_extract_version_from_needle(
&lower,
"me.modmuss50:optifabric:",
None,
)
.or_else(|| {
try_extract_version_from_needle(
&lower,
"optifabric:optifabric:",
None,
)
});
adjuncts.push(("optifabric".to_string(), version));
}
adjuncts
}
/// Extracts the loader version string from JSON content by finding a needle
/// and reading until a terminator character.
fn try_extract_version_from_needle(
content: &str,
needle: &str,
split_at: Option<char>,
) -> Option<String> {
let pos = content.find(needle)?;
let after = &content[pos + needle.len()..];
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())
} else {
Some(ver.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn detect_from_json(content: &str) -> Option<(String, Option<String>)> {
let json: Value = serde_json::from_str(content).expect("test JSON");
detect_loader(content, &json)
}
fn assert_loader(
content: &str,
expected: &str,
expected_version: Option<&str>,
) {
let (loader, version) = detect_from_json(content)
.unwrap_or_else(|| panic!("expected loader {expected}, got None"));
assert_eq!(loader, expected);
assert_eq!(version.as_deref(), expected_version);
}
#[test]
fn test_forge() {
assert_loader(
r#"{
"id": "1.21.1-forge-52.0.0",
"libraries": [
{
"name": "net.minecraftforge:forge:1.21.1-52.0.0"
}
]
}"#,
"forge",
Some("1.21.1-52.0.0"),
);
}
#[test]
fn normalizes_imported_loader_versions_for_central_resolution() {
for (loader, game_version, detected, expected) in [
("fabric", "1.20.1", "0.15.11-1.20.1", "0.15.11"),
("quilt", "1.20.1", "0.26.4-1.20.1", "0.26.4"),
(
"forge",
"1.7.10",
"1.7.10-10.13.4.1614-1.7.10",
"10.13.4.1614",
),
("forge", "1.20.1", "1.20.1-47.4.22", "47.4.22"),
("neoforge", "1.20.1", "1.20.1-44.0.3", "44.0.3"),
("neoforge", "1.21.4", "21.4.157", "21.4.157"),
] {
assert_eq!(
normalize_imported_loader_version(
loader,
game_version,
detected
),
expected,
"{loader} {game_version} {detected}"
);
}
}
#[test]
fn test_neoforge() {
assert_loader(
r#"{
"id": "1.20.1-neoforge-44.0.3",
"libraries": [
{
"name": "net.neoforged:neoforge:1.20.1-44.0.3"
}
]
}"#,
"neoforge",
Some("1.20.1-44.0.3"),
);
}
#[test]
fn test_fabric() {
assert_loader(
r#"{
"id": "1.20.1-fabric-0.15.11",
"libraries": [
{
"name": "net.fabricmc:fabric-loader:0.15.11-1.20.1"
}
]
}"#,
"fabric",
Some("0.15.11-1.20.1"),
);
}
#[test]
fn test_quilt() {
assert_loader(
r#"{
"id": "1.20.1-quilt-0.26.4",
"libraries": [
{
"name": "org.quiltmc:quilt-loader:0.26.4-1.20.1"
}
]
}"#,
"quilt",
Some("0.26.4-1.20.1"),
);
}
#[test]
fn test_optifine() {
assert_loader(
r#"{
"id": "1.8.9-OptiFine",
"libraries": [
{
"name": "optifine:OptiFine:1.8.9_HD_U_H5"
}
]
}"#,
"optifine",
Some("1.8.9_HD_U_H5"),
);
}
#[test]
fn detects_forge_with_liteloader_and_optifine_adjuncts() {
let content = r#"{
"id": "1.12.2-forge-combined",
"libraries": [
{ "name": "net.minecraftforge:forge:1.12.2-14.23.5.2860" },
{ "name": "com.mumfrey:liteloader:1.12.2-SNAPSHOT" },
{ "name": "optifine:OptiFine:1.12.2_HD_U_G5" }
]
}"#;
let json: Value = serde_json::from_str(content).unwrap();
let primary = detect_loader(content, &json).unwrap();
let adjuncts = detect_adjuncts(content, Some(&primary.0));
assert_eq!(primary.0, "forge");
assert_eq!(
adjuncts,
vec![
("lite_loader".to_string(), None),
("optifine".to_string(), Some("1.12.2_HD_U_G5".to_string())),
]
);
}
#[test]
fn test_legacy_fabric() {
assert_loader(
r#"{
"id": "1.8.9-legacy-fabric-0.13.1.4",
"libraries": [
{
"name": "net.legacyfabric:intermediary:1.8.9"
},
{
"name": "net.fabricmc:fabric-loader:0.13.1.4-1.8.9"
}
]
}"#,
"legacy_fabric",
Some("0.13.1.4-1.8.9"),
);
}
#[test]
fn test_cleanroom() {
assert_loader(
r#"{
"id": "1.12.2-cleanroom-7.1.0",
"libraries": [
{
"name": "com.cleanroommc:cleanroom:1.12.2-7.1.0"
}
]
}"#,
"cleanroom",
Some("1.12.2-7.1.0"),
);
}
#[test]
fn test_labymod() {
assert_loader(
r#"{
"id": "1.20.1-labymod",
"labymod_data": {
"version": "4.4.20"
}
}"#,
"labymod",
Some("4.4.20"),
);
}
#[test]
fn test_lite_loader() {
assert_loader(
r#"{
"id": "1.12.2-LiteLoader-1.12.2-SNAPSHOT",
"libraries": [
{
"name": "com.mumfrey:liteloader:1.12.2"
}
]
}"#,
"lite_loader",
None,
);
}
#[test]
fn test_no_loader() {
assert!(detect_from_json(r#"{"id": "1.20.4"}"#).is_none());
}
#[test]
fn test_detect_end_to_end() {
let dir = tempfile::tempdir().expect("temp dir");
let instance = dir.path().join(".minecraft");
std::fs::create_dir(&instance).expect("create .minecraft dir");
std::fs::write(
instance.join(".minecraft.json"),
r#"{
"id": "1.20.1-fabric-0.15.11",
"libraries": [
{
"name": "net.fabricmc:fabric-loader:0.15.11-1.20.1"
}
]
}"#,
)
.expect("write instance json");
let info = detect(&instance).expect("detect should succeed");
assert_eq!(info.vanilla_name, "1.20.1");
assert_eq!(info.loader.as_deref(), Some("fabric"));
assert_eq!(info.loader_version.as_deref(), Some("0.15.11"));
}
}

View File

@ -0,0 +1,440 @@
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,
minecraft_folder,
&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());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,254 @@
use std::{collections::HashMap, path::PathBuf};
use sqlx::{
Row,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use crate::{
State,
api::pack::{
import::finish_import,
install_from::{self, CreatePackDescription, PackDependency},
},
install::{InstallPhaseDetails, InstallProgressReporter},
};
async fn open_source_db(
base_path: &PathBuf,
) -> crate::Result<sqlx::SqlitePool> {
let db_path = base_path.join("app.db");
let options = SqliteConnectOptions::new()
.filename(db_path)
.read_only(true)
.create_if_missing(false);
Ok(SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await?)
}
async fn source_config_dir(
base_path: &PathBuf,
pool: &sqlx::SqlitePool,
) -> crate::Result<PathBuf> {
let custom_dir: Option<String> =
sqlx::query_scalar("SELECT custom_dir FROM settings WHERE id = 0")
.fetch_optional(pool)
.await?
.flatten();
Ok(custom_dir.map_or_else(|| base_path.clone(), PathBuf::from))
}
async fn has_table(
pool: &sqlx::SqlitePool,
table: &str,
) -> crate::Result<bool> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
)
.bind(table)
.fetch_one(pool)
.await?;
Ok(count > 0)
}
pub async fn get_importable_instances_with_paths(
base_path: PathBuf,
) -> crate::Result<Vec<(String, PathBuf)>> {
let pool = open_source_db(&base_path).await?;
let config_dir = source_config_dir(&base_path, &pool).await?;
let profiles_dir = config_dir.join("profiles");
let rows = if has_table(&pool, "instances").await? {
sqlx::query("SELECT path FROM instances ORDER BY name COLLATE NOCASE")
.fetch_all(&pool)
.await?
} else {
sqlx::query("SELECT path FROM profiles ORDER BY name COLLATE NOCASE")
.fetch_all(&pool)
.await?
};
Ok(rows
.into_iter()
.filter_map(|row| {
let path = row.try_get::<String, _>("path").ok()?;
let full = profiles_dir.join(&path);
full.is_dir().then_some((path, full))
})
.collect())
}
pub async fn get_importable_instances(
base_path: PathBuf,
) -> crate::Result<Vec<String>> {
get_importable_instances_with_paths(base_path)
.await
.map(|v| v.into_iter().map(|(n, _)| n).collect())
}
fn dependencies(
game_version: String,
loader: String,
loader_version: Option<String>,
) -> crate::Result<HashMap<PackDependency, String>> {
let mut dependencies =
HashMap::from([(PackDependency::Minecraft, game_version)]);
let loader = loader.trim().to_ascii_lowercase();
if loader == "vanilla" || loader.is_empty() {
return Ok(dependencies);
}
if loader == "labymod" || loader.starts_with("labymod-") {
return Err(crate::ErrorKind::InputError(
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
.to_string(),
)
.into());
}
let dependency = match loader.as_str() {
"fabric" => PackDependency::FabricLoader,
"forge" => PackDependency::Forge,
"neoforge" | "neo_forge" => PackDependency::NeoForge,
"quilt" => PackDependency::QuiltLoader,
"optifine" => PackDependency::OptiFine,
"cleanroom" => PackDependency::Cleanroom,
"lite_loader" | "liteloader" => PackDependency::LiteLoader,
"legacy_fabric" | "legacyfabric" => PackDependency::LegacyFabric,
_ => {
return Err(crate::ErrorKind::InputError(format!(
"Unsupported loader {loader}: the instance was not imported as Vanilla"
))
.into());
}
};
let loader_version = loader_version
.filter(|version| !version.trim().is_empty())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Modrinth source instance is missing the {loader} version and was not imported as Vanilla"
))
})?;
dependencies.insert(dependency, loader_version);
Ok(dependencies)
}
pub async fn import_instance(
base_path: PathBuf,
instance_path: String,
instance_id: &str,
reporter: InstallProgressReporter,
details: InstallPhaseDetails,
symlink: bool,
) -> crate::Result<()> {
let pool = open_source_db(&base_path).await?;
let config_dir = source_config_dir(&base_path, &pool).await?;
let source = config_dir.join("profiles").join(&instance_path);
let (name, game_version, loader, loader_version, icon_path) = if has_table(
&pool,
"instances",
)
.await?
{
let row = sqlx::query(
"SELECT i.name, s.game_version, s.loader, s.loader_version, i.icon_path \
FROM instances i JOIN instance_content_sets s \
ON s.id = i.applied_content_set_id WHERE i.path = ?",
)
.bind(&instance_path)
.fetch_one(&pool)
.await?;
(
row.try_get("name")?,
row.try_get("game_version")?,
row.try_get("loader")?,
row.try_get("loader_version")?,
row.try_get::<Option<String>, _>("icon_path")?,
)
} else {
let row = sqlx::query(
"SELECT name, game_version, mod_loader AS loader, \
mod_loader_version AS loader_version, icon_path \
FROM profiles WHERE path = ?",
)
.bind(&instance_path)
.fetch_one(&pool)
.await?;
(
row.try_get("name")?,
row.try_get("game_version")?,
row.try_get("loader")?,
row.try_get("loader_version")?,
row.try_get::<Option<String>, _>("icon_path")?,
)
};
let icon = match icon_path {
Some(path) => super::recache_icon(config_dir.join(path)).await?,
None => None,
};
let description = CreatePackDescription {
icon,
override_title: Some(name),
project_id: None,
version_id: None,
instance_id: instance_id.to_string(),
source_filename: None,
};
install_from::set_instance_information(
instance_id.to_string(),
&description,
"Imported from Modrinth source installation",
None,
&dependencies(game_version, loader, loader_version)?,
false,
)
.await?;
let state = State::get().await?;
finish_import(
instance_id,
source,
&state.io_semaphore,
reporter,
details,
symlink,
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_dependencies_preserve_supported_loaders() {
let dependencies = dependencies(
"1.12.2".to_string(),
"cleanroom".to_string(),
Some("0.6.11-alpha".to_string()),
)
.unwrap();
assert_eq!(
dependencies.get(&PackDependency::Cleanroom),
Some(&"0.6.11-alpha".to_string())
);
}
#[test]
fn source_dependencies_reject_labymod_and_missing_versions() {
for result in [
dependencies(
"1.20.1".to_string(),
"labymod".to_string(),
Some("4.4.20".to_string()),
),
dependencies("1.12.2".to_string(), "lite_loader".to_string(), None),
] {
assert!(result.is_err());
}
}
}

View File

@ -0,0 +1,109 @@
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[cfg(target_os = "windows")]
pub fn read_pcl_registry() -> Option<String> {
use winreg::enums::HKEY_CURRENT_USER;
let hkcu = winreg::RegKey::predef(HKEY_CURRENT_USER);
let key = hkcu.open_subkey("SOFTWARE\\PCL").ok()?;
let value: String = key.get_value("LaunchFolders").ok()?;
tracing::debug!(raw = %value, "read_pcl_registry: read LaunchFolders from HKCU\\SOFTWARE\\PCL");
Some(value)
}
#[cfg(not(target_os = "windows"))]
pub fn read_pcl_registry() -> Option<String> {
None
}
#[derive(Debug, Deserialize)]
struct PclCeConfig {
#[serde(rename = "LaunchFolders")]
launch_folders: Option<String>,
}
fn read_pclce_config() -> Option<String> {
let path = dirs::data_dir()?.join("PCLCE").join("config.v1.json");
tracing::debug!(path = %path.display(), "read_pclce_config: attempting to read config file");
let content = std::fs::read_to_string(&path).inspect_err(|e| {
tracing::debug!(path = %path.display(), error = %e, "read_pclce_config: failed to read file");
}).ok()?;
let config: PclCeConfig = serde_json::from_str(&content).inspect_err(|e| {
tracing::debug!(path = %path.display(), error = %e, "read_pclce_config: failed to parse JSON");
}).ok()?;
let launch_folders = config.launch_folders.as_deref().unwrap_or("");
tracing::debug!(launch_folders = %launch_folders, "read_pclce_config: parsed LaunchFolders");
config.launch_folders
}
fn parse_pcl_folders(raw: &str) -> Vec<(String, String)> {
let mut result = Vec::new();
for entry in raw.split('|') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if let Some((name, path)) = entry.split_once('>') {
let path = PathBuf::from(path.trim());
let exists = path.is_dir();
tracing::debug!(
entry = %entry,
name = %name.trim(),
path = %path.display(),
exists = exists,
"parse_pcl_folders: entry"
);
if exists {
result.push((
name.trim().to_string(),
path.to_string_lossy().to_string(),
));
}
} else {
tracing::debug!(entry = %entry, "parse_pcl_folders: malformed entry (no '>' separator)");
}
}
result.sort_by(|a, b| a.0.cmp(&b.0));
tracing::debug!(count = result.len(), raw = %raw, "parse_pcl_folders: done");
result
}
pub fn config_exists() -> bool {
let exists = read_pclce_config().is_some();
tracing::debug!(exists = exists, "config_exists");
exists
}
pub fn get_pcl_instances() -> Vec<(String, String)> {
let raw = read_pcl_registry().unwrap_or_default();
let instances = parse_pcl_folders(&raw);
tracing::info!(count = instances.len(), "get_pcl_instances");
instances
}
pub fn get_pclce_instances() -> Vec<(String, String)> {
let raw = read_pclce_config().unwrap_or_default();
let instances = parse_pcl_folders(&raw);
tracing::info!(count = instances.len(), "get_pclce_instances");
instances
}
/// Checks if a `.minecraft` folder exists next to the launcher (i.e. at
/// `base_path/.minecraft`) and returns it as a `(name, path)` pair suitable
/// for merging into the GameDir list.
pub fn get_local_dotminecraft(base_path: &Path) -> Option<(String, String)> {
let dot_mc = base_path.join(".minecraft");
if dot_mc.is_dir() {
tracing::debug!(
path = %dot_mc.display(),
"get_local_dotminecraft: found .minecraft next to launcher"
);
Some((
".minecraft".to_string(),
dot_mc.to_string_lossy().to_string(),
))
} else {
None
}
}

View File

@ -0,0 +1,148 @@
#[cfg(target_os = "windows")]
mod imp {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
pub fn get_product_name(path: &Path) -> Option<String> {
use windows::Win32::Storage::FileSystem::{
GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW,
};
use windows::core::PCWSTR;
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut unused = 0u32;
let size = unsafe {
GetFileVersionInfoSizeW(
PCWSTR::from_raw(wide.as_ptr()),
Some(&raw mut unused),
)
};
if size == 0 {
return None;
}
let mut buffer = vec![0u8; size as usize];
let ok = unsafe {
GetFileVersionInfoW(
PCWSTR::from_raw(wide.as_ptr()),
Some(0),
size,
buffer.as_mut_ptr() as *mut _,
)
};
if ok.is_err() {
return None;
}
let sub = OsStr::new("\\VarFileInfo\\Translation")
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<u16>>();
let mut lang_ptr = std::ptr::null_mut::<std::ffi::c_void>();
let mut lang_len = 0u32;
let ok = unsafe {
VerQueryValueW(
buffer.as_ptr() as *const _,
PCWSTR::from_raw(sub.as_ptr()),
&raw mut lang_ptr,
&raw mut lang_len,
)
};
if ok.0 == 0 || lang_len == 0 || lang_ptr.is_null() {
return None;
}
// lang_len from VerQueryValueW(Translation) is in bytes, not characters
let lang_len_u16 = (lang_len / 2) as usize;
if lang_len_u16 < 2 {
return None;
}
let lang = unsafe {
std::slice::from_raw_parts(lang_ptr as *const u16, lang_len_u16)
};
let block = format!(
"\\StringFileInfo\\{:04x}{:04x}\\ProductName",
lang[0], lang[1]
);
let block_wide: Vec<u16> = OsStr::new(&block)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut val_ptr = std::ptr::null_mut::<std::ffi::c_void>();
let mut val_len = 0u32;
let ok = unsafe {
VerQueryValueW(
buffer.as_ptr() as *const _,
PCWSTR::from_raw(block_wide.as_ptr()),
&raw mut val_ptr,
&raw mut val_len,
)
};
if ok.0 == 0 || val_len == 0 || val_ptr.is_null() {
return None;
}
let slice = unsafe {
std::slice::from_raw_parts(
val_ptr as *const u16,
val_len as usize - 1,
)
};
String::from_utf16(slice).ok()
}
pub fn folder_has_product(base_path: &Path, product_name: &str) -> bool {
if !base_path.is_dir() {
return false;
}
let Ok(read_dir) = std::fs::read_dir(base_path) else {
return false;
};
for entry in read_dir.flatten() {
let p = entry.path();
if p.extension().map(|e| e == "exe").unwrap_or(false)
&& let Some(product) = get_product_name(&p)
&& product.contains(product_name)
{
return true;
}
}
false
}
}
#[cfg(not(target_os = "windows"))]
mod imp {
use std::path::Path;
pub fn folder_has_product(_base_path: &Path, _product_name: &str) -> bool {
false
}
}
pub use imp::folder_has_product;
pub fn folder_has_product_result(
path: &std::path::Path,
product_name: &str,
) -> Result<bool, String> {
#[cfg(target_os = "windows")]
{
Ok(folder_has_product(path, product_name))
}
#[cfg(not(target_os = "windows"))]
{
let _ = (path, product_name);
Err("PE detection is only available on Windows".to_string())
}
}

View File

@ -0,0 +1,774 @@
use crate::State;
use crate::api::pack::detect::detect_local_pack_sync;
use crate::data::ModLoader;
use crate::install::{
InstallErrorContext, InstallJobEventKind, InstallPhaseDetails,
InstallPhaseId, InstallProgress, InstallProgressReporter,
};
use crate::state::{
AppliedContentSetPatch, CacheBehaviour, CachedEntry, ContentSourceKind,
EditInstance, InstanceInstallStage, InstanceLink, LoaderComponent,
LoaderComponentKind, LoaderComponentRole, ModrinthProjectId,
ModrinthVersionId, SideType,
};
use crate::util::fetch::{
ContentValidation, DownloadMeta, DownloadReason, DownloadRequest,
FetchProgressFn, Integrity, ResourceClass, download_to_path, fetch,
sha1_file_async, write_cached_icon,
};
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::future::Future;
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
use zip::ZipArchive;
#[derive(Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PackFormat {
pub game: String,
pub format_version: i32,
pub version_id: String,
pub name: String,
pub summary: Option<String>,
pub files: Vec<PackFile>,
pub dependencies: HashMap<PackDependency, String>,
}
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PackFile {
pub path: SafeRelativeUtf8UnixPathBuf,
pub hashes: HashMap<PackFileHash, String>,
pub env: Option<HashMap<EnvType, SideType>>,
pub downloads: Vec<String>,
pub file_size: u32,
}
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "camelCase", from = "String")]
pub enum PackFileHash {
Sha1,
Sha512,
Unknown(String),
}
impl From<String> for PackFileHash {
fn from(s: String) -> Self {
match s.as_str() {
"sha1" => PackFileHash::Sha1,
"sha512" => PackFileHash::Sha512,
_ => PackFileHash::Unknown(s),
}
}
}
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
#[serde(rename_all = "camelCase")]
pub enum EnvType {
Client,
Server,
}
#[derive(Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq, Debug)]
pub enum PackDependency {
#[serde(rename = "forge")]
Forge,
#[serde(rename = "neoforge")]
#[serde(alias = "neo-forge")]
NeoForge,
#[serde(rename = "fabric-loader")]
FabricLoader,
#[serde(rename = "quilt-loader")]
QuiltLoader,
#[serde(rename = "optifine")]
OptiFine,
#[serde(rename = "cleanroom")]
Cleanroom,
#[serde(rename = "lite_loader", alias = "liteloader")]
LiteLoader,
#[serde(rename = "legacy_fabric", alias = "legacyfabric")]
LegacyFabric,
#[serde(rename = "optifabric")]
OptiFabric,
#[serde(rename = "minecraft")]
Minecraft,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum CreatePackLocation {
// Create a pack from a modrinth version ID (such as a modpack)
FromVersionId {
project_id: String,
version_id: String,
title: String,
icon_url: Option<String>,
},
// Create a pack from a file (such as an .mrpack for installing from a file, or a folder name for importing)
FromFile {
path: PathBuf,
},
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePackInstance {
pub name: String, // the name of the instance and relative path
pub game_version: String, // the game version of the instance
pub modloader: ModLoader, // the modloader to use
pub loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader. defaults to latest
pub icon: Option<PathBuf>, // the icon for the instance
pub icon_url: Option<String>, // the URL icon for an instance during import
pub link: Option<InstanceLink>,
pub unknown_file: bool, // true when pack file isn't found on Modrinth via hash lookup
pub skip_install_profile: Option<bool>,
pub no_watch: Option<bool>,
}
// default
impl Default for CreatePackInstance {
fn default() -> Self {
CreatePackInstance {
name: "Untitled".to_string(),
game_version: "1.19.4".to_string(),
modloader: ModLoader::Vanilla,
loader_version: None,
icon: None,
icon_url: None,
link: None,
unknown_file: false,
skip_install_profile: Some(true),
no_watch: Some(false),
}
}
}
#[derive(Clone)]
pub enum CreatePackFile {
Bytes(bytes::Bytes),
// Local packs can be larger than available memory, so keep them file-backed.
Path(PathBuf),
}
#[derive(Clone)]
pub struct CreatePack {
pub file: CreatePackFile,
pub description: CreatePackDescription,
}
// The hash lookup only gates the unknown-pack warning, so avoid a long blocking scan for huge local packs.
const MAX_LOCAL_FILE_HASH_LOOKUP_SIZE: u64 = 1024 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct CreatePackDescription {
pub icon: Option<PathBuf>,
pub override_title: Option<String>,
pub project_id: Option<String>,
pub version_id: Option<String>,
pub instance_id: String,
pub source_filename: Option<String>,
}
pub async fn get_instance_from_pack(
location: CreatePackLocation,
) -> crate::Result<CreatePackInstance> {
match location {
CreatePackLocation::FromVersionId {
project_id,
version_id,
title,
icon_url,
} => Ok(CreatePackInstance {
name: title,
icon_url,
link: Some(InstanceLink::ModrinthModpack {
project_id,
version_id,
}),
..Default::default()
}),
CreatePackLocation::FromFile { path } => {
let file_name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// Validate ZIP structure before proceeding — fail fast on corrupt archives
// rather than discovering the error later during extraction.
let file = std::fs::File::open(&path)
.map_err(crate::ErrorKind::StdIOError)?;
ZipArchive::new(file).map_err(|e| {
crate::ErrorKind::InputError(format!(
"Invalid or corrupt modpack archive ({}): {e}",
path.display()
))
})?;
// Scan ZIP entry names to detect pack format (no extraction, just
// reads the central directory). This tells us what kind of content
// we're dealing with before any expensive operations.
let _has_known_manifest = detect_local_pack_sync(&path).is_ok();
let is_known_file = if tokio::fs::metadata(&path).await?.len()
<= MAX_LOCAL_FILE_HASH_LOOKUP_SIZE
{
let state = State::get().await?;
let (_, hash) = sha1_file_async(&path).await?;
match CachedEntry::get_file_many(
&[&hash],
Some(CacheBehaviour::StaleWhileRevalidateSkipOffline),
&state.pool,
&state.api_semaphore,
)
.await
{
Ok(files) => !files.is_empty(),
Err(err) => {
tracing::warn!(
"Failed to check Modrinth file hash for {}: {}",
path.display(),
err
);
false
}
}
} else {
false
};
Ok(CreatePackInstance {
name: file_name,
unknown_file: !is_known_file,
..Default::default()
})
}
}
}
#[tracing::instrument(skip(reporter))]
#[allow(clippy::too_many_arguments)]
pub(crate) async fn generate_pack_from_version_id_with_reporter(
project_id: String,
version_id: String,
title: String,
icon_url: Option<String>,
instance_id: String,
reason: DownloadReason,
reporter: InstallProgressReporter,
) -> crate::Result<CreatePack> {
let state = State::get().await?;
let has_icon_url = icon_url.is_some();
let version = CachedEntry::get_version(
&ModrinthVersionId::new(version_id.clone())?,
Some(CacheBehaviour::Bypass),
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Invalid version ID specified!".to_string(),
)
})?;
// Update instance with correct loader and game version from the API version metadata,
// so the UI shows accurate info while the pack file is still downloading.
if let Some(game_version) = version.game_versions.first() {
let loader = version
.loaders
.first()
.map(|loader| ModLoader::try_from_string(loader))
.transpose()?
.unwrap_or(ModLoader::Vanilla);
let game_version = game_version.clone();
crate::api::instance::edit(
&instance_id,
EditInstance {
content_set_patch: Some(AppliedContentSetPatch {
source_kind: None,
game_version: Some(game_version),
protocol_version: Some(None),
loader: Some(loader),
loader_version: None,
}),
..EditInstance::default()
},
)
.await?;
}
let pack_file = version
.files
.iter()
.find(|file| file.primary)
.or_else(|| version.files.first())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Specified version has no files".to_string(),
)
})?;
let file_name = Path::new(&pack_file.filename);
if file_name.components().count() != 1
|| !matches!(file_name.components().next(), Some(Component::Normal(_)))
{
return Err(crate::ErrorKind::InputError(
"Modrinth returned an invalid modpack file name".to_string(),
)
.into());
}
let hash = pack_file.hashes.get("sha1");
let pack_path = state
.directories
.caches_dir()
.join("modpacks")
.join(&project_id)
.join(&version_id)
.join(file_name);
let metadata =
crate::api::instance::get(&instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let download_meta = DownloadMeta {
reason,
game_version: metadata.applied_content_set.game_version.clone(),
loader: metadata.applied_content_set.loader.as_str().to_string(),
dependent_on: Some(version_id.clone()),
};
let details = InstallPhaseDetails::Modpack {
project_id: Some(project_id.clone()),
version_id: Some(version_id.clone()),
title: Some(title.clone()),
};
let mut last_reported_bytes = 0_u64;
let mut progress =
|current: u64,
total: u64|
-> Pin<Box<dyn Future<Output = crate::Result<()>> + Send>> {
let min_delta = (total / 200).max(256 * 1024);
if current < total
&& current.saturating_sub(last_reported_bytes) < min_delta
{
return Box::pin(async { Ok(()) });
}
last_reported_bytes = current;
let reporter = reporter.clone();
let details = details.clone();
Box::pin(async move {
reporter
.update(
InstallPhaseId::DownloadingPackFile,
Some(InstallProgress {
current,
total,
secondary: None,
}),
details,
)
.await?;
Ok(())
})
};
let progress = Some(&mut progress as &mut FetchProgressFn<'_>);
let context = InstallErrorContext::new("download modpack file")
.urls(vec![pack_file.url.clone()])
.maybe_expected_hash(hash.cloned())
.expected_size(pack_file.size as u64)
.target_path(pack_path.display().to_string())
.project_id(project_id.clone())
.version_id(version_id.clone())
.build();
reporter.set_context(context).await?;
let item_path = pack_path.display().to_string();
reporter
.update_with_events(
InstallPhaseId::DownloadingPackFile,
Some(InstallProgress {
current: 0,
total: pack_file.size.max(1) as u64,
secondary: None,
}),
details.clone(),
vec![InstallJobEventKind::ContentFileQueued {
path: item_path,
bytes_total: Some(pack_file.size as u64),
max_attempts: 5,
}],
)
.await?;
reporter.persist().await?;
let download_result = download_to_path(
DownloadRequest::new(&pack_file.url, ResourceClass::Modpack)
.with_integrity(Integrity {
size: Some(pack_file.size as u64),
sha1: hash.cloned(),
sha512: pack_file.hashes.get("sha512").cloned(),
content: ContentValidation::Jar,
..Integrity::default()
})
.with_h2_range_concurrency(16)
.with_download_meta(download_meta)
.with_install_tracking(
reporter.clone(),
pack_path.display().to_string(),
pack_file.filename.clone(),
),
&pack_path,
&state.download_semaphore,
&state.pool,
progress,
)
.await?;
if download_result.attempts > 0 {
reporter
.record_download_metrics(
download_result.source.as_str(),
download_result.fallback_count as u64,
)
.await?;
}
reporter
.update(InstallPhaseId::ResolvingPack, None, details.clone())
.await?;
let project = CachedEntry::get_project(
&ModrinthProjectId::new(version.project_id.clone())?,
None,
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Invalid project ID specified!".to_string(),
)
})?;
// Only fetch the pack icon when icon_url is provided (new profile).
// When installing to an existing profile (e.g. server projects),
// icon_url is None and we preserve the profile's existing icon.
let icon = if has_icon_url {
if let Some(icon_url) = project.icon_url {
let state = State::get().await?;
reporter
.set_context(
InstallErrorContext::new("download modpack icon")
.urls(vec![icon_url.clone()])
.project_id(project_id.clone())
.version_id(version_id.clone())
.build(),
)
.await?;
let icon_bytes = fetch(
&icon_url,
None,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
let filename = icon_url.rsplit('/').next();
if let Some(filename) = filename {
Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
)
} else {
None
}
} else {
None
}
} else {
None
};
// Set the icon immediately so the UI shows it during download.
if let Some(ref icon_path) = icon {
let _ = crate::api::instance::edit_icon(
&instance_id,
Some(icon_path.as_path()),
)
.await;
}
Ok(CreatePack {
file: CreatePackFile::Path(pack_path),
description: CreatePackDescription {
icon,
override_title: Some(title),
project_id: Some(project_id),
version_id: Some(version_id),
instance_id,
source_filename: None,
},
})
}
#[tracing::instrument]
pub async fn generate_pack_from_file(
path: PathBuf,
instance_id: String,
) -> crate::Result<CreatePack> {
let source_filename =
path.file_name().map(|x| x.to_string_lossy().to_string());
Ok(CreatePack {
file: CreatePackFile::Path(path),
description: CreatePackDescription {
icon: None,
override_title: None,
project_id: None,
version_id: None,
instance_id,
source_filename,
},
})
}
/// Sets generated instance attributes to the pack ones.
/// This includes the pack name, icon, game version, loader version, and loader
pub async fn set_instance_information(
instance_id: String,
description: &CreatePackDescription,
backup_name: &str,
pack_version_id: Option<&str>,
dependencies: &HashMap<PackDependency, String>,
_ignore_lock: bool,
) -> crate::Result<()> {
let mut game_version: Option<&String> = None;
for (key, value) in dependencies {
if *key == PackDependency::Minecraft {
game_version = Some(value);
}
}
let Some(game_version) = game_version else {
return Err(crate::ErrorKind::InputError(
"Pack did not specify Minecraft version".to_string(),
)
.into());
};
let primary_dependencies = [
(PackDependency::Forge, ModLoader::Forge),
(PackDependency::NeoForge, ModLoader::NeoForge),
(PackDependency::FabricLoader, ModLoader::Fabric),
(PackDependency::QuiltLoader, ModLoader::Quilt),
(PackDependency::Cleanroom, ModLoader::Cleanroom),
(PackDependency::LegacyFabric, ModLoader::LegacyFabric),
]
.into_iter()
.filter(|(dependency, _)| dependencies.contains_key(dependency))
.collect::<Vec<_>>();
if primary_dependencies.len() > 1 {
return Err(crate::ErrorKind::InputError(format!(
"Pack declares incompatible primary loaders: {}",
primary_dependencies
.iter()
.map(|(_, loader)| loader.as_str())
.collect::<Vec<_>>()
.join(", ")
))
.into());
}
let primary_loader = primary_dependencies
.first()
.map(|(_, loader)| *loader)
.unwrap_or(ModLoader::Vanilla);
let mut components = vec![LoaderComponent::new_primary(
instance_id.clone(),
primary_loader,
None,
)];
if let Some((dependency, loader)) = primary_dependencies.first() {
let version = resolve_pack_loader_version(
dependencies,
*dependency,
*loader,
game_version,
)
.await?;
components[0].version = Some(version);
components[0].provider_metadata = Some(serde_json::json!({
"source": "pack"
}));
}
for (dependency, loader, kind) in [
(
PackDependency::LiteLoader,
ModLoader::LiteLoader,
LoaderComponentKind::LiteLoader,
),
(
PackDependency::OptiFine,
ModLoader::OptiFine,
LoaderComponentKind::OptiFine,
),
] {
if dependencies.contains_key(&dependency) {
let version = resolve_pack_loader_version(
dependencies,
dependency,
loader,
game_version,
)
.await?;
components.push(LoaderComponent {
instance_id: instance_id.clone(),
kind,
version: Some(version),
role: LoaderComponentRole::Adjunct,
provider_metadata: Some(serde_json::json!({
"source": "pack"
})),
});
}
}
if let Some(version) = dependencies
.get(&PackDependency::OptiFabric)
.filter(|version| !version.trim().is_empty())
{
components.push(LoaderComponent {
instance_id: instance_id.clone(),
kind: LoaderComponentKind::OptiFabric,
version: Some(version.clone()),
role: LoaderComponentRole::Adjunct,
provider_metadata: Some(serde_json::json!({
"projectId": crate::install::runner::OPTIFABRIC_CURSEFORGE_PROJECT_ID,
"provider": "curseforge",
"source": "pack"
})),
});
}
crate::install::runner::validate_loader_components(&components)?;
let (mod_loader, loader_version) =
crate::state::project_loader_components(&components)?;
let link = match (&description.project_id, &description.version_id) {
(Some(project_id), Some(version_id)) => {
Some(InstanceLink::ModrinthModpack {
project_id: project_id.clone(),
version_id: version_id.clone(),
})
}
_ if description.source_filename.is_some() => {
Some(InstanceLink::ImportedModpack {
project_id: None,
version_id: None,
name: Some(backup_name.to_string()),
version_number: pack_version_id.map(ToString::to_string),
filename: description.source_filename.clone(),
})
}
_ => None,
};
let source_kind = match &link {
Some(InstanceLink::ModrinthModpack { .. }) => {
Some(ContentSourceKind::ModrinthModpack)
}
Some(InstanceLink::ImportedModpack { .. }) => {
Some(ContentSourceKind::ImportedModpack)
}
_ => None,
};
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(
description
.override_title
.clone()
.unwrap_or_else(|| backup_name.to_string()),
),
icon_path: description
.icon
.as_ref()
.map(|icon| Some(icon.to_string_lossy().to_string())),
link,
content_set_patch: Some(AppliedContentSetPatch {
source_kind,
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(mod_loader),
loader_version: Some(loader_version),
}),
..EditInstance::default()
},
)
.await?;
let state = State::get().await?;
crate::state::instances::commands::replace_instance_loader_components(
&instance_id,
&components,
&state.pool,
)
.await?;
Ok(())
}
async fn resolve_pack_loader_version(
dependencies: &HashMap<PackDependency, String>,
dependency: PackDependency,
loader: ModLoader,
game_version: &str,
) -> crate::Result<String> {
let requested = dependencies
.get(&dependency)
.filter(|version| !version.trim().is_empty())
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Pack is missing the {} version",
loader.as_str()
))
})?;
crate::launcher::get_loader_version_from_profile(
game_version,
loader,
Some(requested),
)
.await?
.map(|version| version.id)
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Loader version {requested} is not available for {} {game_version}",
loader.as_str()
))
.into()
})
}

View File

@ -0,0 +1,362 @@
//! Installer for HMCL modpacks.
//!
//! HMCL packs are zips carrying a `modpack.json` with the pack name and game
//! version, optionally with an MCBBS-style `addons` array declaring loaders;
//! bundled content ships in a `minecraft/` folder that maps onto the
//! instance's game directory.
use std::path::PathBuf;
use serde::Deserialize;
use super::archive_util;
use crate::State;
use crate::data::ModLoader;
use crate::install::{
InstallPhaseDetails, InstallPhaseId, InstallProgressReporter,
};
use crate::pack::detect::HMCL_MANIFEST;
use crate::state::{
AppliedContentSetPatch, ContentSourceKind, EditInstance,
InstanceInstallStage, InstanceLink,
};
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct HmclManifest {
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
game_version: Option<String>,
#[serde(default)]
addons: Vec<HmclAddon>,
}
#[derive(Deserialize, Debug)]
struct HmclAddon {
id: String,
version: String,
}
pub(crate) async fn install_hmcl_pack_with_reporter(
instance_id: String,
archive_path: PathBuf,
base_folder: String,
source_filename: Option<String>,
reporter: InstallProgressReporter,
) -> crate::Result<()> {
let state = State::get().await?;
let manifest_json = archive_util::read_archive_entry_to_string(
archive_path.clone(),
format!("{base_folder}{HMCL_MANIFEST}"),
)
.await?;
let manifest: HmclManifest = serde_json::from_str(&manifest_json)?;
let mut game_version = manifest
.game_version
.clone()
.filter(|version| !version.trim().is_empty());
let mut loader = ModLoader::Vanilla;
let mut loader_version = None;
let mut optifine_version = None;
let mut lite_loader_version = None;
for addon in &manifest.addons {
match addon.id.to_ascii_lowercase().as_str() {
"game" => {
if game_version.is_none() {
game_version = Some(addon.version.clone());
}
}
"forge" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Forge;
loader_version = Some(addon.version.clone());
}
"neoforge" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::NeoForge;
loader_version = Some(addon.version.clone());
}
"fabric" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Fabric;
loader_version = Some(addon.version.clone());
}
"quilt" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Quilt;
loader_version = Some(addon.version.clone());
}
"cleanroom" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Cleanroom;
loader_version = Some(addon.version.clone());
}
"legacy_fabric" | "legacyfabric" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"HMCL modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::LegacyFabric;
loader_version = Some(addon.version.clone());
}
"lite_loader" | "liteloader" => {
lite_loader_version = Some(addon.version.clone());
}
"optifine" => optifine_version = Some(addon.version.clone()),
"labymod" => {
return Err(crate::ErrorKind::InputError(
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
.to_string(),
)
.into());
}
other => {
return Err(crate::ErrorKind::InputError(format!(
"Unsupported HMCL loader component {other} {}",
addon.version
))
.into());
}
}
}
let Some(game_version) = game_version else {
return Err(crate::ErrorKind::InputError(
"HMCL modpack did not specify a Minecraft version".to_string(),
)
.into());
};
let mut lite_loader_as_adjunct = None;
if let Some(lite_loader_version) = lite_loader_version {
match loader {
ModLoader::Vanilla => {
loader = ModLoader::LiteLoader;
loader_version = Some(lite_loader_version);
}
ModLoader::Forge => {
lite_loader_as_adjunct = Some(lite_loader_version);
}
_ => {
return Err(crate::ErrorKind::InputError(format!(
"LiteLoader is not supported with {}",
loader.as_str()
))
.into());
}
}
}
let mut optifine_as_mod = None;
let mut requires_optifabric = false;
if let Some(optifine_version) = optifine_version {
match loader {
ModLoader::Vanilla => {
loader = ModLoader::OptiFine;
loader_version = Some(optifine_version);
}
ModLoader::Forge | ModLoader::NeoForge => {
optifine_as_mod = Some(optifine_version);
}
ModLoader::Fabric | ModLoader::LegacyFabric => {
optifine_as_mod = Some(optifine_version);
requires_optifabric = true;
}
_ => {
return Err(crate::ErrorKind::InputError(format!(
"OptiFine is not supported with {}",
loader.as_str()
))
.into());
}
}
}
let pack_name = manifest
.name
.clone()
.filter(|name| !name.trim().is_empty())
.or_else(|| {
source_filename.as_ref().map(|name| {
std::path::Path::new(name)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
})
})
.unwrap_or_else(|| "HMCL Modpack".to_string());
let pack_details = InstallPhaseDetails::Modpack {
project_id: None,
version_id: None,
title: Some(pack_name.clone()),
};
reporter
.update(InstallPhaseId::ResolvingPack, None, pack_details.clone())
.await?;
let lite_loader_as_adjunct = if let Some(requested_version) =
lite_loader_as_adjunct
{
Some(
crate::launcher::get_loader_version_from_profile(
&game_version,
ModLoader::LiteLoader,
Some(&requested_version),
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"No LiteLoader version {requested_version} supports Minecraft {game_version}"
))
})?,
)
} else {
None
};
let optifabric_version = if requires_optifabric {
Some(
crate::install::runner::resolve_optifabric_version(&game_version)
.await?,
)
} else {
None
};
let resolved_loader_version = if loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&game_version,
loader,
loader_version.as_deref(),
)
.await?
} else {
None
};
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(pack_name.clone()),
link: Some(InstanceLink::ImportedModpack {
project_id: None,
version_id: None,
name: Some(pack_name.clone()),
version_number: manifest.version.clone(),
filename: source_filename,
}),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: Some(ContentSourceKind::ImportedModpack),
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(loader),
loader_version: Some(
resolved_loader_version.map(|version| version.id),
),
}),
..EditInstance::default()
},
)
.await?;
let minecraft_install =
super::parallel_minecraft_install::ParallelMinecraftInstall::start(
instance_id.clone(),
reporter.clone(),
);
reporter
.update(
InstallPhaseId::ExtractingOverrides,
None,
pack_details.clone(),
)
.await?;
let instance_path =
crate::api::instance::get_full_path(&instance_id).await?;
archive_util::extract_archive_subdir_for_instance(
instance_id.clone(),
reporter.cancellation_token(),
archive_path,
format!("{base_folder}minecraft/"),
instance_path.clone(),
)
.await?;
minecraft_install.join().await?;
if let Some(lite_loader_version) = lite_loader_as_adjunct {
super::install_mcbbs::install_liteloader_component(
&state,
&instance_id,
&game_version,
loader,
&lite_loader_version,
)
.await?;
}
if let Some(optifine_version) = optifine_as_mod {
super::install_mcbbs::install_optifine_mod(
&state,
&instance_id,
reporter.cancellation_token(),
&game_version,
&optifine_version,
&instance_path,
)
.await?;
super::install_mcbbs::record_optifine_component(
&instance_id,
&optifine_version,
)
.await?;
}
if let Some(optifabric_version) = optifabric_version {
super::install_mcbbs::install_optifabric_component(
&instance_id,
&game_version,
&optifabric_version,
)
.await?;
}
reporter.clear_context().await?;
Ok(())
}

View File

@ -0,0 +1,615 @@
//! Installer for MCBBS modpacks.
//!
//! MCBBS packs are zips carrying either an `mcbbs.packmeta` file or a
//! `manifest.json` with an `addons` array. Game and loader versions come from
//! the addons list, bundled content ships in `overrides/`, and optional
//! launch settings come from `launchInfo`.
use std::path::PathBuf;
use serde::Deserialize;
use super::archive_util;
use crate::State;
use crate::data::ModLoader;
use crate::install::{
InstallPhaseDetails, InstallPhaseId, InstallProgressReporter,
};
use crate::pack::detect::{CURSEFORGE_MANIFEST, MCBBS_MANIFEST};
use crate::state::{
AppliedContentSetPatch, ContentSourceKind, EditInstance,
InstanceInstallStage, InstanceLaunchOverridesPatch, InstanceLink,
};
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct McbbsManifest {
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
#[serde(default)]
addons: Vec<McbbsAddon>,
#[serde(default)]
files: Vec<McbbsFile>,
#[serde(default)]
launch_info: Option<McbbsLaunchInfo>,
}
#[derive(Deserialize, Debug)]
struct McbbsAddon {
id: String,
version: String,
}
/// A `files` entry; `curse` entries carry CurseForge project/file ids while
/// `addition` entries ship inside the overrides folder and need no download.
#[derive(Deserialize, Debug)]
struct McbbsFile {
#[serde(default, rename = "type")]
type_: Option<String>,
#[serde(default, alias = "projectID", alias = "projectId")]
project_id: Option<u32>,
#[serde(default, alias = "fileID", alias = "fileId")]
file_id: Option<u32>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct McbbsLaunchInfo {
#[serde(default)]
java_argument: Option<serde_json::Value>,
}
fn join_arguments(value: &serde_json::Value) -> Vec<String> {
match value {
serde_json::Value::String(value) => vec![value.clone()],
serde_json::Value::Array(values) => values
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect(),
_ => Vec::new(),
}
}
pub(crate) async fn install_mcbbs_pack_with_reporter(
instance_id: String,
archive_path: PathBuf,
base_folder: String,
source_filename: Option<String>,
reporter: InstallProgressReporter,
) -> crate::Result<()> {
let state = State::get().await?;
let manifest_json = match archive_util::read_archive_entry_to_string(
archive_path.clone(),
format!("{base_folder}{MCBBS_MANIFEST}"),
)
.await
{
Ok(contents) => contents,
Err(_) => {
archive_util::read_archive_entry_to_string(
archive_path.clone(),
format!("{base_folder}{CURSEFORGE_MANIFEST}"),
)
.await?
}
};
let manifest: McbbsManifest = serde_json::from_str(&manifest_json)?;
let mut game_version = None;
let mut loader = ModLoader::Vanilla;
let mut loader_version = None;
let mut optifine_version = None;
let mut lite_loader_version = None;
for addon in &manifest.addons {
match addon.id.to_ascii_lowercase().as_str() {
"game" => game_version = Some(addon.version.clone()),
"forge" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Forge;
loader_version = Some(addon.version.clone());
}
"neoforge" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::NeoForge;
loader_version = Some(addon.version.clone());
}
"fabric" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Fabric;
loader_version = Some(addon.version.clone());
}
"quilt" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Quilt;
loader_version = Some(addon.version.clone());
}
"cleanroom" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::Cleanroom;
loader_version = Some(addon.version.clone());
}
"legacy_fabric" | "legacyfabric" => {
if loader != ModLoader::Vanilla {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack declares multiple primary loaders"
.to_string(),
)
.into());
}
loader = ModLoader::LegacyFabric;
loader_version = Some(addon.version.clone());
}
"lite_loader" | "liteloader" => {
lite_loader_version = Some(addon.version.clone());
}
"optifine" => optifine_version = Some(addon.version.clone()),
"labymod" => {
return Err(crate::ErrorKind::InputError(
"Unsupported loader LabyMod: Axolotl does not install, update, or repair LabyMod instances"
.to_string(),
)
.into());
}
other => {
return Err(crate::ErrorKind::InputError(format!(
"Unsupported MCBBS loader component {other} {}",
addon.version
))
.into());
}
}
}
let Some(game_version) = game_version else {
return Err(crate::ErrorKind::InputError(
"MCBBS modpack did not specify a Minecraft version".to_string(),
)
.into());
};
let mut lite_loader_as_adjunct = None;
if let Some(lite_loader_version) = lite_loader_version {
match loader {
ModLoader::Vanilla => {
loader = ModLoader::LiteLoader;
loader_version = Some(lite_loader_version);
}
ModLoader::Forge => {
lite_loader_as_adjunct = Some(lite_loader_version);
}
_ => {
return Err(crate::ErrorKind::InputError(format!(
"LiteLoader is not supported with {}",
loader.as_str()
))
.into());
}
}
}
let mut optifine_as_mod = None;
let mut requires_optifabric = false;
if let Some(optifine_version) = optifine_version {
match loader {
ModLoader::Vanilla => {
loader = ModLoader::OptiFine;
loader_version = Some(optifine_version);
}
ModLoader::Forge | ModLoader::NeoForge => {
optifine_as_mod = Some(optifine_version);
}
ModLoader::Fabric | ModLoader::LegacyFabric => {
optifine_as_mod = Some(optifine_version);
requires_optifabric = true;
}
_ => {
return Err(crate::ErrorKind::InputError(format!(
"OptiFine is not supported with {}",
loader.as_str()
))
.into());
}
}
}
let pack_name = manifest
.name
.clone()
.filter(|name| !name.trim().is_empty())
.or_else(|| {
source_filename.as_ref().map(|name| {
std::path::Path::new(name)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
})
})
.unwrap_or_else(|| "MCBBS Modpack".to_string());
let pack_details = InstallPhaseDetails::Modpack {
project_id: None,
version_id: None,
title: Some(pack_name.clone()),
};
reporter
.update(InstallPhaseId::ResolvingPack, None, pack_details.clone())
.await?;
let lite_loader_as_adjunct = if let Some(requested_version) =
lite_loader_as_adjunct
{
Some(
crate::launcher::get_loader_version_from_profile(
&game_version,
ModLoader::LiteLoader,
Some(&requested_version),
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"No LiteLoader version {requested_version} supports Minecraft {game_version}"
))
})?,
)
} else {
None
};
let optifabric_version = if requires_optifabric {
Some(
crate::install::runner::resolve_optifabric_version(&game_version)
.await?,
)
} else {
None
};
let resolved_loader_version = if loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&game_version,
loader,
loader_version.as_deref(),
)
.await?
} else {
None
};
let launch_overrides =
manifest.launch_info.as_ref().and_then(|launch_info| {
let jvm_args = launch_info
.java_argument
.as_ref()
.map(join_arguments)
.filter(|args| !args.is_empty())?;
Some(InstanceLaunchOverridesPatch {
extra_launch_args: Some(Some(jvm_args)),
..InstanceLaunchOverridesPatch::default()
})
});
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(pack_name.clone()),
link: Some(InstanceLink::ImportedModpack {
project_id: None,
version_id: None,
name: Some(pack_name.clone()),
version_number: manifest.version.clone(),
filename: source_filename,
}),
launch_overrides,
content_set_patch: Some(AppliedContentSetPatch {
source_kind: Some(ContentSourceKind::ImportedModpack),
game_version: Some(game_version.clone()),
protocol_version: Some(None),
loader: Some(loader),
loader_version: Some(
resolved_loader_version.map(|version| version.id),
),
}),
..EditInstance::default()
},
)
.await?;
let minecraft_install =
super::parallel_minecraft_install::ParallelMinecraftInstall::start(
instance_id.clone(),
reporter.clone(),
);
let curse_files = manifest
.files
.iter()
.filter(|file| {
file.type_
.as_deref()
.is_none_or(|kind| kind.eq_ignore_ascii_case("curse"))
})
.filter_map(|file| {
Some(crate::api::curseforge::CurseForgeManifestFile {
project_id: file.project_id?,
file_id: file.file_id?,
required: true,
})
})
.collect::<Vec<_>>();
if !curse_files.is_empty() {
let content_loader = (loader != ModLoader::Vanilla
&& loader != ModLoader::OptiFine)
.then(|| loader.as_str().to_string());
crate::api::curseforge::install_local_manifest_files(
&instance_id,
curse_files,
false,
&game_version,
content_loader.as_deref(),
pack_details.clone(),
&reporter,
)
.await?;
}
reporter
.update(
InstallPhaseId::ExtractingOverrides,
None,
pack_details.clone(),
)
.await?;
let instance_path =
crate::api::instance::get_full_path(&instance_id).await?;
archive_util::extract_archive_subdir_for_instance(
instance_id.clone(),
reporter.cancellation_token(),
archive_path,
format!("{base_folder}overrides/"),
instance_path.clone(),
)
.await?;
minecraft_install.join().await?;
if let Some(lite_loader_version) = lite_loader_as_adjunct {
install_liteloader_component(
&state,
&instance_id,
&game_version,
loader,
&lite_loader_version,
)
.await?;
}
if let Some(optifine_version) = optifine_as_mod {
install_optifine_mod(
&state,
&instance_id,
reporter.cancellation_token(),
&game_version,
&optifine_version,
&instance_path,
)
.await?;
record_optifine_component(&instance_id, &optifine_version).await?;
}
if let Some(optifabric_version) = optifabric_version {
install_optifabric_component(
&instance_id,
&game_version,
&optifabric_version,
)
.await?;
}
reporter.clear_context().await?;
Ok(())
}
pub(crate) async fn record_optifine_component(
instance_id: &str,
version: &str,
) -> crate::Result<()> {
record_loader_component(
instance_id,
crate::state::LoaderComponentKind::OptiFine,
version,
Some(serde_json::json!({ "source": "pack" })),
)
.await
}
pub(crate) async fn record_loader_component(
instance_id: &str,
kind: crate::state::LoaderComponentKind,
version: &str,
provider_metadata: Option<serde_json::Value>,
) -> crate::Result<()> {
let state = State::get().await?;
let metadata =
crate::api::instance::get(instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let mut components = metadata.loader_components;
components.retain(|component| component.kind != kind);
components.push(crate::state::LoaderComponent {
instance_id: instance_id.to_string(),
kind,
version: Some(version.to_string()),
role: crate::state::LoaderComponentRole::Adjunct,
provider_metadata,
});
crate::state::instances::commands::replace_instance_loader_components(
instance_id,
&components,
&state.pool,
)
.await
}
pub(crate) async fn install_liteloader_component(
state: &State,
instance_id: &str,
game_version: &str,
primary_loader: ModLoader,
resolved_version: &daedalus::modded::LoaderVersion,
) -> crate::Result<()> {
let metadata =
crate::api::instance::get(instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let version = crate::install::runner::install_liteloader_adjunct_resolved(
state,
&metadata,
game_version,
primary_loader,
resolved_version,
)
.await?;
record_loader_component(
instance_id,
crate::state::LoaderComponentKind::LiteLoader,
&version,
Some(serde_json::json!({ "source": "pack" })),
)
.await
}
pub(crate) async fn install_optifabric_component(
instance_id: &str,
game_version: &str,
version: &str,
) -> crate::Result<()> {
let version = crate::install::runner::install_optifabric_file(
instance_id,
game_version,
version,
)
.await?;
record_loader_component(
instance_id,
crate::state::LoaderComponentKind::OptiFabric,
&version,
Some(serde_json::json!({
"projectId": crate::install::runner::OPTIFABRIC_CURSEFORGE_PROJECT_ID,
"provider": "curseforge"
})),
)
.await
}
/// Installs OptiFine into the instance's mods folder for packs that pair it
/// with Forge or NeoForge. Requires the instance's Minecraft install to have
/// completed so the client jar and a Java runtime are available.
pub(crate) async fn install_optifine_mod(
state: &State,
instance_id: &str,
cancellation: tokio_util::sync::CancellationToken,
game_version: &str,
optifine_version: &str,
instance_path: &std::path::Path,
) -> crate::Result<()> {
let metadata =
crate::api::instance::get(instance_id)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown instance {instance_id}"
))
})?;
let version_jar = match &metadata.applied_content_set.loader_version {
Some(loader_version) => format!("{game_version}-{loader_version}"),
None => game_version.to_string(),
};
let loader_client_jar = state
.directories
.version_dir(&version_jar)
.join(format!("{version_jar}.jar"));
let client_jar = if loader_client_jar.is_file() {
loader_client_jar
} else {
state
.directories
.version_dir(game_version)
.join(format!("{game_version}.jar"))
};
let (manifest, version_index) =
crate::launcher::resolve_minecraft_manifest(game_version, state)
.await?;
let version_info = crate::launcher::download::download_version_info(
state,
&manifest.versions[version_index],
ModLoader::Vanilla,
None,
None,
None,
None,
)
.await?;
let java_key = version_info
.java_version
.as_ref()
.map_or(8, |java| java.major_version);
let java = crate::api::jre::find_java_for_version(java_key)
.await?
.ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"No Java {java_key} runtime is available for the OptiFine installer"
))
})?;
crate::launcher::optifine::install_optifine_as_mod(
state,
instance_id,
cancellation,
std::path::Path::new(&java.path),
game_version,
optifine_version,
&client_jar,
&instance_path.join("mods"),
)
.await?;
Ok(())
}

View File

@ -0,0 +1,63 @@
//! Installer for MultiMC/Prism export zips.
//!
//! Export zips carry the same `mmc-pack.json` + `instance.cfg` layout as an
//! installed MMC instance, so the archive is extracted to a scratch directory
//! and imported through the existing MMC instance importer.
use std::path::PathBuf;
use super::archive_util;
use crate::State;
use crate::install::{
InstallPhaseDetails, InstallPhaseId, InstallProgressReporter,
};
use crate::pack::import::ImportLauncherType;
pub(crate) async fn install_mmc_zip_with_reporter(
instance_id: String,
archive_path: PathBuf,
base_folder: String,
source_filename: Option<String>,
reporter: InstallProgressReporter,
) -> crate::Result<()> {
let state = State::get().await?;
let details = InstallPhaseDetails::Import {
launcher_type: ImportLauncherType::MultiMC,
instance_folder: source_filename.unwrap_or_else(|| {
archive_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "MultiMC pack".to_string())
}),
};
reporter
.update(InstallPhaseId::ExtractingOverrides, None, details.clone())
.await?;
let scratch = archive_util::create_import_scratch_dir(&state).await?;
let result = async {
archive_util::extract_archive_subdir(
archive_path,
base_folder,
scratch.clone(),
)
.await?;
crate::pack::import::mmc::import_mmc_instance_dir(
scratch.clone(),
Some(scratch.clone()),
&instance_id,
reporter.clone(),
details,
false, // zip imports don't support symlinks
)
.await
}
.await;
if let Err(error) = tokio::fs::remove_dir_all(&scratch).await {
tracing::warn!(
"Failed to clean up modpack import scratch directory {}: {error}",
scratch.display()
);
}
result
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,312 @@
//! Installer for plain zipped-up game folders.
//!
//! These archives have no pack manifest at all — they are a `.minecraft`
//! folder (optionally wrapped in extra directories) identified by a
//! `versions/<id>/<id>.json` structure. The version JSON is inspected to
//! guess the game version and loader, and the folder contents become the
//! instance's game directory.
use std::path::PathBuf;
use serde::Deserialize;
use super::archive_util;
use crate::data::ModLoader;
use crate::install::{
InstallPhaseDetails, InstallPhaseId, InstallProgressReporter,
};
use crate::state::{
AppliedContentSetPatch, ContentSourceKind, EditInstance,
InstanceInstallStage, InstanceLink,
};
#[derive(Deserialize, Debug, Default)]
#[serde(rename_all = "camelCase")]
struct PlainVersionJson {
#[serde(default)]
id: Option<String>,
#[serde(default)]
inherits_from: Option<String>,
#[serde(default)]
libraries: Vec<PlainLibrary>,
}
#[derive(Deserialize, Debug)]
struct PlainLibrary {
#[serde(default)]
name: Option<String>,
}
fn looks_like_game_version(value: &str) -> bool {
let mut parts = value.split('.');
parts.next().is_some_and(|part| part.parse::<u32>().is_ok())
&& value.split('.').skip(1).all(|part| {
part.split('-')
.next()
.is_some_and(|part| part.parse::<u32>().is_ok())
})
}
struct DetectedTarget {
game_version: String,
loader: ModLoader,
loader_version: Option<String>,
}
fn detect_target(version_json: &PlainVersionJson) -> Option<DetectedTarget> {
let mut game_version = version_json
.inherits_from
.clone()
.filter(|value| looks_like_game_version(value));
let mut loader = ModLoader::Vanilla;
let mut loader_version = None;
for library in &version_json.libraries {
let Some(name) = &library.name else {
continue;
};
let parts: Vec<&str> = name.split(':').collect();
if parts.len() < 3 {
continue;
}
let (group, artifact, version) = (parts[0], parts[1], parts[2]);
match (group, artifact) {
("net.fabricmc", "fabric-loader") => {
loader = ModLoader::Fabric;
loader_version = Some(version.to_string());
}
("org.quiltmc", "quilt-loader") => {
loader = ModLoader::Quilt;
loader_version = Some(version.to_string());
}
("net.neoforged", "neoforge" | "forge") => {
loader = ModLoader::NeoForge;
loader_version = Some(version.to_string());
}
("net.minecraftforge", "forge" | "fmlloader") => {
loader = ModLoader::Forge;
// Forge versions are usually stored as `<mc>-<forge>`.
let forge_version = version
.split_once('-')
.map(|(mc, forge)| {
if game_version.is_none() && looks_like_game_version(mc)
{
game_version = Some(mc.to_string());
}
forge.to_string()
})
.unwrap_or_else(|| version.to_string());
loader_version = Some(forge_version);
}
("optifine", "OptiFine") if loader == ModLoader::Vanilla => {
loader = ModLoader::OptiFine;
// OptiFine library versions look like `<mc>_HD_U_I6`.
loader_version = Some(
version
.split_once('_')
.map(|(_, of)| of.to_string())
.unwrap_or_else(|| version.to_string()),
);
}
_ => {}
}
}
if game_version.is_none()
&& let Some(id) = &version_json.id
&& looks_like_game_version(id)
{
game_version = Some(id.clone());
}
game_version.map(|game_version| DetectedTarget {
game_version,
loader,
loader_version,
})
}
/// Reads every `versions/<id>/<id>.json` under the base folder. Archives of
/// modded installs usually contain both the vanilla and the modded version
/// folder, so all candidates are needed to pick the right one.
async fn read_version_candidates(
archive_path: PathBuf,
base_folder: String,
) -> crate::Result<Vec<(String, PlainVersionJson)>> {
tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&archive_path).map_err(|error| {
crate::util::io::IOError::with_path(error, &archive_path)
})?;
let mut archive = zip::ZipArchive::new(file).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Modpack archive is invalid: {error}"
))
})?;
let versions_prefix = format!("{base_folder}versions/");
let mut candidates = Vec::new();
for index in 0..archive.len() {
let name = {
let entry = archive.by_index_raw(index).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Failed to read modpack archive entry: {error}"
))
})?;
crate::pack::detect::decode_zip_entry_name(entry.name_raw())
};
let Some(rest) = name.strip_prefix(&versions_prefix) else {
continue;
};
let mut segments = rest.split('/');
let (Some(id), Some(json), None) =
(segments.next(), segments.next(), segments.next())
else {
continue;
};
if json.strip_suffix(".json") != Some(id) {
continue;
}
let id = id.to_string();
let mut entry = archive.by_index(index).map_err(|error| {
crate::ErrorKind::InputError(format!(
"Failed to read modpack archive entry: {error}"
))
})?;
let mut contents = Vec::new();
std::io::Read::read_to_end(&mut entry, &mut contents)?;
if let Ok(parsed) =
serde_json::from_slice::<PlainVersionJson>(&contents)
{
candidates.push((id, parsed));
}
}
Ok(candidates)
})
.await?
}
pub(crate) async fn install_plain_archive_with_reporter(
instance_id: String,
archive_path: PathBuf,
base_folder: String,
version_id: String,
source_filename: Option<String>,
reporter: InstallProgressReporter,
) -> crate::Result<()> {
let candidates =
read_version_candidates(archive_path.clone(), base_folder.clone())
.await?;
let mut targets: Vec<(String, DetectedTarget)> = candidates
.iter()
.filter_map(|(id, json)| {
detect_target(json).map(|target| (id.clone(), target))
})
.collect();
let selected = targets
.iter()
.position(|(_, target)| target.loader != ModLoader::Vanilla)
.map(|index| targets.remove(index))
.or_else(|| {
if targets.is_empty() {
None
} else {
Some(targets.remove(0))
}
});
let Some((selected_id, target)) = selected else {
return Err(crate::ErrorKind::InputError(format!(
"Could not determine the Minecraft version of archived instance {version_id}"
))
.into());
};
let pack_name = if selected_id.trim().is_empty() {
source_filename
.as_ref()
.map(|name| {
std::path::Path::new(name)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
})
.unwrap_or_else(|| "Imported Instance".to_string())
} else {
selected_id.clone()
};
let pack_details = InstallPhaseDetails::Modpack {
project_id: None,
version_id: None,
title: Some(pack_name.clone()),
};
reporter
.update(InstallPhaseId::ResolvingPack, None, pack_details.clone())
.await?;
let resolved_loader_version = if target.loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&target.game_version,
target.loader,
target.loader_version.as_deref(),
)
.await?
} else {
None
};
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(InstanceInstallStage::PackInstalling),
name: Some(pack_name.clone()),
link: Some(InstanceLink::ImportedModpack {
project_id: None,
version_id: None,
name: Some(pack_name),
version_number: None,
filename: source_filename,
}),
content_set_patch: Some(AppliedContentSetPatch {
source_kind: Some(ContentSourceKind::ImportedModpack),
game_version: Some(target.game_version.clone()),
protocol_version: Some(None),
loader: Some(target.loader),
loader_version: Some(
resolved_loader_version.map(|version| version.id),
),
}),
..EditInstance::default()
},
)
.await?;
reporter
.update(
InstallPhaseId::ExtractingOverrides,
None,
pack_details.clone(),
)
.await?;
let instance_path =
crate::api::instance::get_full_path(&instance_id).await?;
archive_util::extract_archive_subdir_for_instance(
instance_id.clone(),
reporter.cancellation_token(),
archive_path,
base_folder,
instance_path.clone(),
)
.await?;
let local_source =
crate::launcher::download::LocalRuntimeSource::discover(&instance_path);
crate::launcher::install_minecraft_for_instance_id_with_local_source(
&instance_id,
local_source,
false,
Some(reporter.clone()),
crate::launcher::InstanceCompletionPolicy::DeferToInstallJob,
)
.await?;
reporter.clear_context().await?;
Ok(())
}

View File

@ -0,0 +1,10 @@
pub(crate) mod archive_util;
pub mod detect;
pub mod import;
pub mod install_from;
pub(crate) mod install_hmcl;
pub(crate) mod install_mcbbs;
pub(crate) mod install_mmc_zip;
pub mod install_mrpack;
pub(crate) mod install_plain_archive;
pub(crate) mod parallel_minecraft_install;

View File

@ -0,0 +1,65 @@
use crate::install::InstallProgressReporter;
use tokio_util::sync::CancellationToken;
/// Owns a Minecraft core installation that runs alongside pack content work.
/// Dropping the guard cancels the task so an importer cannot leave a core
/// download running after its content installation fails.
pub(crate) struct ParallelMinecraftInstall {
cancel: CancellationToken,
task: Option<tokio::task::JoinHandle<crate::Result<()>>>,
}
impl ParallelMinecraftInstall {
pub(crate) fn start(
instance_id: String,
reporter: InstallProgressReporter,
) -> Self {
let cancel = CancellationToken::new();
let task_cancel = cancel.clone();
let parallel_reporter = reporter.with_parallel_output();
let task = tokio::spawn(async move {
tokio::select! {
_ = task_cancel.cancelled() => {
tracing::debug!(
instance_id = %instance_id,
"Parallel Minecraft install aborted before completion"
);
Ok(())
}
result = crate::launcher::install_minecraft_for_instance_id_with_reporter(
&instance_id,
false,
Some(parallel_reporter),
crate::launcher::InstanceCompletionPolicy::DeferToInstallJob,
) => result,
}
});
Self {
cancel,
task: Some(task),
}
}
/// Cancels the core install and waits until the task has stopped.
pub(crate) async fn abort(mut self) {
self.cancel.cancel();
if let Some(task) = self.task.take() {
let _ = task.await;
}
}
/// Waits for the core install to finish without cancelling it.
pub(crate) async fn join(mut self) -> crate::Result<()> {
if let Some(task) = self.task.take() {
task.await??;
}
Ok(())
}
}
impl Drop for ParallelMinecraftInstall {
fn drop(&mut self) {
self.cancel.cancel();
}
}