feat:移除了弹窗,服务器添加sls
This commit is contained in:
293
packages/app-lib/src/api/pack/archive_util.rs
Normal file
293
packages/app-lib/src/api/pack/archive_util.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
464
packages/app-lib/src/api/pack/detect.rs
Normal file
464
packages/app-lib/src/api/pack/detect.rs
Normal 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
|
||||
}
|
||||
277
packages/app-lib/src/api/pack/import/atlauncher.rs
Normal file
277
packages/app-lib/src/api/pack/import/atlauncher.rs
Normal 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(())
|
||||
}
|
||||
178
packages/app-lib/src/api/pack/import/axolotl.rs
Normal file
178
packages/app-lib/src/api/pack/import/axolotl.rs
Normal 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
|
||||
}
|
||||
275
packages/app-lib/src/api/pack/import/curseforge.rs
Normal file
275
packages/app-lib/src/api/pack/import/curseforge.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
948
packages/app-lib/src/api/pack/import/direct_link.rs
Normal file
948
packages/app-lib/src/api/pack/import/direct_link.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
130
packages/app-lib/src/api/pack/import/gdlauncher.rs
Normal file
130
packages/app-lib/src/api/pack/import/gdlauncher.rs
Normal 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(())
|
||||
}
|
||||
465
packages/app-lib/src/api/pack/import/generic.rs
Normal file
465
packages/app-lib/src/api/pack/import/generic.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
116
packages/app-lib/src/api/pack/import/hmcl.rs
Normal file
116
packages/app-lib/src/api/pack/import/hmcl.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
89
packages/app-lib/src/api/pack/import/hmcl_config.rs
Normal file
89
packages/app-lib/src/api/pack/import/hmcl_config.rs
Normal 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 three‑priority strategy:
|
||||
//!
|
||||
//! 1. Portable mode — `{launcher_dir}/.hmcl/config/launcher-settings.json`
|
||||
//! 2. System install — platform‑specific 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 — side‑car `.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 platform‑specific 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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
840
packages/app-lib/src/api/pack/import/instance_json.rs
Normal file
840
packages/app-lib/src/api/pack/import/instance_json.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
440
packages/app-lib/src/api/pack/import/mmc.rs
Normal file
440
packages/app-lib/src/api/pack/import/mmc.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
1704
packages/app-lib/src/api/pack/import/mod.rs
Normal file
1704
packages/app-lib/src/api/pack/import/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
254
packages/app-lib/src/api/pack/import/modrinth_app.rs
Normal file
254
packages/app-lib/src/api/pack/import/modrinth_app.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
109
packages/app-lib/src/api/pack/import/pcl.rs
Normal file
109
packages/app-lib/src/api/pack/import/pcl.rs
Normal 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
|
||||
}
|
||||
}
|
||||
148
packages/app-lib/src/api/pack/import/pe_info.rs
Normal file
148
packages/app-lib/src/api/pack/import/pe_info.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
774
packages/app-lib/src/api/pack/install_from.rs
Normal file
774
packages/app-lib/src/api/pack/install_from.rs
Normal 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()
|
||||
})
|
||||
}
|
||||
362
packages/app-lib/src/api/pack/install_hmcl.rs
Normal file
362
packages/app-lib/src/api/pack/install_hmcl.rs
Normal 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(())
|
||||
}
|
||||
615
packages/app-lib/src/api/pack/install_mcbbs.rs
Normal file
615
packages/app-lib/src/api/pack/install_mcbbs.rs
Normal 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(())
|
||||
}
|
||||
63
packages/app-lib/src/api/pack/install_mmc_zip.rs
Normal file
63
packages/app-lib/src/api/pack/install_mmc_zip.rs
Normal 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
|
||||
}
|
||||
2227
packages/app-lib/src/api/pack/install_mrpack.rs
Normal file
2227
packages/app-lib/src/api/pack/install_mrpack.rs
Normal file
File diff suppressed because it is too large
Load Diff
312
packages/app-lib/src/api/pack/install_plain_archive.rs
Normal file
312
packages/app-lib/src/api/pack/install_plain_archive.rs
Normal 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(())
|
||||
}
|
||||
10
packages/app-lib/src/api/pack/mod.rs
Normal file
10
packages/app-lib/src/api/pack/mod.rs
Normal 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;
|
||||
65
packages/app-lib/src/api/pack/parallel_minecraft_install.rs
Normal file
65
packages/app-lib/src/api/pack/parallel_minecraft_install.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user