Files
Starlight_Lancher/packages/app-lib/src/api/curseforge.rs

11127 lines
371 KiB
Rust

use crate::api::content_search::{
chinese_file_title_for_curseforge_slug, localized_content_file_name,
};
use crate::event::LoadingBarType;
use crate::event::emit::{
emit_loading, init_loading, loading_try_for_each_concurrent,
};
use crate::install::{
InstallJobEventKind, InstallPhaseDetails, InstallPhaseId, InstallProgress,
InstallProgressReporter, InstallProgressSecondary,
};
use crate::state::{
CacheBehaviour, CachedEntry, ContentProvider, ContentProviderRef,
ContentSourceKind, CurseForgeFileId, CurseForgeProjectId,
DependencyResolutionEdge, DependencyResolutionIssue,
DependencyResolutionNode, DependencyResolutionPlan,
DependencyResolutionTarget, DependencySelectionReason, DownloadSourceMode,
EditInstance, InstanceInstallStage, InstanceLink, ModLoader,
ModrinthProjectId, ModrinthVersionId, ProjectType, ReleaseChannel,
};
use crate::util::fetch::{
ContentValidation, DownloadRequest, DownloadResult, DownloadRouteSource,
FetchProgressFn, Integrity, ProxyPolicy, ResourceClass, download_to_path,
resolve_download_routes_for, sha1_file_async,
};
use crate::{ErrorKind, State};
use dashmap::DashMap;
use futures::{StreamExt, stream};
use reqwest::{Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet, VecDeque};
use std::io::{Read, Seek};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex, RwLock};
use std::time::{Duration, Instant};
const API_BASE_URL: &str = "https://api.curseforge.com";
const MINECRAFT_GAME_ID: u32 = 432;
const MAX_PAGE_SIZE: u32 = 50;
const MODPACK_FILE_INSTALL_ATTEMPTS: usize = 3;
const MODPACK_METADATA_CONCURRENCY: usize = 4;
const DEPENDENCY_RELATION_EMBEDDED: u32 = 1;
const DEPENDENCY_RELATION_OPTIONAL: u32 = 2;
pub(crate) const DEPENDENCY_RELATION_REQUIRED: u32 = 3;
const DEPENDENCY_RELATION_TOOL: u32 = 4;
const DEPENDENCY_RELATION_INCOMPATIBLE: u32 = 5;
pub(crate) const DEPENDENCY_RELATION_INCLUDE: u32 = 6;
pub(crate) const FABRIC_API_CURSEFORGE_PROJECT_ID: u32 = 306612;
pub(crate) const QUILTED_FABRIC_API_CURSEFORGE_PROJECT_ID: u32 = 634179;
const CURSEFORGE_LOADER_QUILT: u32 = 5;
const MAX_DEPENDENCY_DEPTH: usize = 32;
const DEPENDENCY_PLAN_TTL: Duration = Duration::from_secs(10 * 60);
const OVERRIDE_EXTRACTION_CONCURRENCY: usize = 4;
static UNAUTHORIZED: AtomicBool = AtomicBool::new(false);
static CATEGORY_CACHE: LazyLock<RwLock<Option<Vec<CurseForgeCategory>>>> =
LazyLock::new(|| RwLock::new(None));
static MANUAL_IMPORT_SCAN_LOCK: LazyLock<tokio::sync::Mutex<()>> =
LazyLock::new(|| tokio::sync::Mutex::new(()));
static DEPENDENCY_RESOLUTION_PLANS: LazyLock<
DashMap<String, CachedDependencyResolutionPlan>,
> = LazyLock::new(DashMap::new);
fn unique_metadata_chunks(mut ids: Vec<u32>) -> Vec<Vec<u32>> {
ids.sort_unstable();
ids.dedup();
ids.chunks(MAX_PAGE_SIZE as usize)
.map(<[u32]>::to_vec)
.collect()
}
async fn get_modpack_projects(
project_ids: Vec<u32>,
) -> crate::Result<HashMap<u32, CurseForgeProject>> {
let chunks = unique_metadata_chunks(project_ids);
let project_ids = chunks.iter().flatten().copied().collect::<Vec<_>>();
let batches = stream::iter(chunks)
.map(|chunk| async move { get_projects(chunk).await })
.buffer_unordered(MODPACK_METADATA_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut projects = HashMap::new();
for batch in batches {
for project in batch? {
projects.insert(project.id, project);
}
}
let missing = project_ids
.into_iter()
.filter(|project_id| !projects.contains_key(project_id))
.collect::<Vec<_>>();
let fallbacks = stream::iter(missing)
.map(|project_id| async move { get_project(project_id).await })
.buffer_unordered(MODPACK_METADATA_CONCURRENCY)
.collect::<Vec<_>>()
.await;
for project in fallbacks {
let project = project?;
projects.insert(project.id, project);
}
Ok(projects)
}
async fn get_modpack_files(
file_ids: Vec<u32>,
) -> crate::Result<HashMap<u32, CurseForgeFile>> {
let chunks = unique_metadata_chunks(file_ids);
let batches = stream::iter(chunks)
.map(|chunk| async move { get_files_many(chunk).await })
.buffer_unordered(MODPACK_METADATA_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut files = HashMap::new();
for batch in batches {
for file in batch? {
files.insert(file.id, file);
}
}
Ok(files)
}
#[derive(Clone)]
struct CachedDependencyResolutionPlan {
plan: DependencyResolutionPlan,
expires_at: Instant,
}
#[derive(Default)]
struct CurseForgeDownloadMetrics {
source: Mutex<Option<String>>,
fallback_count: AtomicU64,
reporter: Option<InstallProgressReporter>,
}
impl CurseForgeDownloadMetrics {
fn with_reporter(reporter: InstallProgressReporter) -> Self {
Self {
reporter: Some(reporter),
..Self::default()
}
}
fn record(&self, result: &DownloadResult) {
if result.attempts > 0
&& let Ok(mut source) = self.source.lock()
{
*source = Some(result.source.as_str().to_string());
}
self.fallback_count
.fetch_add(result.fallback_count as u64, Ordering::Relaxed);
}
async fn finish(
&self,
reporter: &InstallProgressReporter,
) -> crate::Result<()> {
let source = self.source.lock().ok().and_then(|source| source.clone());
if let Some(source) = source {
reporter
.record_download_metrics(
source,
self.fallback_count.load(Ordering::Relaxed),
)
.await?;
}
Ok(())
}
}
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.user_agent(crate::launcher_user_agent())
.no_proxy()
.build()
.expect("CurseForge client configuration should be valid")
});
static PROXY_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.user_agent(crate::launcher_user_agent())
.build()
.expect("CurseForge proxy client configuration should be valid")
});
#[cfg(debug_assertions)]
static LOCAL_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(15))
.read_timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.user_agent(crate::launcher_user_agent())
.no_proxy()
.build()
.expect("Local CurseForge client configuration should be valid")
});
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CurseForgeCapabilityStatus {
MissingKey,
Ready,
Unauthorized,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CurseForgeCapability {
pub status: CurseForgeCapabilityStatus,
pub configured: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgePagination {
pub index: u32,
pub page_size: u32,
pub result_count: u32,
pub total_count: u32,
}
#[derive(Clone, Debug, Deserialize)]
struct CurseForgeResponse<T> {
data: T,
#[serde(default)]
pagination: Option<CurseForgePagination>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeSearchRequest {
pub class_id: u32,
#[serde(default)]
pub category_id: Option<u32>,
#[serde(default)]
pub category_ids: Vec<u32>,
#[serde(default)]
pub search_filter: Option<String>,
#[serde(default)]
pub game_version: Option<String>,
#[serde(default)]
pub mod_loader_type: Option<u32>,
#[serde(default)]
pub sort_field: Option<u32>,
#[serde(default)]
pub sort_order: Option<String>,
#[serde(default)]
pub index: u32,
#[serde(default = "default_page_size")]
pub page_size: u32,
}
fn default_page_size() -> u32 {
20
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UnifiedSearchResponse {
pub provider: ContentProvider,
pub hits: Vec<UnifiedSearchHit>,
pub offset: u32,
pub limit: u32,
pub total_hits: u32,
}
fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::Deserialize<'de> + Default,
{
Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UnifiedSearchHit {
pub provider: ContentProvider,
pub project_id: String,
pub slug: Option<String>,
pub author: String,
pub author_url: Option<String>,
pub title: String,
pub description: String,
pub project_type: String,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub categories: Vec<String>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub versions: Vec<String>,
pub downloads: u64,
pub icon_url: Option<String>,
pub date_created: String,
pub date_modified: String,
pub latest_version: Option<String>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub gallery: Vec<String>,
pub website_url: Option<String>,
pub source_url: Option<String>,
pub allow_mod_distribution: Option<bool>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeAuthor {
pub id: u32,
pub name: String,
pub url: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeLinks {
pub website_url: Option<String>,
pub wiki_url: Option<String>,
pub issues_url: Option<String>,
pub source_url: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeAsset {
pub id: u32,
pub mod_id: u32,
pub title: String,
pub description: String,
pub thumbnail_url: String,
pub url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeCategory {
pub id: u32,
pub game_id: u32,
pub name: String,
pub slug: String,
pub url: String,
pub icon_url: Option<String>,
pub date_modified: String,
#[serde(default)]
pub is_class: Option<bool>,
pub class_id: Option<u32>,
pub parent_category_id: Option<u32>,
pub display_index: Option<i32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeFileIndex {
pub game_version: String,
pub file_id: u32,
pub filename: String,
pub release_type: u32,
pub game_version_type_id: Option<u32>,
pub mod_loader: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeProject {
pub id: u32,
pub game_id: u32,
pub name: String,
pub slug: String,
#[serde(default)]
pub links: CurseForgeLinks,
pub summary: String,
pub status: u32,
pub download_count: u64,
pub is_featured: bool,
pub primary_category_id: u32,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub categories: Vec<CurseForgeCategory>,
pub class_id: Option<u32>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub authors: Vec<CurseForgeAuthor>,
pub logo: Option<CurseForgeAsset>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub screenshots: Vec<CurseForgeAsset>,
pub main_file_id: u32,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub latest_files: Vec<CurseForgeFile>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub latest_files_indexes: Vec<CurseForgeFileIndex>,
pub date_created: String,
pub date_modified: String,
pub date_released: String,
pub allow_mod_distribution: Option<bool>,
pub game_popularity_rank: Option<i32>,
pub is_available: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeFileHash {
pub value: String,
pub algo: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeFileDependency {
pub mod_id: u32,
pub relation_type: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeSortableGameVersion {
pub game_version_name: String,
pub game_version_padded: Option<String>,
pub game_version: Option<String>,
pub game_version_release_date: Option<String>,
pub game_version_type_id: Option<u32>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeFileModule {
pub name: String,
pub fingerprint: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeFile {
pub id: u32,
pub game_id: u32,
pub mod_id: u32,
pub is_available: bool,
pub display_name: String,
pub file_name: String,
pub release_type: u32,
pub file_status: u32,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub hashes: Vec<CurseForgeFileHash>,
pub file_date: String,
pub file_length: u64,
pub download_count: u64,
pub file_size_on_disk: Option<u64>,
pub download_url: Option<String>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub game_versions: Vec<String>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub sortable_game_versions: Vec<CurseForgeSortableGameVersion>,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub dependencies: Vec<CurseForgeFileDependency>,
pub expose_as_alternative: Option<bool>,
pub parent_project_file_id: Option<u32>,
pub alternate_file_id: Option<u32>,
pub is_server_pack: Option<bool>,
pub server_pack_file_id: Option<u32>,
pub is_early_access_content: Option<bool>,
pub early_access_end_date: Option<String>,
pub file_fingerprint: u64,
#[serde(default, deserialize_with = "deserialize_null_default")]
pub modules: Vec<CurseForgeFileModule>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeFilesRequest {
#[serde(default)]
pub game_version: Option<String>,
#[serde(default)]
pub mod_loader_type: Option<u32>,
#[serde(default)]
pub game_version_type_id: Option<u32>,
#[serde(default)]
pub index: u32,
#[serde(default = "default_page_size")]
pub page_size: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CurseForgeFilesResponse {
pub files: Vec<CurseForgeFile>,
pub pagination: CurseForgePagination,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeInstallRequest {
pub instance_id: String,
pub project_id: u32,
pub file_id: u32,
pub project_type: String,
#[serde(default)]
pub ownership_kind: crate::state::instances::ContentOwnershipKind,
#[serde(default)]
pub manual_operation_kind:
crate::state::instances::ManualDownloadOperationKind,
/// Used to select automatic dependencies. The explicitly requested file is
/// authoritative, including files pinned by a modpack manifest.
#[serde(default)]
pub game_version: Option<String>,
/// Used to select automatic dependencies, not to reject the requested file.
#[serde(default)]
pub mod_loader_type: Option<u32>,
#[serde(default)]
pub world_name: Option<String>,
#[serde(default = "default_true")]
pub install_dependencies: bool,
#[serde(default)]
pub excluded_dependency_project_ids: Vec<u32>,
#[serde(default)]
pub force_dependency_project_ids: Vec<u32>,
/// The immutable dependency selection returned by preview. It is short
/// lived and tied to the target instance revision.
#[serde(default)]
pub dependency_plan_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeWorldInstallRequest {
pub instance_id: String,
pub project_id: u32,
pub file_id: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeInstalledFile {
pub project_id: u32,
pub file_id: u32,
pub relative_path: String,
pub dependency: bool,
}
pub(crate) struct StagedCurseForgeUpgrade {
pub path: PathBuf,
pub file: CurseForgeFile,
pub project_type: ProjectType,
}
pub(crate) async fn stage_curseforge_upgrade_file(
project_id: u32,
file_id: u32,
project_type: Option<ProjectType>,
reporter: Option<&InstallProgressReporter>,
) -> crate::Result<StagedCurseForgeUpgrade> {
let file = get_file(project_id, file_id).await?;
if file.mod_id != project_id || file.id != file_id {
return Err(ErrorKind::InputError(
"CurseForge returned metadata for a different project or file"
.to_string(),
)
.into());
}
validate_file_name(&file.file_name)?;
let project = get_project(project_id).await?;
let project_type = project_type
.or_else(|| recognized_project_type(project.class_id))
.ok_or_else(|| {
ErrorKind::InputError(
"CurseForge upgrade project has an unsupported content type"
.to_string(),
)
})?;
let url =
resolve_curseforge_download_url(project_id, file_id, &project, &file)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"Selected CurseForge upgrade file requires a manual download"
.to_string(),
)
})?;
let state = State::get().await?;
let path = state
.directories
.caches_dir()
.join("content")
.join("curseforge")
.join(project_id.to_string())
.join(file_id.to_string())
.join(&file.file_name);
let tracking = path.display().to_string();
download_curseforge_path(
&url,
&file,
&path,
curseforge_content_validation(&file.file_name),
None,
reporter.map(|reporter| (reporter, tracking.as_str())),
None,
true,
)
.await?;
verify_installed_curseforge_file(&path, &file).await?;
Ok(StagedCurseForgeUpgrade {
path,
file,
project_type,
})
}
pub(crate) async fn apply_staged_curseforge_upgrade_file(
instance_id: &str,
staged: StagedCurseForgeUpgrade,
ownership_kind: crate::state::instances::ContentOwnershipKind,
relative_path: &str,
) -> crate::Result<String> {
let state = State::get().await?;
let full_path = crate::api::instance::get_full_path(instance_id)
.await?
.join(relative_path);
let _instance_lock = state.lock_instance_content(instance_id).await;
let previous_path =
crate::state::materialize_project_download(&staged.path, &full_path)
.await?;
let record_result = record_installed_curseforge_file(
instance_id,
relative_path,
&full_path,
&staged.file,
staged.project_type,
ownership_kind,
&state,
)
.await;
match record_result {
Ok(()) => {
crate::state::finalize_project_materialization(
previous_path.as_deref(),
)
.await?;
}
Err(error) => {
crate::state::restore_project_materialization(
&full_path,
previous_path.as_deref(),
)
.await?;
return Err(error);
}
}
Ok(relative_path.to_string())
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeManualDownload {
pub project_id: u32,
pub file_id: u32,
pub file_name: String,
#[serde(default)]
pub ownership_kind: crate::state::instances::ContentOwnershipKind,
#[serde(default)]
pub operation_kind: crate::state::instances::ManualDownloadOperationKind,
pub website_url: Option<String>,
#[serde(default)]
pub project_type: String,
#[serde(default)]
pub project_slug: String,
#[serde(default)]
pub target_folder: String,
#[serde(default)]
pub hashes: Vec<CurseForgeFileHash>,
#[serde(default)]
pub file_length: u64,
#[serde(default)]
pub file_fingerprint: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeManualDownloadImport {
pub project_id: u32,
pub file_id: u32,
pub relative_path: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeManualDownloadImportError {
pub project_id: u32,
pub file_id: u32,
pub message: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeManualDownloadScanResult {
pub download_directory: Option<String>,
pub imported: Vec<CurseForgeManualDownloadImport>,
pub errors: Vec<CurseForgeManualDownloadImportError>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeFailedDownload {
pub project_id: u32,
pub file_id: u32,
pub file_name: String,
pub reason: String,
}
#[derive(
Clone, Debug, Serialize, Deserialize, Eq, Ord, PartialEq, PartialOrd,
)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeSkippedDependency {
pub project_id: u32,
pub file_id: Option<u32>,
pub reason: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeInstallResult {
pub installed: Vec<CurseForgeInstalledFile>,
pub manual_downloads: Vec<CurseForgeManualDownload>,
#[serde(default)]
pub failed_downloads: Vec<CurseForgeFailedDownload>,
pub optional_dependencies: Vec<u32>,
pub incompatible_dependencies: Vec<u32>,
#[serde(default)]
pub skipped_dependencies: Vec<CurseForgeSkippedDependency>,
#[serde(default)]
pub cross_source_dependencies: Vec<CurseForgeCrossSourceDependency>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeCrossSourceDependency {
pub parent_project_id: u32,
pub parent_file_id: u32,
pub project_id: String,
pub version_id: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeWorldInstallResult {
pub world_name: Option<String>,
pub manual_download: Option<CurseForgeManualDownload>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeRecognitionResult {
pub scanned: u32,
pub matched: u32,
pub linked: Vec<CurseForgeInstalledFile>,
pub unmatched_paths: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeModpackInstallRequest {
pub instance_id: String,
pub project_id: u32,
pub file_id: u32,
#[serde(default)]
pub install_optional: bool,
#[serde(default)]
pub allow_target_change: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeModpackInstallResult {
pub content: CurseForgeInstallResult,
pub overrides_written: u32,
pub minecraft_version: String,
pub loader: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CurseForgeModpackTarget {
pub game_version: String,
pub loader: ModLoader,
pub loader_version: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CurseForgeModpackManifest {
minecraft: CurseForgeManifestMinecraft,
#[serde(default)]
files: Vec<CurseForgeManifestFile>,
#[serde(default = "default_overrides")]
overrides: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
version: Option<String>,
}
fn default_overrides() -> String {
"overrides".to_string()
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CurseForgeManifestMinecraft {
version: String,
#[serde(default)]
mod_loaders: Vec<CurseForgeManifestLoader>,
}
#[derive(Clone, Debug, Deserialize)]
struct CurseForgeManifestLoader {
id: String,
primary: bool,
}
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct CurseForgeManifestFile {
// CurseForge modpack manifests use projectID/fileID (capital ID), not projectId.
#[serde(alias = "projectID", alias = "projectId")]
pub(crate) project_id: u32,
#[serde(alias = "fileID", alias = "fileId")]
pub(crate) file_id: u32,
#[serde(default = "default_true")]
pub(crate) required: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct CurseForgePackExpectedMember {
pub project_id: u32,
pub file_id: u32,
pub project_type: ProjectType,
pub expected_relative_path: String,
pub required: bool,
pub expected_sha1: Option<String>,
pub expected_size: Option<u64>,
pub expected_fingerprint: Option<u64>,
pub manual_download: Option<CurseForgeManualDownload>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CurseForgePackExpectedOverride {
pub project_type: ProjectType,
pub expected_relative_path: String,
}
#[derive(Clone, Debug)]
pub(crate) struct CurseForgePackExpectedContent {
pub members: Vec<CurseForgePackExpectedMember>,
pub overrides: Vec<CurseForgePackExpectedOverride>,
}
pub(crate) async fn get_modpack_expected_members(
project_id: u32,
file_id: u32,
) -> crate::Result<CurseForgePackExpectedContent> {
get_modpack_expected_members_with_reporter(project_id, file_id, None).await
}
pub(crate) async fn get_modpack_expected_members_with_reporter(
project_id: u32,
file_id: u32,
reporter: Option<&InstallProgressReporter>,
) -> crate::Result<CurseForgePackExpectedContent> {
let pack_file = get_file(project_id, file_id).await?;
let project = get_project(project_id).await?;
let download_url = resolve_curseforge_download_url(
project_id,
file_id,
&project,
&pack_file,
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"The linked CurseForge pack archive requires a manual download, so membership cannot be calibrated automatically"
.to_string(),
)
})?;
let pack_details = InstallPhaseDetails::Modpack {
project_id: Some(project_id.to_string()),
version_id: Some(file_id.to_string()),
title: Some(project.name.clone()),
};
if let Some(reporter) = reporter {
let state = State::get().await?;
let item_path = state
.directories
.caches_dir()
.join("curseforge")
.join("modpacks")
.join(project_id.to_string())
.join(file_id.to_string())
.join(&pack_file.file_name)
.display()
.to_string();
reporter
.update_with_events(
InstallPhaseId::ResolvingPack,
Some(InstallProgress {
current: 0,
total: pack_file.file_length.max(1),
secondary: None,
}),
pack_details.clone(),
vec![InstallJobEventKind::ContentFileQueued {
path: item_path,
bytes_total: Some(pack_file.file_length),
max_attempts: 5,
}],
)
.await?;
reporter.persist().await?;
}
let progress_reporter = reporter.cloned();
let mut last_downloaded = 0_u64;
let mut progress = move |current: u64,
total: u64|
-> std::pin::Pin<
Box<dyn std::future::Future<Output = crate::Result<()>> + Send>,
> {
let Some(reporter) = progress_reporter.as_ref() else {
return Box::pin(async { Ok(()) });
};
let min_delta = (total / 200).max(256 * 1024);
if current < total
&& current.saturating_sub(last_downloaded) < min_delta
{
return Box::pin(async { Ok(()) });
}
last_downloaded = current;
let reporter = reporter.clone();
let details = pack_details.clone();
Box::pin(async move {
reporter
.update(
InstallPhaseId::ResolvingPack,
Some(InstallProgress {
current,
total,
secondary: None,
}),
details,
)
.await
})
};
let pack_download = download_curseforge_archive(
project_id,
file_id,
&pack_file,
&download_url,
Some(&mut progress as &mut FetchProgressFn<'_>),
reporter,
)
.await?;
let archive_path = pack_download.path;
let (manifest, overrides) = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(archive_path)?;
let mut archive =
zip::ZipArchive::new(file).map_err(modpack_zip_error)?;
let manifest = read_modpack_manifest(&mut archive)?;
let overrides = read_modpack_override_content(&mut archive, &manifest)?;
Ok::<_, crate::Error>((manifest, overrides))
})
.await??;
let file_ids = manifest
.files
.iter()
.map(|member| member.file_id)
.collect::<Vec<_>>();
let project_ids = manifest
.files
.iter()
.map(|member| member.project_id)
.collect::<Vec<_>>();
let mut files = HashMap::new();
for chunk in file_ids.chunks(50) {
for file in get_files_many(chunk.to_vec()).await? {
files.insert(file.id, file);
}
}
let mut projects = HashMap::new();
for chunk in project_ids.chunks(50) {
for project in get_projects(chunk.to_vec()).await? {
projects.insert(project.id, project);
}
}
let mut expected = Vec::with_capacity(manifest.files.len());
for member in manifest.files {
let file = files.get(&member.file_id).ok_or_else(|| {
ErrorKind::OtherError(format!(
"CurseForge file metadata {} is missing",
member.file_id
))
})?;
let project = projects.get(&member.project_id).ok_or_else(|| {
ErrorKind::OtherError(format!(
"CurseForge project metadata {} is missing",
member.project_id
))
})?;
let project_type =
managed_project_type(project_type_for_class(project.class_id))?;
validate_file_name(&file.file_name)?;
let target_folder = content_target_folder(project_type, None)?;
let manual_download_required = resolve_curseforge_download_url(
member.project_id,
member.file_id,
project,
file,
)
.await?
.is_none();
let manual_download = manual_download_required.then(|| {
manual_download_from_file(
member.project_id,
member.file_id,
file,
project,
project_type.get_name(),
target_folder.clone(),
crate::state::instances::ContentOwnershipKind::PackManaged,
crate::state::instances::ManualDownloadOperationKind::PackUpdate,
)
});
expected.push(CurseForgePackExpectedMember {
project_id: member.project_id,
file_id: member.file_id,
project_type,
expected_relative_path: format!(
"{}/{}",
target_folder, file.file_name
),
required: member.required,
expected_sha1: file
.hashes
.iter()
.find(|hash| hash.algo == 1)
.map(|hash| hash.value.clone()),
expected_size: Some(file.file_length),
expected_fingerprint: Some(file.file_fingerprint),
manual_download,
});
}
Ok(CurseForgePackExpectedContent {
members: expected,
overrides,
})
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeFingerprintMatch {
pub id: u32,
pub file: CurseForgeFile,
pub latest_files: Vec<CurseForgeFile>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeFingerprintResult {
pub is_cache_built: bool,
#[serde(default)]
pub exact_matches: Vec<CurseForgeFingerprintMatch>,
#[serde(default)]
pub exact_fingerprints: Vec<u64>,
#[serde(default)]
pub partial_matches: Vec<Value>,
#[serde(default)]
pub partial_match_fingerprints: Value,
#[serde(default)]
pub installed_fingerprints: Vec<u64>,
#[serde(default)]
pub unmatched_fingerprints: Vec<u64>,
}
pub fn capability() -> CurseForgeCapability {
let configured = api_key().is_some();
let status = if !configured {
CurseForgeCapabilityStatus::MissingKey
} else if UNAUTHORIZED.load(Ordering::Relaxed) {
CurseForgeCapabilityStatus::Unauthorized
} else {
CurseForgeCapabilityStatus::Ready
};
CurseForgeCapability { status, configured }
}
pub async fn validate_credentials() -> crate::Result<CurseForgeCapability> {
let _: CurseForgeResponse<Vec<CurseForgeProject>> = request_json(
Method::GET,
"/v1/mods/search",
vec![
("gameId".to_string(), MINECRAFT_GAME_ID.to_string()),
("classId".to_string(), "6".to_string()),
("pageSize".to_string(), "1".to_string()),
],
None,
MirrorPolicy::OfficialOnly,
)
.await?;
UNAUTHORIZED.store(false, Ordering::Relaxed);
Ok(capability())
}
pub async fn search_projects(
request: CurseForgeSearchRequest,
) -> crate::Result<UnifiedSearchResponse> {
let page_size = request.page_size.clamp(1, MAX_PAGE_SIZE);
let mut query = vec![
("gameId".to_string(), MINECRAFT_GAME_ID.to_string()),
("classId".to_string(), request.class_id.to_string()),
("index".to_string(), request.index.to_string()),
("pageSize".to_string(), page_size.to_string()),
];
if request.category_ids.is_empty() {
push_query(&mut query, "categoryId", request.category_id);
} else if request.category_ids.len() == 1 {
// Single-category requests are more widely compatible as categoryId.
push_query(
&mut query,
"categoryId",
request.category_ids.first().copied(),
);
} else {
let category_ids = request
.category_ids
.iter()
.copied()
.take(10)
.collect::<Vec<_>>();
query.push((
"categoryIds".to_string(),
serde_json::to_string(&category_ids)?,
));
}
push_query(&mut query, "searchFilter", request.search_filter);
push_query(&mut query, "gameVersion", request.game_version);
push_query(&mut query, "modLoaderType", request.mod_loader_type);
push_query(&mut query, "sortField", request.sort_field);
push_query(&mut query, "sortOrder", request.sort_order);
let response: CurseForgeResponse<Vec<CurseForgeProject>> = request_json(
Method::GET,
"/v1/mods/search",
query,
None,
MirrorPolicy::MirrorFirst,
)
.await?;
let pagination = response.pagination.unwrap_or(CurseForgePagination {
index: request.index,
page_size,
result_count: response.data.len() as u32,
total_count: response.data.len() as u32,
});
Ok(UnifiedSearchResponse {
provider: ContentProvider::CurseForge,
hits: response
.data
.into_iter()
.map(UnifiedSearchHit::from)
.collect(),
offset: pagination.index,
limit: pagination.page_size,
total_hits: pagination.total_count,
})
}
pub async fn get_project(project_id: u32) -> crate::Result<CurseForgeProject> {
let state = State::get().await?;
CachedEntry::get_curseforge_project(
&CurseForgeProjectId::new(project_id)?,
None,
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
ErrorKind::OtherError(format!(
"CurseForge project {project_id} was not found"
))
.as_error()
})
}
pub async fn get_projects(
project_ids: Vec<u32>,
) -> crate::Result<Vec<CurseForgeProject>> {
get_projects_with_cache_behaviour(project_ids, None).await
}
pub async fn get_projects_with_cache_behaviour(
project_ids: Vec<u32>,
cache_behaviour: Option<CacheBehaviour>,
) -> crate::Result<Vec<CurseForgeProject>> {
let project_ids = project_ids
.into_iter()
.map(CurseForgeProjectId::new)
.collect::<crate::Result<Vec<_>>>()?;
let state = State::get().await?;
CachedEntry::get_curseforge_project_many(
&project_ids,
cache_behaviour,
&state.pool,
&state.api_semaphore,
)
.await
}
pub(crate) async fn get_projects_uncached(
project_ids: Vec<u32>,
) -> crate::Result<Vec<CurseForgeProject>> {
if project_ids.is_empty() {
return Ok(Vec::new());
}
let mut projects = Vec::new();
for project_ids in project_ids.chunks(MAX_PAGE_SIZE as usize) {
let response: CurseForgeResponse<Vec<CurseForgeProject>> =
request_json(
Method::POST,
"/v1/mods",
Vec::new(),
Some(json!({
"modIds": project_ids,
"filterPcOnly": true
})),
MirrorPolicy::MirrorFirst,
)
.await?;
projects.extend(response.data);
}
Ok(projects)
}
pub async fn get_description(project_id: u32) -> crate::Result<String> {
let response: CurseForgeResponse<String> = request_json(
Method::GET,
&format!("/v1/mods/{project_id}/description"),
Vec::new(),
None,
MirrorPolicy::MirrorFirst,
)
.await?;
Ok(response.data)
}
/// Fetches every page of a CurseForge project's files and returns the complete
/// file list, since the API caps each page at `MAX_PAGE_SIZE` entries.
pub async fn get_files(
project_id: u32,
request: CurseForgeFilesRequest,
) -> crate::Result<CurseForgeFilesResponse> {
let page_size = request.page_size.clamp(1, MAX_PAGE_SIZE);
let mut files = Vec::new();
let mut index = request.index;
let total_count = loop {
let mut query = vec![
("index".to_string(), index.to_string()),
("pageSize".to_string(), page_size.to_string()),
];
push_query(&mut query, "gameVersion", request.game_version.clone());
push_query(&mut query, "modLoaderType", request.mod_loader_type);
push_query(
&mut query,
"gameVersionTypeId",
request.game_version_type_id,
);
let response: CurseForgeResponse<Vec<CurseForgeFile>> = request_json(
Method::GET,
&format!("/v1/mods/{project_id}/files"),
query,
None,
MirrorPolicy::MirrorFirst,
)
.await?;
let pagination = response.pagination.unwrap_or(CurseForgePagination {
index,
page_size,
result_count: response.data.len() as u32,
total_count: response.data.len() as u32,
});
files.extend(response.data);
if files.len() as u32 >= pagination.total_count
|| pagination.result_count == 0
{
break pagination.total_count;
}
index += pagination.result_count;
};
let result_count = files.len() as u32;
Ok(CurseForgeFilesResponse {
files,
pagination: CurseForgePagination {
index: request.index,
page_size,
result_count,
total_count,
},
})
}
pub async fn get_file(
project_id: u32,
file_id: u32,
) -> crate::Result<CurseForgeFile> {
let response: CurseForgeResponse<CurseForgeFile> = request_json(
Method::GET,
&format!("/v1/mods/{project_id}/files/{file_id}"),
Vec::new(),
None,
MirrorPolicy::MirrorFirst,
)
.await?;
Ok(response.data)
}
pub async fn get_files_many(
file_ids: Vec<u32>,
) -> crate::Result<Vec<CurseForgeFile>> {
let response: CurseForgeResponse<Vec<CurseForgeFile>> = request_json(
Method::POST,
"/v1/mods/files",
Vec::new(),
Some(json!({ "fileIds": file_ids })),
MirrorPolicy::MirrorFirst,
)
.await?;
Ok(response.data)
}
pub async fn get_changelog(
project_id: u32,
file_id: u32,
) -> crate::Result<String> {
let response: CurseForgeResponse<String> = request_json(
Method::GET,
&format!("/v1/mods/{project_id}/files/{file_id}/changelog"),
Vec::new(),
None,
MirrorPolicy::MirrorFirst,
)
.await?;
Ok(response.data)
}
pub async fn get_download_url(
project_id: u32,
file_id: u32,
) -> crate::Result<Option<String>> {
let path = format!("/v1/mods/{project_id}/files/{file_id}/download-url");
let response: CurseForgeResponse<Option<String>> = request_json(
Method::GET,
&path,
Vec::new(),
None,
MirrorPolicy::MirrorFirst,
)
.await?;
if let Some(url) = normalized_download_url(response.data) {
return Ok(Some(url));
}
if api_key().is_none() {
return Ok(None);
}
let response: CurseForgeResponse<Option<String>> = request_json(
Method::GET,
&path,
Vec::new(),
None,
MirrorPolicy::OfficialOnly,
)
.await?;
Ok(normalized_download_url(response.data))
}
fn normalized_download_url(url: Option<String>) -> Option<String> {
url.and_then(|url| {
let url = url.trim();
(!url.is_empty()).then(|| url.to_string())
})
}
async fn resolve_curseforge_download_url(
project_id: u32,
file_id: u32,
project: &CurseForgeProject,
file: &CurseForgeFile,
) -> crate::Result<Option<String>> {
let state = State::get().await?;
let bypass_restrictions = state.bypass_curseforge_download_restrictions();
if !bypass_restrictions && project.allow_mod_distribution == Some(false) {
return Ok(None);
}
if let Some(url) = normalized_download_url(file.download_url.clone()) {
return Ok(Some(url));
}
if bypass_restrictions {
return Ok(Some(derived_curseforge_download_url(
file_id,
&file.file_name,
)?));
}
get_download_url(project_id, file_id).await
}
fn derived_curseforge_download_url(
file_id: u32,
file_name: &str,
) -> crate::Result<String> {
validate_file_name(file_name)?;
Ok(format!(
"https://edge.forgecdn.net/files/{}/{}/{}",
file_id / 1000,
file_id % 1000,
urlencoding::encode(file_name)
))
}
pub async fn get_categories(
class_id: Option<u32>,
) -> crate::Result<Vec<CurseForgeCategory>> {
let cached = CATEGORY_CACHE.read().ok().and_then(|cache| cache.clone());
let categories = if let Some(categories) = cached {
categories
} else {
let response: CurseForgeResponse<Vec<CurseForgeCategory>> =
request_json(
Method::GET,
"/v1/categories",
vec![("gameId".to_string(), MINECRAFT_GAME_ID.to_string())],
None,
MirrorPolicy::MirrorFirst,
)
.await?;
if let Ok(mut cache) = CATEGORY_CACHE.write() {
*cache = Some(response.data.clone());
}
response.data
};
Ok(filter_categories(categories, class_id))
}
pub async fn match_fingerprints(
fingerprints: Vec<u64>,
) -> crate::Result<CurseForgeFingerprintResult> {
let response: CurseForgeResponse<CurseForgeFingerprintResult> =
request_json(
Method::POST,
&format!("/v1/fingerprints/{MINECRAFT_GAME_ID}"),
Vec::new(),
Some(json!({ "fingerprints": fingerprints })),
MirrorPolicy::MirrorFirst,
)
.await?;
Ok(response.data)
}
pub async fn install_file(
request: CurseForgeInstallRequest,
) -> crate::Result<CurseForgeInstallResult> {
install_file_with_metrics(request, None).await
}
pub async fn install_file_with_reporter(
request: CurseForgeInstallRequest,
reporter: InstallProgressReporter,
) -> crate::Result<CurseForgeInstallResult> {
let metrics = CurseForgeDownloadMetrics::with_reporter(reporter.clone());
let result = install_file_with_metrics(request, Some(&metrics)).await?;
metrics.finish(&reporter).await?;
Ok(result)
}
pub async fn install_world_with_reporter(
request: CurseForgeWorldInstallRequest,
reporter: InstallProgressReporter,
) -> crate::Result<CurseForgeWorldInstallResult> {
let state = State::get().await?;
let instance =
crate::state::get_instance(&request.instance_id, &state.pool)
.await?
.ok_or_else(|| {
ErrorKind::InputError("Unknown instance".to_string())
})?;
if instance.instance.install_stage != InstanceInstallStage::Installed {
return Err(ErrorKind::InputError(
"Maps can only be added to an installed instance".to_string(),
)
.into());
}
let project = get_project(request.project_id).await?;
if project.id != request.project_id || project.class_id != Some(17) {
return Err(ErrorKind::InputError(
"The selected CurseForge project is not a Minecraft world"
.to_string(),
)
.into());
}
let file = get_file(request.project_id, request.file_id).await?;
if file.mod_id != request.project_id {
return Err(ErrorKind::InputError(
"CurseForge returned a file for a different project".to_string(),
)
.into());
}
validate_world_archive_name(&file.file_name)?;
let download_url = resolve_curseforge_download_url(
request.project_id,
request.file_id,
&project,
&file,
)
.await?;
let Some(download_url) = download_url else {
let manual_download = manual_download_from_file(
request.project_id,
request.file_id,
&file,
&project,
"world",
"saves".to_string(),
crate::state::instances::ContentOwnershipKind::UserAdded,
crate::state::instances::ManualDownloadOperationKind::ContentInstall,
);
persist_manual_download(&request.instance_id, &manual_download).await?;
return Ok(CurseForgeWorldInstallResult {
manual_download: Some(manual_download),
..Default::default()
});
};
let staging_directory = tempfile::tempdir()?;
let staging_path = staging_directory.path().join(&file.file_name);
let tracking_path =
format!("worlds/{}/{}", request.project_id, request.file_id);
download_curseforge_path(
&download_url,
&file,
&staging_path,
curseforge_content_validation(&file.file_name),
None,
Some((&reporter, &tracking_path)),
None,
true,
)
.await?;
let world_name = crate::state::instances::commands::import_world_save(
&state,
&request.instance_id,
&staging_path,
None,
)
.await?;
Ok(CurseForgeWorldInstallResult {
world_name: Some(world_name),
..Default::default()
})
}
#[derive(Clone, Copy)]
struct PendingCurseForgeFile {
project_id: u32,
file_id: u32,
item_type: ProjectType,
dependency: bool,
parent_project_id: Option<u32>,
parent_file_id: Option<u32>,
dependency_kind: Option<crate::state::instances::ContentDependencyKind>,
depth: usize,
}
#[derive(Clone)]
struct PreviewPendingCurseForgeFile {
project_id: u32,
file_id: u32,
item_type: ProjectType,
depth: usize,
ancestors: HashSet<(u32, u32)>,
}
#[derive(Clone)]
struct CurseForgeDependencyEdgeCandidate {
parent_project_id: u32,
parent_file_id: u32,
child_project_id: u32,
child_file_id: u32,
dependency_kind: crate::state::instances::ContentDependencyKind,
}
#[derive(Clone)]
struct ModrinthFallbackPlan {
parent_project_id: u32,
parent_file_id: u32,
plan: modrinth_content_management::ResolveContentPlan,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeModrinthFallbackPreview {
pub project_id: String,
pub version_id: String,
pub title: String,
pub version_number: String,
pub parent_project_id: u32,
#[serde(default)]
pub icon_url: Option<String>,
#[serde(default = "default_true")]
pub required: bool,
}
async fn install_file_with_metrics(
request: CurseForgeInstallRequest,
download_metrics: Option<&CurseForgeDownloadMetrics>,
) -> crate::Result<CurseForgeInstallResult> {
let state = State::get().await?;
let project_type = managed_project_type(&request.project_type)?;
if let Some(plan) =
load_dependency_resolution_plan(&request, &state).await?
{
let result = install_file_from_resolution_plan(
&request,
&plan,
download_metrics,
&state,
)
.await;
if result.is_ok() {
DEPENDENCY_RESOLUTION_PLANS.remove(&plan.id);
}
return result;
}
let mut result = CurseForgeInstallResult::default();
let mut visited = HashSet::new();
let mut projects = HashMap::<u32, CurseForgeProject>::new();
let mut edge_candidates = Vec::new();
let mut modrinth_fallbacks = Vec::new();
let mut fallback_parents = HashSet::new();
let installed_project_ids =
crate::state::get_installed_project_ids_for_instance(
&request.instance_id,
None,
&state,
)
.await?;
let mut pending = vec![PendingCurseForgeFile {
project_id: request.project_id,
file_id: request.file_id,
item_type: project_type,
dependency: false,
parent_project_id: None,
parent_file_id: None,
dependency_kind: None,
depth: 0,
}];
while let Some(pending_file) = pending.pop() {
if !visited.insert((pending_file.project_id, pending_file.file_id)) {
continue;
}
let project_id = pending_file.project_id;
let file_id = pending_file.file_id;
let item_type = pending_file.item_type;
let dependency = pending_file.dependency;
let file = get_file(project_id, file_id).await?;
let project = match projects.get(&project_id) {
Some(project) => project.clone(),
None => {
let project = get_project(project_id).await?;
projects.insert(project_id, project.clone());
project
}
};
if request.install_dependencies
&& pending_file.depth < MAX_DEPENDENCY_DEPTH
{
for dependency_ref in &file.dependencies {
match dependency_ref.relation_type {
DEPENDENCY_RELATION_EMBEDDED | DEPENDENCY_RELATION_TOOL => {
result.skipped_dependencies.push(
CurseForgeSkippedDependency {
project_id: dependency_ref.mod_id,
file_id: None,
reason: match dependency_ref.relation_type {
DEPENDENCY_RELATION_EMBEDDED => {
"embedded".to_string()
}
_ => "tool".to_string(),
},
},
)
}
DEPENDENCY_RELATION_INCOMPATIBLE => result
.incompatible_dependencies
.push(dependency_ref.mod_id),
DEPENDENCY_RELATION_OPTIONAL
| DEPENDENCY_RELATION_INCLUDE
| DEPENDENCY_RELATION_REQUIRED => {
let dependency_project_id = if request.mod_loader_type
== Some(CURSEFORGE_LOADER_QUILT)
&& dependency_ref.mod_id
== FABRIC_API_CURSEFORGE_PROJECT_ID
{
QUILTED_FABRIC_API_CURSEFORGE_PROJECT_ID
} else {
dependency_ref.mod_id
};
if request
.excluded_dependency_project_ids
.contains(&dependency_project_id)
{
result.skipped_dependencies.push(
CurseForgeSkippedDependency {
project_id: dependency_ref.mod_id,
file_id: None,
reason: "excluded_by_user".to_string(),
},
);
continue;
}
if installed_project_ids.contains(&format!(
"curseforge:{dependency_project_id}"
)) && !request
.force_dependency_project_ids
.contains(&dependency_project_id)
{
result.skipped_dependencies.push(
CurseForgeSkippedDependency {
project_id: dependency_ref.mod_id,
file_id: None,
reason: "already_installed".to_string(),
},
);
continue;
}
let dependency_project = match projects
.get(&dependency_project_id)
{
Some(project) => project.clone(),
None => {
let project =
get_project(dependency_project_id).await?;
projects.insert(
dependency_project_id,
project.clone(),
);
project
}
};
let Some(dependency_type) = recognized_project_type(
dependency_project.class_id,
) else {
result.failed_downloads.push(
CurseForgeFailedDownload {
project_id: dependency_project_id,
file_id: 0,
file_name: dependency_project.name.clone(),
reason:
"The dependency project type is not supported"
.to_string(),
},
);
continue;
};
if let Some(selected) = select_dependency_file(
dependency_project_id,
request.game_version.clone(),
request.mod_loader_type,
)
.await?
{
let dependency_file = selected.file;
pending.push(PendingCurseForgeFile {
project_id: dependency_project_id,
file_id: dependency_file.id,
item_type: dependency_type,
dependency: true,
parent_project_id: Some(project_id),
parent_file_id: Some(file_id),
dependency_kind: Some(
if dependency_ref.relation_type
== DEPENDENCY_RELATION_REQUIRED
{
crate::state::instances::ContentDependencyKind::Required
} else {
crate::state::instances::ContentDependencyKind::Include
},
),
depth: pending_file.depth + 1,
});
} else if fallback_parents.insert((project_id, file_id))
{
match resolve_modrinth_fallback_plan(
&file, item_type, &request, &state,
)
.await
{
Ok(Some(plan))
if !plan.dependencies.is_empty() =>
{
modrinth_fallbacks.push(
ModrinthFallbackPlan {
parent_project_id: project_id,
parent_file_id: file_id,
plan,
},
);
}
Ok(Some(_)) | Ok(None) => {
result.skipped_dependencies.push(
CurseForgeSkippedDependency {
project_id: dependency_project_id,
file_id: None,
reason: "no_compatible_version"
.to_string(),
},
);
}
Err(error) => {
tracing::warn!(
project_id = dependency_project_id,
parent_project_id = project_id,
parent_file_id = file_id,
"Modrinth SHA-1 dependency fallback failed: {error}"
);
result.skipped_dependencies.push(
CurseForgeSkippedDependency {
project_id: dependency_project_id,
file_id: None,
reason: "modrinth_lookup_failed"
.to_string(),
},
);
}
}
} else {
result.skipped_dependencies.push(
CurseForgeSkippedDependency {
project_id: dependency_project_id,
file_id: None,
reason: "no_compatible_version".to_string(),
},
);
}
}
_ => {}
}
}
} else if request.install_dependencies {
result
.skipped_dependencies
.push(CurseForgeSkippedDependency {
project_id,
file_id: Some(file_id),
reason: "dependency_depth_exceeded".to_string(),
});
}
let download_url = resolve_curseforge_download_url(
project_id, file_id, &project, &file,
)
.await?;
let Some(download_url) = download_url else {
let target_folder = content_target_folder(
item_type,
request.world_name.as_deref(),
)?;
let manual_download = manual_download_from_file(
project_id,
file_id,
&file,
&project,
item_type.get_name(),
target_folder,
request.ownership_kind,
request.manual_operation_kind,
);
persist_manual_download(&request.instance_id, &manual_download)
.await?;
result.manual_downloads.push(manual_download);
continue;
};
validate_file_name(&file.file_name)?;
let relative_path = download_installed_file(
&request.instance_id,
&download_url,
&file,
item_type,
request.world_name.as_deref(),
project_id,
file_id,
&project.slug,
request.ownership_kind,
download_metrics,
)
.await?;
result.installed.push(CurseForgeInstalledFile {
project_id,
file_id,
relative_path: relative_path.clone(),
dependency,
});
if let (
Some(parent_project_id),
Some(parent_file_id),
Some(dependency_kind),
) = (
pending_file.parent_project_id,
pending_file.parent_file_id,
pending_file.dependency_kind,
) {
edge_candidates.push(CurseForgeDependencyEdgeCandidate {
parent_project_id,
parent_file_id,
child_project_id: project_id,
child_file_id: file_id,
dependency_kind,
});
}
}
install_modrinth_fallbacks(
&request.instance_id,
&modrinth_fallbacks,
&mut result,
&state,
)
.await?;
result.optional_dependencies.sort_unstable();
result.optional_dependencies.dedup();
result.incompatible_dependencies.sort_unstable();
result.incompatible_dependencies.dedup();
result.skipped_dependencies.sort_unstable();
result.skipped_dependencies.dedup();
persist_curseforge_dependency_edges(
&request.instance_id,
&edge_candidates,
&state,
)
.await?;
Ok(result)
}
async fn load_dependency_resolution_plan(
request: &CurseForgeInstallRequest,
state: &State,
) -> crate::Result<Option<DependencyResolutionPlan>> {
let Some(plan_id) = request.dependency_plan_id.as_deref() else {
return Ok(None);
};
let Some(cached) = DEPENDENCY_RESOLUTION_PLANS
.get(plan_id)
.map(|cached| cached.clone())
else {
return Err(ErrorKind::InputError(
"The dependency resolution plan has expired".to_string(),
)
.into());
};
if cached.expires_at <= Instant::now() {
DEPENDENCY_RESOLUTION_PLANS.remove(plan_id);
return Err(ErrorKind::InputError(
"The dependency resolution plan has expired".to_string(),
)
.into());
}
let expected_primary =
curseforge_content_ref(request.project_id, request.file_id)?;
if cached.plan.instance_id != request.instance_id
|| cached.plan.primary != expected_primary
|| cached.plan.target.minecraft_version != request.game_version
|| cached.plan.target.loader
!= request
.mod_loader_type
.map(mod_loader_to_slug)
.map(str::to_string)
|| cached.plan.target.content_type.as_deref()
!= Some(&request.project_type)
{
return Err(ErrorKind::InputError(
"The dependency resolution plan does not match this install request"
.to_string(),
)
.into());
}
let current_revision =
instance_content_revision(&request.instance_id, state).await?;
if cached.plan.instance_revision != current_revision {
DEPENDENCY_RESOLUTION_PLANS.remove(plan_id);
return Err(ErrorKind::InputError(
"The instance content changed after dependency preview; preview again"
.to_string(),
)
.into());
}
Ok(Some(cached.plan))
}
async fn install_file_from_resolution_plan(
request: &CurseForgeInstallRequest,
plan: &DependencyResolutionPlan,
download_metrics: Option<&CurseForgeDownloadMetrics>,
state: &State,
) -> crate::Result<CurseForgeInstallResult> {
let primary_type = managed_project_type(&request.project_type)?;
let mut result = CurseForgeInstallResult::default();
let mut installed = HashSet::new();
let mut failed = HashSet::new();
match install_fixed_curseforge_content(
request,
request.project_id,
request.file_id,
primary_type,
false,
plan.primary_expected_sha1.as_deref(),
plan.primary_expected_size,
download_metrics,
&mut result,
)
.await
{
Ok(true) => {
installed.insert(plan.primary.clone());
}
Ok(false) => {
failed.insert(plan.primary.clone());
}
Err(error) => {
result.failed_downloads.push(CurseForgeFailedDownload {
project_id: request.project_id,
file_id: request.file_id,
file_name: format!(
"CurseForge {}:{}",
request.project_id, request.file_id
),
reason: error.to_string(),
});
failed.insert(plan.primary.clone());
}
}
let known_refs = std::iter::once(plan.primary.clone())
.chain(plan.nodes.iter().map(|node| node.content.clone()))
.collect::<HashSet<_>>();
let excluded_project_ids = request
.excluded_dependency_project_ids
.iter()
.copied()
.collect::<HashSet<_>>();
let mut pending = Vec::new();
for node in plan.nodes.clone() {
let excluded = match &node.content {
ContentProviderRef::CurseForge { project_id, .. } => {
excluded_project_ids.contains(&project_id.get())
}
ContentProviderRef::Modrinth { .. } => node
.parent
.as_ref()
.and_then(curseforge_project_id)
.is_some_and(|project_id| {
excluded_project_ids.contains(&project_id)
}),
ContentProviderRef::McArchive { .. } => false,
};
if excluded {
result
.skipped_dependencies
.push(CurseForgeSkippedDependency {
project_id: curseforge_project_id(&node.content)
.or_else(|| {
node.parent.as_ref().and_then(curseforge_project_id)
})
.unwrap_or_default(),
file_id: curseforge_file_id(&node.content).or_else(|| {
node.parent.as_ref().and_then(curseforge_file_id)
}),
reason: "excluded_by_user".to_string(),
});
failed.insert(node.content);
} else {
pending.push(node);
}
}
while !pending.is_empty() {
let mut progressed = false;
let mut index = 0;
while index < pending.len() {
let node = &pending[index];
let parent_ready = node
.parent
.as_ref()
.is_none_or(|parent| installed.contains(parent));
let parent_failed = node.parent.as_ref().is_some_and(|parent| {
failed.contains(parent) || !known_refs.contains(parent)
});
if parent_failed {
result
.skipped_dependencies
.push(CurseForgeSkippedDependency {
project_id: curseforge_project_id(&node.content)
.unwrap_or_default(),
file_id: curseforge_file_id(&node.content),
reason: "parent_not_installed".to_string(),
});
failed.insert(node.content.clone());
pending.remove(index);
progressed = true;
continue;
}
if !parent_ready {
index += 1;
continue;
}
let node = pending.remove(index);
let installed_node = match &node.content {
ContentProviderRef::CurseForge {
project_id,
file_id,
} => {
if let Some(file_id) = file_id {
let project_id = project_id.get();
let project = get_project(project_id).await?;
if let Some(project_type) =
recognized_project_type(project.class_id)
{
match install_fixed_curseforge_content(
request,
project_id,
file_id.get(),
project_type,
true,
node.expected_sha1.as_deref(),
node.expected_size,
download_metrics,
&mut result,
)
.await
{
Ok(installed) => installed,
Err(error) => {
result.failed_downloads.push(
CurseForgeFailedDownload {
project_id,
file_id: file_id.get(),
file_name: project.name,
reason: error.to_string(),
},
);
false
}
}
} else {
result.skipped_dependencies.push(
CurseForgeSkippedDependency {
project_id,
file_id: Some(file_id.get()),
reason: "unsupported_project_type"
.to_string(),
},
);
false
}
} else {
result.skipped_dependencies.push(
CurseForgeSkippedDependency {
project_id: project_id.get(),
file_id: None,
reason: "missing_version".to_string(),
},
);
false
}
}
ContentProviderRef::Modrinth { .. } => {
match install_fixed_modrinth_content(
request, plan, &node, state,
)
.await
{
Ok(()) => true,
Err(error) => {
result.failed_downloads.push(
CurseForgeFailedDownload {
project_id: curseforge_project_id(
node.parent
.as_ref()
.unwrap_or(&plan.primary),
)
.unwrap_or_default(),
file_id: curseforge_file_id(
node.parent
.as_ref()
.unwrap_or(&plan.primary),
)
.unwrap_or_default(),
file_name: format!(
"Modrinth {}",
node.content.database_project_id()
),
reason: error.to_string(),
},
);
false
}
}
}
ContentProviderRef::McArchive { .. } => {
result.skipped_dependencies.push(
CurseForgeSkippedDependency {
project_id: 0,
file_id: None,
reason: "unsupported_provider".to_string(),
},
);
false
}
};
if installed_node {
if let Some(ContentProviderRef::CurseForge {
project_id,
file_id,
}) = node.parent.as_ref()
&& let ContentProviderRef::Modrinth {
project_id: child_project,
version_id: Some(child_version),
} = &node.content
{
result.cross_source_dependencies.push(
CurseForgeCrossSourceDependency {
parent_project_id: project_id.get(),
parent_file_id: file_id
.map(CurseForgeFileId::get)
.unwrap_or_default(),
project_id: child_project.to_string(),
version_id: child_version.to_string(),
},
);
}
installed.insert(node.content);
} else {
failed.insert(node.content);
}
progressed = true;
}
if !progressed {
for node in pending.drain(..) {
result
.skipped_dependencies
.push(CurseForgeSkippedDependency {
project_id: curseforge_project_id(&node.content)
.unwrap_or_default(),
file_id: curseforge_file_id(&node.content),
reason: "dependency_cycle".to_string(),
});
}
}
}
result.skipped_dependencies.sort_unstable();
result.skipped_dependencies.dedup();
persist_resolution_plan_dependency_edges(
&request.instance_id,
plan,
&installed,
state,
)
.await?;
Ok(result)
}
async fn install_fixed_curseforge_content(
request: &CurseForgeInstallRequest,
project_id: u32,
file_id: u32,
project_type: ProjectType,
dependency: bool,
expected_sha1: Option<&str>,
expected_size: Option<u64>,
download_metrics: Option<&CurseForgeDownloadMetrics>,
result: &mut CurseForgeInstallResult,
) -> crate::Result<bool> {
let file = get_file(project_id, file_id).await?;
if file.mod_id != project_id || file.id != file_id {
return Err(ErrorKind::InputError(
"CurseForge returned metadata for a different planned file"
.to_string(),
)
.into());
}
if expected_sha1.is_some_and(|expected| {
curseforge_file_sha1(&file).as_deref() != Some(expected)
}) || expected_size.is_some_and(|expected| file.file_length != expected)
{
return Err(ErrorKind::InputError(
"CurseForge file metadata changed after dependency preview"
.to_string(),
)
.into());
}
let project = get_project(project_id).await?;
let Some(download_url) =
resolve_curseforge_download_url(project_id, file_id, &project, &file)
.await?
else {
let target_folder =
content_target_folder(project_type, request.world_name.as_deref())?;
let manual_download = manual_download_from_file(
project_id,
file_id,
&file,
&project,
project_type.get_name(),
target_folder,
request.ownership_kind,
request.manual_operation_kind,
);
persist_manual_download(&request.instance_id, &manual_download).await?;
result.manual_downloads.push(manual_download);
return Ok(false);
};
validate_file_name(&file.file_name)?;
let relative_path = download_installed_file(
&request.instance_id,
&download_url,
&file,
project_type,
request.world_name.as_deref(),
project_id,
file_id,
&project.slug,
request.ownership_kind,
download_metrics,
)
.await?;
result.installed.push(CurseForgeInstalledFile {
project_id,
file_id,
relative_path,
dependency,
});
Ok(true)
}
async fn install_fixed_modrinth_content(
request: &CurseForgeInstallRequest,
plan: &DependencyResolutionPlan,
node: &DependencyResolutionNode,
state: &State,
) -> crate::Result<()> {
let ContentProviderRef::Modrinth {
project_id,
version_id: Some(version_id),
} = &node.content
else {
return Err(ErrorKind::InputError(
"The planned Modrinth dependency has no exact version".to_string(),
)
.into());
};
let version = CachedEntry::get_version(
version_id,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"The planned Modrinth dependency is no longer available"
.to_string(),
)
})?;
if version.project_id != project_id.as_str()
|| !modrinth_version_matches_plan_target(&version, &plan.target)
{
return Err(ErrorKind::InputError(
"The planned Modrinth dependency no longer matches the instance target"
.to_string(),
)
.into());
}
let file = version
.files
.iter()
.find(|file| file.primary)
.or_else(|| version.files.first())
.ok_or_else(|| {
ErrorKind::InputError(
"The planned Modrinth dependency has no downloadable file"
.to_string(),
)
})?;
if node.expected_sha1.as_deref()
!= file.hashes.get("sha1").map(String::as_str)
|| node
.expected_size
.is_some_and(|size| file.size as u64 != size)
{
return Err(ErrorKind::InputError(
"The planned Modrinth file metadata changed after dependency preview"
.to_string(),
)
.into());
}
crate::state::instances::commands::install_resolved_dependency(
&request.instance_id,
&modrinth_content_management::ResolvedContent {
project_id: project_id.to_string(),
version_id: version_id.to_string(),
dependent_on_version_id: None,
required: true,
},
state,
)
.await?;
Ok(())
}
fn modrinth_version_matches_plan_target(
version: &crate::state::Version,
target: &DependencyResolutionTarget,
) -> bool {
target.minecraft_version.as_deref().is_none_or(|minecraft| {
version
.game_versions
.iter()
.any(|candidate| candidate == minecraft)
}) && target.loader.as_deref().is_none_or(|loader| {
version
.loaders
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(loader))
})
}
fn curseforge_project_id(reference: &ContentProviderRef) -> Option<u32> {
match reference {
ContentProviderRef::CurseForge { project_id, .. } => {
Some(project_id.get())
}
ContentProviderRef::Modrinth { .. } => None,
ContentProviderRef::McArchive { .. } => None,
}
}
fn curseforge_file_id(reference: &ContentProviderRef) -> Option<u32> {
match reference {
ContentProviderRef::CurseForge { file_id, .. } => {
file_id.map(CurseForgeFileId::get)
}
ContentProviderRef::Modrinth { .. } => None,
ContentProviderRef::McArchive { .. } => None,
}
}
async fn persist_resolution_plan_dependency_edges(
instance_id: &str,
plan: &DependencyResolutionPlan,
installed: &HashSet<ContentProviderRef>,
state: &State,
) -> crate::Result<()> {
if plan.edges.is_empty() {
return Ok(());
}
let _instance_lock = state.lock_instance_content(instance_id).await;
let scope = crate::state::instances::commands::resolve_content_scope(
instance_id,
None,
state,
)
.await?;
let mut tx = state.pool.begin().await?;
for edge in &plan.edges {
if !installed.contains(&edge.parent) || !installed.contains(&edge.child)
{
continue;
}
let (Some(parent_release), Some(child_release)) = (
edge.parent.database_release_id(),
edge.child.database_release_id(),
) else {
continue;
};
let Some(parent_entry) = crate::state::instances::adapters::sqlite::content_rows::get_content_entry_by_provider_ref(
&scope.content_set_id,
edge.parent.provider(),
&edge.parent.database_project_id(),
&parent_release,
&state.pool,
)
.await?
else {
continue;
};
let Some(child_entry) = crate::state::instances::adapters::sqlite::content_rows::get_content_entry_by_provider_ref(
&scope.content_set_id,
edge.child.provider(),
&edge.child.database_project_id(),
&child_release,
&state.pool,
)
.await?
else {
continue;
};
let now = chrono::Utc::now();
crate::state::instances::adapters::sqlite::content_rows::upsert_content_dependency_edge_in_transaction(
&crate::state::instances::ContentDependencyEdge {
id: format!("content-dependency:{}", uuid::Uuid::new_v4()),
content_set_id: scope.content_set_id.clone(),
parent_entry_id: parent_entry.id,
child_entry_id: child_entry.id,
evidence_provider: edge.evidence_provider,
parent_provider: edge.parent.provider(),
child_provider: edge.child.provider(),
dependency_kind: edge.relation,
parent_project_id: edge.parent.database_project_id(),
parent_release_id: parent_release,
child_project_id: edge.child.database_project_id(),
child_release_id: child_release,
created_at: now,
modified_at: now,
},
&mut tx,
)
.await?;
}
tx.commit().await?;
Ok(())
}
async fn persist_curseforge_dependency_edges(
instance_id: &str,
candidates: &[CurseForgeDependencyEdgeCandidate],
state: &State,
) -> crate::Result<()> {
if candidates.is_empty() {
return Ok(());
}
let _instance_lock = state.lock_instance_content(instance_id).await;
let scope = crate::state::instances::commands::resolve_content_scope(
instance_id,
None,
state,
)
.await?;
let mut tx = state.pool.begin().await?;
for candidate in candidates {
let Some(parent_entry) =
crate::state::instances::adapters::sqlite::content_rows::get_content_entry_by_provider_ref(
&scope.content_set_id,
ContentProvider::CurseForge,
&candidate.parent_project_id.to_string(),
&candidate.parent_file_id.to_string(),
&state.pool,
)
.await?
else {
continue;
};
let Some(child_entry) =
crate::state::instances::adapters::sqlite::content_rows::get_content_entry_by_provider_ref(
&scope.content_set_id,
ContentProvider::CurseForge,
&candidate.child_project_id.to_string(),
&candidate.child_file_id.to_string(),
&state.pool,
)
.await?
else {
continue;
};
crate::state::instances::adapters::sqlite::content_rows::set_content_entry_auto_dependency(
&child_entry.id,
true,
&state.pool,
)
.await?;
let now = chrono::Utc::now();
crate::state::instances::adapters::sqlite::content_rows::upsert_content_dependency_edge_in_transaction(
&crate::state::instances::ContentDependencyEdge {
id: format!("content-dependency:{}", uuid::Uuid::new_v4()),
content_set_id: scope.content_set_id.clone(),
parent_entry_id: parent_entry.id,
child_entry_id: child_entry.id,
evidence_provider: ContentProvider::CurseForge,
parent_provider: ContentProvider::CurseForge,
child_provider: ContentProvider::CurseForge,
dependency_kind: candidate.dependency_kind,
parent_project_id: candidate.parent_project_id.to_string(),
parent_release_id: candidate.parent_file_id.to_string(),
child_project_id: candidate.child_project_id.to_string(),
child_release_id: candidate.child_file_id.to_string(),
created_at: now,
modified_at: now,
},
&mut tx,
)
.await?;
}
tx.commit().await?;
Ok(())
}
async fn install_modrinth_fallbacks(
instance_id: &str,
fallbacks: &[ModrinthFallbackPlan],
result: &mut CurseForgeInstallResult,
state: &State,
) -> crate::Result<()> {
let mut installed_versions_by_parent = Vec::new();
for fallback in fallbacks {
let mut installed_versions =
HashSet::from([fallback.plan.primary.version_id.clone()]);
for dependency in &fallback.plan.dependencies {
if dependency
.dependent_on_version_id
.as_ref()
.is_some_and(|parent| !installed_versions.contains(parent))
{
continue;
}
match crate::state::instances::commands::install_resolved_dependency(
instance_id, dependency, state,
)
.await
{
Ok(_) => {
installed_versions.insert(dependency.version_id.clone());
result.cross_source_dependencies.push(
CurseForgeCrossSourceDependency {
parent_project_id: fallback.parent_project_id,
parent_file_id: fallback.parent_file_id,
project_id: dependency.project_id.clone(),
version_id: dependency.version_id.clone(),
},
);
}
Err(error) => result.failed_downloads.push(
CurseForgeFailedDownload {
project_id: fallback.parent_project_id,
file_id: fallback.parent_file_id,
file_name: format!(
"Modrinth {} {}",
dependency.project_id, dependency.version_id
),
reason: error.to_string(),
},
),
}
}
installed_versions_by_parent.push((fallback, installed_versions));
}
persist_modrinth_fallback_dependency_edges(
instance_id,
&installed_versions_by_parent,
state,
)
.await
}
async fn persist_modrinth_fallback_dependency_edges(
instance_id: &str,
fallbacks: &[(&ModrinthFallbackPlan, HashSet<String>)],
state: &State,
) -> crate::Result<()> {
if fallbacks.is_empty() {
return Ok(());
}
let _instance_lock = state.lock_instance_content(instance_id).await;
let scope = crate::state::instances::commands::resolve_content_scope(
instance_id,
None,
state,
)
.await?;
let mut tx = state.pool.begin().await?;
for (fallback, installed_versions) in fallbacks {
for dependency in &fallback.plan.dependencies {
if !installed_versions.contains(&dependency.version_id) {
continue;
}
let Some(parent_version_id) =
dependency.dependent_on_version_id.as_deref()
else {
continue;
};
let parent = if parent_version_id
== fallback.plan.primary.version_id
{
crate::state::instances::adapters::sqlite::content_rows::get_content_entry_by_provider_ref(
&scope.content_set_id,
ContentProvider::CurseForge,
&fallback.parent_project_id.to_string(),
&fallback.parent_file_id.to_string(),
&state.pool,
)
.await?
.map(|entry| (
entry,
ContentProvider::CurseForge,
fallback.parent_project_id.to_string(),
fallback.parent_file_id.to_string(),
))
} else if let Some(parent_content) = fallback
.plan
.dependencies
.iter()
.find(|content| content.version_id == parent_version_id)
{
crate::state::instances::adapters::sqlite::content_rows::get_content_entry_by_provider_ref(
&scope.content_set_id,
ContentProvider::Modrinth,
&parent_content.project_id,
&parent_content.version_id,
&state.pool,
)
.await?
.map(|entry| (
entry,
ContentProvider::Modrinth,
parent_content.project_id.clone(),
parent_content.version_id.clone(),
))
} else {
None
};
let Some((
parent_entry,
parent_provider,
parent_project_id,
parent_release_id,
)) = parent
else {
continue;
};
let Some(child_entry) = crate::state::instances::adapters::sqlite::content_rows::get_content_entry_by_provider_ref(
&scope.content_set_id,
ContentProvider::Modrinth,
&dependency.project_id,
&dependency.version_id,
&state.pool,
)
.await?
else {
continue;
};
let now = chrono::Utc::now();
crate::state::instances::adapters::sqlite::content_rows::upsert_content_dependency_edge_in_transaction(
&crate::state::instances::ContentDependencyEdge {
id: format!("content-dependency:{}", uuid::Uuid::new_v4()),
content_set_id: scope.content_set_id.clone(),
parent_entry_id: parent_entry.id,
child_entry_id: child_entry.id,
evidence_provider: ContentProvider::Modrinth,
parent_provider,
child_provider: ContentProvider::Modrinth,
dependency_kind: crate::state::instances::ContentDependencyKind::Required,
parent_project_id,
parent_release_id,
child_project_id: dependency.project_id.clone(),
child_release_id: dependency.version_id.clone(),
created_at: now,
modified_at: now,
},
&mut tx,
)
.await?;
}
}
tx.commit().await?;
Ok(())
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgePreviewItem {
pub project_id: u32,
pub file_id: u32,
pub title: String,
pub version_number: String,
pub file_name: String,
pub size: u64,
pub required_by_project_ids: Vec<u32>,
#[serde(default)]
pub icon_url: Option<String>,
#[serde(default)]
pub version_mismatch: bool,
#[serde(default)]
pub selection_reason: Option<CurseForgeDependencySelectionReason>,
#[serde(default = "default_true")]
pub required: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurseForgeInstallPreview {
pub plan_id: String,
pub primary: CurseForgePreviewItem,
pub dependencies: Vec<CurseForgePreviewItem>,
#[serde(default)]
pub modrinth_fallbacks: Vec<CurseForgeModrinthFallbackPreview>,
pub skipped: Vec<CurseForgeSkippedDependency>,
pub optional_dependencies: Vec<u32>,
pub incompatible_dependencies: Vec<u32>,
}
pub async fn preview_install_file(
request: CurseForgeInstallRequest,
) -> crate::Result<CurseForgeInstallPreview> {
let state = State::get().await?;
let primary_type = managed_project_type(&request.project_type)?;
let primary_file = get_file(request.project_id, request.file_id).await?;
let primary_project = get_project(request.project_id).await?;
let primary_ref =
curseforge_content_ref(request.project_id, request.file_id)?;
let instance_revision =
instance_content_revision(&request.instance_id, &state).await?;
let mut plan = DependencyResolutionPlan {
id: format!("dependency-resolution-plan:{}", uuid::Uuid::new_v4()),
instance_id: request.instance_id.clone(),
instance_revision,
target: DependencyResolutionTarget {
minecraft_version: request.game_version.clone(),
loader: request
.mod_loader_type
.map(mod_loader_to_slug)
.map(str::to_string),
content_type: Some(request.project_type.clone()),
},
primary: primary_ref.clone(),
primary_expected_sha1: curseforge_file_sha1(&primary_file),
primary_expected_size: Some(primary_file.file_length),
nodes: Vec::new(),
edges: Vec::new(),
issues: Vec::new(),
};
let installed_project_ids = if request.instance_id.is_empty() {
Vec::new()
} else {
crate::state::get_installed_project_ids_for_instance(
&request.instance_id,
None,
&state,
)
.await?
};
let mut visited = HashSet::new();
let mut projects = HashMap::<u32, CurseForgeProject>::new();
projects.insert(request.project_id, primary_project.clone());
let mut dependencies: Vec<CurseForgePreviewItem> = Vec::new();
let mut modrinth_fallbacks = Vec::new();
let mut fallback_parents = HashSet::new();
let mut skipped = Vec::new();
let mut optional = Vec::new();
let mut incompatible = Vec::new();
let mut pending = vec![PreviewPendingCurseForgeFile {
project_id: request.project_id,
file_id: request.file_id,
item_type: primary_type,
depth: 0,
ancestors: HashSet::new(),
}];
while let Some(pending_file) = pending.pop() {
let project_id = pending_file.project_id;
let file_id = pending_file.file_id;
let item_type = pending_file.item_type;
if !visited.insert((project_id, file_id)) {
continue;
}
let file =
if project_id == request.project_id && file_id == request.file_id {
primary_file.clone()
} else {
get_file(project_id, file_id).await?
};
if request.install_dependencies
&& pending_file.depth < MAX_DEPENDENCY_DEPTH
{
for dependency_ref in &file.dependencies {
let dependency_project_id = if request.mod_loader_type
== Some(CURSEFORGE_LOADER_QUILT)
&& dependency_ref.mod_id == FABRIC_API_CURSEFORGE_PROJECT_ID
{
QUILTED_FABRIC_API_CURSEFORGE_PROJECT_ID
} else {
dependency_ref.mod_id
};
match dependency_ref.relation_type {
DEPENDENCY_RELATION_INCOMPATIBLE => {
incompatible.push(dependency_ref.mod_id)
}
DEPENDENCY_RELATION_EMBEDDED | DEPENDENCY_RELATION_TOOL => {
skipped.push(CurseForgeSkippedDependency {
project_id: dependency_ref.mod_id,
file_id: None,
reason: if dependency_ref.relation_type
== DEPENDENCY_RELATION_EMBEDDED
{
"embedded".to_string()
} else {
"tool".to_string()
},
})
}
DEPENDENCY_RELATION_OPTIONAL
| DEPENDENCY_RELATION_INCLUDE
| DEPENDENCY_RELATION_REQUIRED => {
if request
.excluded_dependency_project_ids
.contains(&dependency_project_id)
{
skipped.push(CurseForgeSkippedDependency {
project_id: dependency_project_id,
file_id: None,
reason: "excluded_by_user".to_string(),
});
continue;
}
if installed_project_ids.contains(&format!(
"curseforge:{dependency_project_id}"
)) && !request
.force_dependency_project_ids
.contains(&dependency_project_id)
{
skipped.push(CurseForgeSkippedDependency {
project_id: dependency_project_id,
file_id: None,
reason: "already_installed".to_string(),
});
continue;
}
let dependency_project = match projects
.get(&dependency_project_id)
{
Some(project) => project.clone(),
None => {
let project =
get_project(dependency_project_id).await?;
projects.insert(
dependency_project_id,
project.clone(),
);
project
}
};
let Some(dependency_type) = recognized_project_type(
dependency_project.class_id,
) else {
skipped.push(CurseForgeSkippedDependency {
project_id: dependency_project_id,
file_id: None,
reason: "unsupported_project_type".to_string(),
});
continue;
};
let Some(selected) = select_dependency_file(
dependency_project_id,
request.game_version.clone(),
request.mod_loader_type,
)
.await?
else {
if fallback_parents.insert((project_id, file_id)) {
match resolve_modrinth_fallback_plan(
&file, item_type, &request, &state,
)
.await
{
Ok(Some(fallback_plan))
if !fallback_plan
.dependencies
.is_empty() =>
{
append_modrinth_fallback_plan(
&mut plan,
&fallback_plan,
&curseforge_content_ref(
project_id, file_id,
)?,
&state,
)
.await?;
modrinth_fallbacks.extend(
preview_modrinth_fallbacks(
&fallback_plan,
project_id,
&state,
)
.await?,
);
continue;
}
Ok(Some(_)) | Ok(None) => {}
Err(error) => {
tracing::warn!(
project_id = dependency_project_id,
parent_project_id = project_id,
parent_file_id = file_id,
"Modrinth SHA-1 dependency fallback failed: {error}"
);
skipped.push(
CurseForgeSkippedDependency {
project_id:
dependency_project_id,
file_id: None,
reason:
"modrinth_lookup_failed"
.to_string(),
},
);
continue;
}
}
}
skipped.push(CurseForgeSkippedDependency {
project_id: dependency_project_id,
file_id: None,
reason: "no_compatible_version".to_string(),
});
continue;
};
let dependency_file = selected.file;
let child_ref = curseforge_content_ref(
dependency_project_id,
dependency_file.id,
)?;
if pending_file.ancestors.contains(&(
dependency_project_id,
dependency_file.id,
)) || (dependency_project_id, dependency_file.id)
== (project_id, file_id)
{
skipped.push(CurseForgeSkippedDependency {
project_id: dependency_project_id,
file_id: Some(dependency_file.id),
reason: "dependency_cycle".to_string(),
});
continue;
}
let parent_ref =
curseforge_content_ref(project_id, file_id)?;
plan.edges.push(DependencyResolutionEdge {
parent: parent_ref.clone(),
child: child_ref.clone(),
relation: if dependency_ref.relation_type
== DEPENDENCY_RELATION_REQUIRED
{
crate::state::instances::ContentDependencyKind::Required
} else {
crate::state::instances::ContentDependencyKind::Include
},
evidence_provider: ContentProvider::CurseForge,
});
if !plan
.nodes
.iter()
.any(|node| node.content == child_ref)
{
plan.nodes.push(DependencyResolutionNode {
content: child_ref,
parent: Some(parent_ref),
relation: if dependency_ref.relation_type
== DEPENDENCY_RELATION_REQUIRED
{
crate::state::instances::ContentDependencyKind::Required
} else {
crate::state::instances::ContentDependencyKind::Include
},
source: ContentProvider::CurseForge,
selection_reason: selected.reason.into(),
expected_sha1: curseforge_file_sha1(&dependency_file),
expected_size: Some(dependency_file.file_length),
});
}
let existing = dependencies.iter_mut().find(|item| {
item.project_id == dependency_project_id
});
if let Some(existing) = existing {
existing.required |= dependency_ref.relation_type
== DEPENDENCY_RELATION_REQUIRED;
if !existing
.required_by_project_ids
.contains(&project_id)
{
existing
.required_by_project_ids
.push(project_id);
}
continue;
}
dependencies.push(CurseForgePreviewItem {
project_id: dependency_project_id,
file_id: dependency_file.id,
title: dependency_project.name.clone(),
version_number: dependency_file
.display_name
.clone(),
file_name: dependency_file.file_name.clone(),
size: dependency_file.file_length,
required_by_project_ids: vec![project_id],
icon_url: dependency_project
.logo
.as_ref()
.map(|logo| logo.thumbnail_url.clone()),
version_mismatch: false,
selection_reason: Some(selected.reason),
required: dependency_ref.relation_type
== DEPENDENCY_RELATION_REQUIRED,
});
let mut ancestors = pending_file.ancestors.clone();
ancestors.insert((project_id, file_id));
pending.push(PreviewPendingCurseForgeFile {
project_id: dependency_project_id,
file_id: dependency_file.id,
item_type: dependency_type,
depth: pending_file.depth + 1,
ancestors,
});
}
_ => {}
}
}
} else if request.install_dependencies {
skipped.push(CurseForgeSkippedDependency {
project_id,
file_id: Some(file_id),
reason: "dependency_depth_exceeded".to_string(),
});
}
}
optional.sort_unstable();
optional.dedup();
incompatible.sort_unstable();
incompatible.dedup();
skipped.sort_unstable();
skipped.dedup();
modrinth_fallbacks.sort_by(|left, right| {
left.project_id
.cmp(&right.project_id)
.then_with(|| left.version_id.cmp(&right.version_id))
});
modrinth_fallbacks.dedup_by(|left, right| {
left.project_id == right.project_id
&& left.version_id == right.version_id
});
plan.issues.extend(
skipped
.iter()
.map(|skipped| DependencyResolutionIssue {
provider: ContentProvider::CurseForge,
project_id: skipped.project_id.to_string(),
parent: None,
relation: Some(
crate::state::instances::ContentDependencyKind::Required,
),
reason: skipped.reason.clone(),
})
.collect::<Vec<_>>(),
);
store_dependency_resolution_plan(plan.clone());
Ok(CurseForgeInstallPreview {
plan_id: plan.id,
primary: CurseForgePreviewItem {
project_id: request.project_id,
file_id: request.file_id,
title: primary_project.name,
version_number: primary_file.display_name,
file_name: primary_file.file_name,
size: primary_file.file_length,
required_by_project_ids: Vec::new(),
icon_url: primary_project
.logo
.as_ref()
.map(|logo| logo.thumbnail_url.clone()),
version_mismatch: false,
selection_reason: None,
required: true,
},
dependencies,
modrinth_fallbacks,
skipped,
optional_dependencies: optional,
incompatible_dependencies: incompatible,
})
}
pub async fn install_modpack(
request: CurseForgeModpackInstallRequest,
) -> crate::Result<CurseForgeModpackInstallResult> {
install_modpack_with_reporter(request, None).await
}
pub async fn get_modpack_target(
project_id: u32,
file_id: u32,
) -> crate::Result<CurseForgeModpackTarget> {
let pack_file = get_file(project_id, file_id).await?;
let project = get_project(project_id).await?;
let download_url = resolve_curseforge_download_url(
project_id, file_id, &project, &pack_file,
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"The CurseForge modpack manifest cannot be downloaded automatically"
.to_string(),
)
})?;
let icon_url = project.logo.as_ref().and_then(|logo| {
if !logo.thumbnail_url.is_empty() {
Some(logo.thumbnail_url.clone())
} else if !logo.url.is_empty() {
Some(logo.url.clone())
} else {
None
}
});
let loading_bar = init_loading(
LoadingBarType::PackFileDownload {
instance_id: String::new(),
pack_name: project.name.clone(),
icon: icon_url,
pack_version: pack_file.display_name.clone(),
},
pack_file.file_length.max(1) as f64,
&format!("Downloading {}", pack_file.file_name),
)
.await?;
let mut last_downloaded = 0_u64;
let mut progress = |current: u64,
_total: u64|
-> std::pin::Pin<
Box<dyn std::future::Future<Output = crate::Result<()>> + Send>,
> {
let delta = current.saturating_sub(last_downloaded);
last_downloaded = current;
let result = emit_loading(
&loading_bar,
delta as f64,
Some("Downloading CurseForge modpack"),
);
Box::pin(async move { result })
};
let pack_download = download_curseforge_archive(
project_id,
file_id,
&pack_file,
&download_url,
Some(&mut progress as &mut FetchProgressFn<'_>),
None,
)
.await?;
let pack_path = pack_download.path;
let target = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&pack_path)?;
let mut archive =
zip::ZipArchive::new(file).map_err(modpack_zip_error)?;
let manifest = read_modpack_manifest(&mut archive)?;
modpack_target(&manifest)
})
.await??;
Ok(target)
}
pub async fn install_modpack_with_reporter(
request: CurseForgeModpackInstallRequest,
reporter: Option<InstallProgressReporter>,
) -> crate::Result<CurseForgeModpackInstallResult> {
let pack_file = get_file(request.project_id, request.file_id).await?;
let project = get_project(request.project_id).await?;
let icon_url = project
.logo
.as_ref()
.map(|logo| {
if !logo.thumbnail_url.is_empty() {
logo.thumbnail_url.clone()
} else {
logo.url.clone()
}
})
.filter(|url| !url.is_empty());
let download_url = resolve_curseforge_download_url(
request.project_id,
request.file_id,
&project,
&pack_file,
)
.await?;
let Some(download_url) = download_url else {
let manual_download = CurseForgeManualDownload {
project_id: request.project_id,
file_id: request.file_id,
file_name: pack_file.file_name.clone(),
ownership_kind: crate::state::instances::ContentOwnershipKind::PackManaged,
operation_kind: crate::state::instances::ManualDownloadOperationKind::PackInstall,
website_url: curseforge_file_page_url(
project.links.website_url.as_deref(),
request.file_id,
),
project_type: "modpack".to_string(),
project_slug: project.slug.clone(),
target_folder: String::new(),
hashes: pack_file.hashes.clone(),
file_length: pack_file.file_length,
file_fingerprint: pack_file.file_fingerprint,
};
persist_manual_download(&request.instance_id, &manual_download).await?;
return Ok(CurseForgeModpackInstallResult {
content: CurseForgeInstallResult {
manual_downloads: vec![manual_download],
..Default::default()
},
..Default::default()
});
};
let cached_icon_path = if let Some(icon_url) = icon_url.as_ref() {
match cache_instance_icon_from_url(icon_url).await {
Ok(path) => {
let _ = crate::api::instance::edit_icon(
&request.instance_id,
Some(path.as_path()),
)
.await;
Some(path)
}
Err(err) => {
tracing::warn!(
"Failed to cache CurseForge modpack icon: {err}"
);
None
}
}
} else {
None
};
let pack_details = InstallPhaseDetails::Modpack {
project_id: Some(request.project_id.to_string()),
version_id: Some(request.file_id.to_string()),
title: Some(project.name.clone()),
};
if let Some(reporter) = reporter.as_ref() {
let state = State::get().await?;
let item_path = state
.directories
.caches_dir()
.join("curseforge")
.join("modpacks")
.join(request.project_id.to_string())
.join(request.file_id.to_string())
.join(&pack_file.file_name)
.display()
.to_string();
reporter
.update_with_events(
InstallPhaseId::DownloadingPackFile,
Some(InstallProgress {
current: 0,
total: pack_file.file_length.max(1),
secondary: None,
}),
pack_details.clone(),
vec![InstallJobEventKind::ContentFileQueued {
path: item_path,
bytes_total: Some(pack_file.file_length),
max_attempts: 5,
}],
)
.await?;
reporter.persist().await?;
}
let mut last_downloaded = 0_u64;
let progress_reporter = reporter.clone();
let progress_details = pack_details.clone();
let mut progress = move |current: u64,
total: u64|
-> std::pin::Pin<
Box<dyn std::future::Future<Output = crate::Result<()>> + Send>,
> {
let min_delta = (total / 200).max(256 * 1024);
if current < total
&& current.saturating_sub(last_downloaded) < min_delta
{
return Box::pin(async { Ok(()) });
}
last_downloaded = current;
let reporter = progress_reporter.clone();
let details = progress_details.clone();
Box::pin(async move {
if let Some(reporter) = reporter {
reporter
.update(
InstallPhaseId::DownloadingPackFile,
Some(InstallProgress {
current,
total,
secondary: None,
}),
details,
)
.await?;
}
Ok(())
})
};
let progress = reporter
.is_some()
.then_some(&mut progress as &mut FetchProgressFn<'_>);
let pack_download = download_curseforge_archive(
request.project_id,
request.file_id,
&pack_file,
&download_url,
progress,
reporter.as_ref(),
)
.await?;
if let Some(reporter) = reporter.as_ref()
&& pack_download.attempts > 0
{
reporter
.record_download_metrics(
pack_download.source.as_str(),
pack_download.fallback_count as u64,
)
.await?;
}
let pack_path = pack_download.path;
if let Some(reporter) = reporter.as_ref() {
reporter
.update(
InstallPhaseId::DownloadingPackFile,
Some(InstallProgress {
current: pack_file.file_length,
total: pack_file.file_length.max(1),
secondary: None,
}),
pack_details.clone(),
)
.await?;
reporter
.update(
InstallPhaseId::ReadingPackManifest,
None,
pack_details.clone(),
)
.await?;
}
let pack_path_for_manifest = pack_path.clone();
let manifest = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&pack_path_for_manifest)?;
let mut archive =
zip::ZipArchive::new(file).map_err(modpack_zip_error)?;
read_modpack_manifest(&mut archive)
})
.await??;
let state = State::get().await?;
use sqlx::Row;
let instance_target = sqlx::query(
"SELECT content_set.game_version, content_set.loader
FROM instances instance
INNER JOIN instance_content_sets content_set
ON content_set.id = instance.applied_content_set_id
WHERE instance.id = ?",
)
.bind(&request.instance_id)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"The selected instance has no active Minecraft installation"
.to_string(),
)
})?;
let instance_game_version =
instance_target.try_get::<String, _>("game_version")?;
let instance_loader = instance_target.try_get::<String, _>("loader")?;
let target = modpack_target(&manifest)?;
let loader = (target.loader != ModLoader::Vanilla)
.then(|| target.loader.as_str().to_string());
if instance_game_version != manifest.minecraft.version
|| target.loader.as_str() != instance_loader
{
if !request.allow_target_change {
return Err(ErrorKind::InputError(format!(
"This modpack targets Minecraft {} with {}, while the selected instance uses {} with {}",
manifest.minecraft.version,
loader.as_deref().unwrap_or("vanilla"),
instance_game_version,
instance_loader
))
.into());
}
}
let content_set = crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
&request.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?;
let pack_members = crate::state::instances::adapters::sqlite::content_rows::get_pack_members(
&content_set.id,
&state.pool,
)
.await?;
let preserved_pack_projects = pack_members
.iter()
.filter(|member| {
matches!(
member.override_kind,
crate::state::instances::PackMemberOverrideKind::Version
| crate::state::instances::PackMemberOverrideKind::Removed
)
})
.filter_map(|member| member.provider_project_id.clone())
.collect::<HashSet<_>>();
let disabled_pack_projects = pack_members
.iter()
.filter(|member| {
member.override_kind
== crate::state::instances::PackMemberOverrideKind::Disabled
})
.filter_map(|member| member.provider_project_id.clone())
.collect::<HashSet<_>>();
let installed_pack_releases = pack_members
.iter()
.filter(|member| {
member.materialization_state
== crate::state::instances::PackMemberMaterializationState::Present
})
.filter_map(|member| {
Some((
member.provider_project_id.clone()?,
member.provider_release_id.clone()?,
))
})
.collect::<HashSet<_>>();
let selected_files = manifest
.files
.into_iter()
.filter(|file| file.required || request.install_optional)
.filter(|file| {
!preserved_pack_projects.contains(&file.project_id.to_string())
})
.filter(|file| {
!installed_pack_releases.contains(&(
file.project_id.to_string(),
file.file_id.to_string(),
))
})
.collect::<Vec<_>>();
let loader_type_value = loader.as_deref().and_then(loader_type);
let project_ids = selected_files
.iter()
.map(|file| file.project_id)
.collect::<Vec<_>>();
let projects = get_modpack_projects(project_ids).await?;
let instance_name = crate::api::instance::get(&request.instance_id)
.await?
.map(|metadata| metadata.instance.name)
.unwrap_or_else(|| project.name.clone());
let total_files = selected_files.len().max(1);
let file_ids = selected_files
.iter()
.map(|file| file.file_id)
.collect::<Vec<_>>();
let file_meta = get_modpack_files(file_ids).await?;
let content_total_bytes = selected_files
.iter()
.map(|file| {
file_meta
.get(&file.file_id)
.map(|meta| meta.file_length)
.unwrap_or(0)
})
.sum::<u64>();
// Keep the LoadingBarId in an Arc. LoadingBarId::Drop removes the bar, so
// cloning the ID itself would destroy progress as soon as the first task
// finished. Arc clones only share ownership.
let loading_bar = if reporter.is_none() {
Some(Arc::new(
init_loading(
LoadingBarType::PackDownload {
instance_id: request.instance_id.clone(),
pack_name: project.name.clone(),
icon: cached_icon_path.clone(),
pack_id: Some(request.project_id.to_string()),
pack_version: Some(request.file_id.to_string()),
},
total_files as f64,
&format!("Downloading {instance_name}"),
)
.await?,
))
} else {
None
};
if let Some(loading_bar) = loading_bar.as_ref() {
let _ = emit_loading(
loading_bar.as_ref(),
0.0,
Some(&format!(
"0/{total_files} files · 0 / {}",
format_bytes(content_total_bytes)
)),
);
}
if let Some(reporter) = reporter.as_ref() {
reporter
.update_with_events(
InstallPhaseId::DownloadingContent,
Some(InstallProgress {
current: 0,
total: total_files as u64,
secondary: Some(InstallProgressSecondary {
current: 0,
total: content_total_bytes,
}),
}),
pack_details.clone(),
vec![InstallJobEventKind::ContentDownloadStarted {
files: total_files as u64,
bytes: Some(content_total_bytes),
}],
)
.await?;
}
tracing::info!(
selected_manifest_files = selected_files.len(),
"Resolved CurseForge modpack manifest files"
);
let content = Arc::new(Mutex::new(CurseForgeInstallResult::default()));
let download_metrics = reporter.as_ref().map(|reporter| {
Arc::new(CurseForgeDownloadMetrics::with_reporter(reporter.clone()))
});
let projects = Arc::new(projects);
let file_meta = Arc::new(file_meta);
let files_done = Arc::new(AtomicU64::new(0));
let bytes_done = Arc::new(AtomicU64::new(0));
let active_downloads = Arc::new(AtomicU64::new(0));
let instance_id = request.instance_id.clone();
let minecraft_version = manifest.minecraft.version.clone();
loading_try_for_each_concurrent(
stream::iter(selected_files.into_iter().map(Ok::<_, crate::Error>)),
Some(state.download_concurrency()),
// Progress is updated manually with file+byte counts below.
None,
1.0,
total_files,
None,
|manifest_file| {
let content = content.clone();
let projects = projects.clone();
let file_meta = file_meta.clone();
let files_done = files_done.clone();
let bytes_done = bytes_done.clone();
let active_downloads = active_downloads.clone();
let loading_bar = loading_bar.clone();
let reporter = reporter.clone();
let download_metrics = download_metrics.clone();
let pack_details = pack_details.clone();
let instance_id = instance_id.clone();
let minecraft_version = minecraft_version.clone();
async move {
let expected_bytes = file_meta
.get(&manifest_file.file_id)
.map(|file| file.file_length)
.unwrap_or(0);
let project = projects
.get(&manifest_file.project_id)
.ok_or_else(|| {
ErrorKind::OtherError(format!(
"CurseForge project metadata is missing for {}",
manifest_file.project_id
))
})?;
let project_type = project_type_for_class(project.class_id);
managed_project_type(project_type)?;
active_downloads.fetch_add(1, Ordering::Relaxed);
let mut installed_result = None;
let mut failed_result = None;
let mut failure_reason = "no file was installed".to_string();
for attempt in 1..=MODPACK_FILE_INSTALL_ATTEMPTS {
match install_file_with_metrics(
CurseForgeInstallRequest {
instance_id: instance_id.clone(),
project_id: manifest_file.project_id,
file_id: manifest_file.file_id,
project_type: project_type.to_string(),
ownership_kind: crate::state::instances::ContentOwnershipKind::PackManaged,
manual_operation_kind: if request.allow_target_change {
crate::state::instances::ManualDownloadOperationKind::PackUpdate
} else {
crate::state::instances::ManualDownloadOperationKind::PackInstall
},
game_version: Some(minecraft_version.clone()),
mod_loader_type: loader_type_value,
world_name: None,
install_dependencies: false,
excluded_dependency_project_ids: Vec::new(),
force_dependency_project_ids: Vec::new(),
dependency_plan_id: None,
},
download_metrics.as_deref(),
)
.await
{
Ok(item_result)
if !item_result.installed.is_empty() =>
{
installed_result = Some(item_result);
break;
}
Ok(item_result) => {
failure_reason = item_result
.manual_downloads
.first()
.map(|file| {
format!(
"{} requires manual download",
file.file_name
)
})
.unwrap_or_else(|| {
"no file was installed".to_string()
});
let manual_download_required =
!item_result.manual_downloads.is_empty();
failed_result = Some(item_result);
if manual_download_required {
break;
}
}
Err(err) => {
failure_reason = err.to_string();
}
}
tracing::warn!(
project_id = manifest_file.project_id,
file_id = manifest_file.file_id,
attempt,
max_attempts = MODPACK_FILE_INSTALL_ATTEMPTS,
reason = %failure_reason,
"Failed to install required CurseForge file"
);
if attempt < MODPACK_FILE_INSTALL_ATTEMPTS {
tokio::time::sleep(Duration::from_millis(
250 * attempt as u64,
))
.await;
}
}
let Some(item_result) = installed_result else {
active_downloads.fetch_sub(1, Ordering::Relaxed);
let mut failed_result = failed_result.unwrap_or_default();
let file_name = file_meta
.get(&manifest_file.file_id)
.map(|file| file.file_name.clone())
.unwrap_or_else(|| {
format!(
"project-{}-file-{}",
manifest_file.project_id,
manifest_file.file_id
)
});
let manual_download =
failed_result.manual_downloads.first().cloned();
let event = if let Some(manual_download) =
manual_download.as_ref()
{
InstallJobEventKind::ContentFileSkipped {
path: manual_download.file_name.clone(),
reason: "CurseForge requires manual download"
.to_string(),
project_id: Some(
manifest_file.project_id.to_string(),
),
version_id: Some(manifest_file.file_id.to_string()),
manual_url: manual_download.website_url.clone(),
}
} else {
let reason = format!(
"Failed after {MODPACK_FILE_INSTALL_ATTEMPTS} attempts: {failure_reason}"
);
failed_result.failed_downloads.push(
CurseForgeFailedDownload {
project_id: manifest_file.project_id,
file_id: manifest_file.file_id,
file_name: file_name.clone(),
reason: reason.clone(),
},
);
InstallJobEventKind::ContentFileFailed {
path: file_name,
reason,
project_id: Some(
manifest_file.project_id.to_string(),
),
version_id: Some(manifest_file.file_id.to_string()),
}
};
{
let mut content =
content.lock().expect("content mutex");
merge_install_result(&mut content, failed_result);
}
report_modpack_progress(
loading_bar.as_deref(),
reporter.as_ref(),
pack_details,
&files_done,
&bytes_done,
&active_downloads,
total_files as u64,
content_total_bytes,
0,
event,
)
.await?;
return Ok(());
};
let completed_path =
item_result.installed[0].relative_path.clone();
{
let mut content = content.lock().expect("content mutex");
merge_install_result(&mut content, item_result);
}
active_downloads.fetch_sub(1, Ordering::Relaxed);
report_modpack_progress(
loading_bar.as_deref(),
reporter.as_ref(),
pack_details,
&files_done,
&bytes_done,
&active_downloads,
total_files as u64,
content_total_bytes,
expected_bytes,
InstallJobEventKind::ContentFileCompleted {
path: completed_path,
bytes: expected_bytes,
},
)
.await?;
Ok(())
}
},
)
.await?;
if let (Some(reporter), Some(download_metrics)) =
(reporter.as_ref(), download_metrics.as_ref())
{
download_metrics.finish(reporter).await?;
}
let content = Arc::try_unwrap(content)
.map_err(|_| {
ErrorKind::OtherError(
"CurseForge install state was still shared after completion"
.to_string(),
)
})?
.into_inner()
.map_err(|_| {
ErrorKind::OtherError(
"CurseForge install state mutex was poisoned".to_string(),
)
})?;
if request.allow_target_change {
for installed in &content.installed {
if disabled_pack_projects
.contains(&installed.project_id.to_string())
&& !installed.relative_path.ends_with(".disabled")
{
crate::state::instances::commands::toggle_disable_project(
&request.instance_id,
&installed.relative_path,
Some(false),
&state,
)
.await?;
}
}
}
let instance_path =
crate::api::instance::get_full_path(&request.instance_id).await?;
if let Some(reporter) = reporter.as_ref() {
reporter
.update(InstallPhaseId::ExtractingOverrides, None, pack_details)
.await?;
}
let update_ready = content.manual_downloads.is_empty()
&& content.failed_downloads.is_empty();
let should_commit = content.manual_downloads.is_empty()
&& (!request.allow_target_change || update_ready);
let overrides_written = if should_commit {
crate::api::pack::archive_util::run_blocking_instance_write(
request.instance_id.clone(),
reporter
.as_ref()
.map(InstallProgressReporter::cancellation_token)
.unwrap_or_default(),
move |cancellation| {
extract_modpack_overrides(
&pack_path,
&instance_path,
Some(cancellation),
)
},
)
.await?
} else {
0
};
if should_commit && !request.allow_target_change {
crate::api::instance::edit(
&request.instance_id,
EditInstance {
name: (!request.allow_target_change)
.then_some(project.name.clone()),
icon_path: (!request.allow_target_change).then(|| {
cached_icon_path
.as_ref()
.map(|path| path.to_string_lossy().to_string())
}),
link: Some(InstanceLink::CurseForgeModpack {
project_id: request.project_id.to_string(),
version_id: request.file_id.to_string(),
}),
content_set_patch: Some(crate::state::AppliedContentSetPatch {
source_kind: Some(ContentSourceKind::CurseForge),
game_version: Some(manifest.minecraft.version.clone()),
protocol_version: Some(None),
loader: Some(target.loader),
loader_version: Some(target.loader_version.clone()),
}),
..EditInstance::default()
},
)
.await?;
let content_set = crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
&request.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?;
crate::state::sync_content_files(&request.instance_id, &state).await?;
match get_modpack_expected_members(request.project_id, request.file_id)
.await
{
Ok(expected) => {
crate::state::instances::commands::reconcile_curseforge_members(
&request.instance_id,
&content_set.id,
&expected,
&state,
)
.await?;
}
Err(error) => tracing::warn!(
"Unable to persist the complete CurseForge pack manifest: {error}"
),
}
}
Ok(CurseForgeModpackInstallResult {
content,
overrides_written,
minecraft_version: manifest.minecraft.version,
loader,
})
}
/// Installs a CurseForge modpack from a local archive on disk (a zip with a
/// `manifest.json`), downloading the listed files through the CurseForge API
/// and extracting the overrides folder. Unlike [`install_modpack_with_reporter`]
/// this needs no project/file id — undownloadable files land on the manual
/// download list exactly like API-driven installs.
pub async fn install_modpack_from_local_archive_with_reporter(
instance_id: String,
archive_path: std::path::PathBuf,
_base_folder: String,
source_filename: Option<String>,
install_optional: bool,
reporter: InstallProgressReporter,
completion_policy: crate::launcher::InstanceCompletionPolicy,
) -> crate::Result<CurseForgeModpackInstallResult> {
let manifest_archive_path = archive_path.clone();
let manifest = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(&manifest_archive_path)?;
let mut archive =
zip::ZipArchive::new(file).map_err(modpack_zip_error)?;
read_modpack_manifest(&mut archive)
})
.await??;
let target = modpack_target(&manifest)?;
let loader = (target.loader != ModLoader::Vanilla)
.then(|| target.loader.as_str().to_string());
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(|| "CurseForge 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 loader_version = if target.loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
&manifest.minecraft.version,
target.loader,
target.loader_version.as_deref(),
)
.await?
} else {
None
};
crate::api::instance::edit(
&instance_id,
EditInstance {
install_stage: Some(
crate::state::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(crate::state::AppliedContentSetPatch {
source_kind: Some(ContentSourceKind::CurseForge),
game_version: Some(manifest.minecraft.version.clone()),
protocol_version: Some(None),
loader: Some(target.loader),
loader_version: Some(loader_version.map(|version| version.id)),
}),
..EditInstance::default()
},
)
.await?;
let minecraft_install = (completion_policy
== crate::launcher::InstanceCompletionPolicy::DeferToInstallJob)
.then(|| {
crate::api::pack::parallel_minecraft_install::ParallelMinecraftInstall::start(
instance_id.clone(),
reporter.clone(),
)
});
let content = install_local_manifest_files(
&instance_id,
manifest.files.clone(),
install_optional,
&manifest.minecraft.version,
loader.as_deref(),
pack_details.clone(),
&reporter,
)
.await?;
if !content.manual_downloads.is_empty() {
if let Some(minecraft_install) = minecraft_install {
minecraft_install.abort().await;
}
return Ok(CurseForgeModpackInstallResult {
content,
overrides_written: 0,
minecraft_version: manifest.minecraft.version,
loader,
});
}
reporter
.update(
InstallPhaseId::ExtractingOverrides,
None,
pack_details.clone(),
)
.await?;
let instance_path =
crate::api::instance::get_full_path(&instance_id).await?;
let overrides_archive_path = archive_path.clone();
let overrides_written =
crate::api::pack::archive_util::run_blocking_instance_write(
instance_id.clone(),
reporter.cancellation_token(),
move |cancellation| {
extract_modpack_overrides(
&overrides_archive_path,
&instance_path,
Some(cancellation),
)
},
)
.await?;
if let Some(minecraft_install) = minecraft_install {
minecraft_install.join().await?;
} else {
crate::launcher::install_minecraft_for_instance_id_with_reporter(
&instance_id,
false,
Some(reporter.clone()),
completion_policy,
)
.await?;
}
reporter.clear_context().await?;
Ok(CurseForgeModpackInstallResult {
content,
overrides_written,
minecraft_version: manifest.minecraft.version,
loader,
})
}
/// Downloads and installs the files listed in a local CurseForge manifest.
/// Mirrors the file loop of [`install_modpack_with_reporter`], simplified to
/// the job-reporter path used by local imports. Also used by the MCBBS
/// installer for its CurseForge-style `files` array.
pub(crate) async fn install_local_manifest_files(
instance_id: &str,
manifest_files: Vec<CurseForgeManifestFile>,
install_optional: bool,
minecraft_version: &str,
loader: Option<&str>,
pack_details: InstallPhaseDetails,
reporter: &InstallProgressReporter,
) -> crate::Result<CurseForgeInstallResult> {
let state = State::get().await?;
let content_set = crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?;
let installed_releases = crate::state::instances::adapters::sqlite::content_rows::get_pack_members(
&content_set.id,
&state.pool,
)
.await?
.into_iter()
.filter(|member| {
member.materialization_state
== crate::state::instances::PackMemberMaterializationState::Present
})
.filter_map(|member| {
Some((member.provider_project_id?, member.provider_release_id?))
})
.collect::<HashSet<_>>();
let selected_files = manifest_files
.into_iter()
.filter(|file| file.required || install_optional)
.filter(|file| {
!installed_releases.contains(&(
file.project_id.to_string(),
file.file_id.to_string(),
))
})
.collect::<Vec<_>>();
let loader_type_value = loader.and_then(loader_type);
let project_ids = selected_files
.iter()
.map(|file| file.project_id)
.collect::<Vec<_>>();
let projects = get_modpack_projects(project_ids).await?;
let total_files = selected_files.len().max(1);
let file_ids = selected_files
.iter()
.map(|file| file.file_id)
.collect::<Vec<_>>();
let file_meta = get_modpack_files(file_ids).await?;
let content_total_bytes = selected_files
.iter()
.map(|file| {
file_meta
.get(&file.file_id)
.map(|meta| meta.file_length)
.unwrap_or(0)
})
.sum::<u64>();
reporter
.update_with_events(
InstallPhaseId::DownloadingContent,
Some(InstallProgress {
current: 0,
total: total_files as u64,
secondary: Some(InstallProgressSecondary {
current: 0,
total: content_total_bytes,
}),
}),
pack_details.clone(),
vec![InstallJobEventKind::ContentDownloadStarted {
files: total_files as u64,
bytes: Some(content_total_bytes),
}],
)
.await?;
tracing::info!(
selected_manifest_files = selected_files.len(),
"Resolved local CurseForge modpack manifest files"
);
let content = Arc::new(Mutex::new(CurseForgeInstallResult::default()));
let download_metrics =
Arc::new(CurseForgeDownloadMetrics::with_reporter(reporter.clone()));
let projects = Arc::new(projects);
let file_meta = Arc::new(file_meta);
let files_done = Arc::new(AtomicU64::new(0));
let bytes_done = Arc::new(AtomicU64::new(0));
let active_downloads = Arc::new(AtomicU64::new(0));
loading_try_for_each_concurrent(
stream::iter(selected_files.into_iter().map(Ok::<_, crate::Error>)),
Some(state.download_concurrency()),
None,
1.0,
total_files,
None,
|manifest_file| {
let content = content.clone();
let projects = projects.clone();
let file_meta = file_meta.clone();
let files_done = files_done.clone();
let bytes_done = bytes_done.clone();
let active_downloads = active_downloads.clone();
let reporter = reporter.clone();
let download_metrics = download_metrics.clone();
let pack_details = pack_details.clone();
let instance_id = instance_id.to_string();
let minecraft_version = minecraft_version.to_string();
async move {
let expected_bytes = file_meta
.get(&manifest_file.file_id)
.map(|file| file.file_length)
.unwrap_or(0);
let project = projects
.get(&manifest_file.project_id)
.ok_or_else(|| {
ErrorKind::OtherError(format!(
"CurseForge project metadata is missing for {}",
manifest_file.project_id
))
})?;
let project_type = project_type_for_class(project.class_id);
managed_project_type(project_type)?;
active_downloads.fetch_add(1, Ordering::Relaxed);
let mut installed_result = None;
let mut failed_result = None;
let mut failure_reason = "no file was installed".to_string();
for attempt in 1..=MODPACK_FILE_INSTALL_ATTEMPTS {
match install_file_with_metrics(
CurseForgeInstallRequest {
instance_id: instance_id.clone(),
project_id: manifest_file.project_id,
file_id: manifest_file.file_id,
project_type: project_type.to_string(),
ownership_kind: crate::state::instances::ContentOwnershipKind::PackManaged,
manual_operation_kind: crate::state::instances::ManualDownloadOperationKind::PackUpdate,
game_version: Some(minecraft_version.clone()),
mod_loader_type: loader_type_value,
world_name: None,
install_dependencies: false,
excluded_dependency_project_ids: Vec::new(),
force_dependency_project_ids: Vec::new(),
dependency_plan_id: None,
},
Some(&download_metrics),
)
.await
{
Ok(item_result)
if !item_result.installed.is_empty() =>
{
installed_result = Some(item_result);
break;
}
Ok(item_result) => {
failure_reason = item_result
.manual_downloads
.first()
.map(|file| {
format!(
"{} requires manual download",
file.file_name
)
})
.unwrap_or_else(|| {
"no file was installed".to_string()
});
let manual_download_required =
!item_result.manual_downloads.is_empty();
failed_result = Some(item_result);
if manual_download_required {
break;
}
}
Err(err) => {
failure_reason = err.to_string();
}
}
tracing::warn!(
project_id = manifest_file.project_id,
file_id = manifest_file.file_id,
attempt,
max_attempts = MODPACK_FILE_INSTALL_ATTEMPTS,
reason = %failure_reason,
"Failed to install required CurseForge file"
);
if attempt < MODPACK_FILE_INSTALL_ATTEMPTS {
tokio::time::sleep(Duration::from_millis(
250 * attempt as u64,
))
.await;
}
}
let Some(item_result) = installed_result else {
active_downloads.fetch_sub(1, Ordering::Relaxed);
let mut failed_result = failed_result.unwrap_or_default();
let file_name = file_meta
.get(&manifest_file.file_id)
.map(|file| file.file_name.clone())
.unwrap_or_else(|| {
format!(
"project-{}-file-{}",
manifest_file.project_id,
manifest_file.file_id
)
});
let manual_download =
failed_result.manual_downloads.first().cloned();
let event = if let Some(manual_download) =
manual_download.as_ref()
{
InstallJobEventKind::ContentFileSkipped {
path: manual_download.file_name.clone(),
reason: "CurseForge requires manual download"
.to_string(),
project_id: Some(
manifest_file.project_id.to_string(),
),
version_id: Some(manifest_file.file_id.to_string()),
manual_url: manual_download.website_url.clone(),
}
} else {
let reason = format!(
"Failed after {MODPACK_FILE_INSTALL_ATTEMPTS} attempts: {failure_reason}"
);
failed_result.failed_downloads.push(
CurseForgeFailedDownload {
project_id: manifest_file.project_id,
file_id: manifest_file.file_id,
file_name: file_name.clone(),
reason: reason.clone(),
},
);
InstallJobEventKind::ContentFileFailed {
path: file_name,
reason,
project_id: Some(
manifest_file.project_id.to_string(),
),
version_id: Some(manifest_file.file_id.to_string()),
}
};
{
let mut content =
content.lock().expect("content mutex");
merge_install_result(&mut content, failed_result);
}
report_modpack_progress(
None,
Some(&reporter),
pack_details,
&files_done,
&bytes_done,
&active_downloads,
total_files as u64,
content_total_bytes,
0,
event,
)
.await?;
return Ok(());
};
let completed_path =
item_result.installed[0].relative_path.clone();
{
let mut content = content.lock().expect("content mutex");
merge_install_result(&mut content, item_result);
}
active_downloads.fetch_sub(1, Ordering::Relaxed);
report_modpack_progress(
None,
Some(&reporter),
pack_details,
&files_done,
&bytes_done,
&active_downloads,
total_files as u64,
content_total_bytes,
expected_bytes,
InstallJobEventKind::ContentFileCompleted {
path: completed_path,
bytes: expected_bytes,
},
)
.await?;
Ok(())
}
},
)
.await?;
download_metrics.finish(reporter).await?;
Arc::try_unwrap(content)
.map_err(|_| {
ErrorKind::OtherError(
"CurseForge install state was still shared after completion"
.to_string(),
)
})?
.into_inner()
.map_err(|_| {
ErrorKind::OtherError(
"CurseForge install state mutex was poisoned".to_string(),
)
})
.map_err(Into::into)
}
pub async fn update_managed_modpack(
instance_id: &str,
file_id: u32,
) -> crate::Result<CurseForgeModpackInstallResult> {
update_managed_modpack_with_reporter(instance_id, file_id, None).await
}
pub(crate) async fn update_managed_modpack_with_reporter(
instance_id: &str,
file_id: u32,
reporter: Option<InstallProgressReporter>,
) -> crate::Result<CurseForgeModpackInstallResult> {
let state = State::get().await?;
let metadata = crate::state::instances::commands::get_instance_metadata(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| ErrorKind::InputError("Unknown instance".to_string()))?;
let project_id = match &metadata.link {
InstanceLink::CurseForgeModpack { project_id, .. } => {
project_id.parse::<u32>().map_err(|_| {
ErrorKind::InputError(
"Linked CurseForge project ID is invalid".to_string(),
)
})?
}
_ => {
return Err(ErrorKind::InputError(format!(
"Instance {instance_id} is not a managed CurseForge pack, or has been disconnected."
))
.into());
}
};
let members = crate::state::instances::adapters::sqlite::content_rows::get_pack_members(
&metadata.applied_content_set.id,
&state.pool,
)
.await?;
if members.iter().any(|member| !member.reconciled) {
return Err(ErrorKind::InputError(
"CurseForge pack membership is not calibrated yet; refresh the content page while online before updating"
.to_string(),
)
.into());
}
let expected = get_modpack_expected_members_with_reporter(
project_id,
file_id,
reporter.as_ref(),
)
.await?;
let pack_file = get_file(project_id, file_id).await?;
let game_version = pack_file
.game_versions
.iter()
.find(|value| loader_type(value).is_none())
.cloned()
.unwrap_or_else(|| metadata.applied_content_set.game_version.clone());
let loader = pack_file
.game_versions
.iter()
.find_map(|value| {
loader_type(value).map(|_| value.to_ascii_lowercase())
})
.or_else(|| {
Some(metadata.applied_content_set.loader.as_str().to_string())
});
let content_set_loader = loader
.as_deref()
.map(crate::data::ModLoader::try_from_string)
.transpose()?;
let installed_releases = members
.iter()
.filter(|member| {
member.materialization_state
== crate::state::instances::PackMemberMaterializationState::Present
})
.filter_map(|member| {
Some((
member.provider_project_id.clone()?,
member.provider_release_id.clone()?,
))
})
.collect::<HashSet<_>>();
let manual_downloads = expected
.members
.iter()
.filter(|member| member.required)
.filter(|member| {
!installed_releases.contains(&(
member.project_id.to_string(),
member.file_id.to_string(),
))
})
.filter_map(|member| member.manual_download.clone())
.collect::<Vec<_>>();
if !manual_downloads.is_empty() {
for download in &manual_downloads {
persist_manual_download(instance_id, download).await?;
}
if let Some(reporter) = &reporter {
let events = manual_downloads
.iter()
.map(|download| InstallJobEventKind::ContentFileSkipped {
path: download.file_name.clone(),
reason: "CurseForge requires manual download".to_string(),
project_id: Some(download.project_id.to_string()),
version_id: Some(download.file_id.to_string()),
manual_url: download.website_url.clone(),
})
.collect();
reporter.record_events(events).await?;
}
return Ok(CurseForgeModpackInstallResult {
content: CurseForgeInstallResult {
manual_downloads,
..CurseForgeInstallResult::default()
},
overrides_written: 0,
minecraft_version: game_version,
loader,
});
}
let entries = crate::state::instances::adapters::sqlite::content_rows::get_content_entries(
&metadata.applied_content_set.id,
&state.pool,
)
.await?
.into_iter()
.filter_map(|entry| entry.file_id.map(|file_id| (entry.id, file_id)))
.collect::<HashMap<_, _>>();
let files = crate::state::instances::adapters::sqlite::content_rows::get_instance_files(
instance_id,
&state.pool,
)
.await?
.into_iter()
.map(|file| (file.id.clone(), file.relative_path))
.collect::<HashMap<_, _>>();
let backup = create_curseforge_update_backup(
&metadata, &members, &entries, &files, &state,
)
.await?;
let result = match install_modpack_with_reporter(
CurseForgeModpackInstallRequest {
instance_id: instance_id.to_string(),
project_id,
file_id,
install_optional: false,
allow_target_change: true,
},
reporter,
)
.await
{
Ok(result) => result,
Err(error) => {
rollback_curseforge_update(
instance_id,
&metadata,
&members,
&backup,
&[],
&state,
)
.await?;
return Err(error);
}
};
if !result.content.manual_downloads.is_empty()
|| !result.content.failed_downloads.is_empty()
{
rollback_curseforge_update(
instance_id,
&metadata,
&members,
&backup,
&result.content.installed,
&state,
)
.await?;
return Ok(result);
}
let expected_keys = expected
.members
.iter()
.map(|member| {
format!(
"curseforge:{}:{}",
member.project_id,
member.project_type.get_name()
)
})
.collect::<HashSet<_>>();
for member in &members {
if expected_keys.contains(&member.member_key)
|| member.override_kind
== crate::state::instances::PackMemberOverrideKind::Version
{
continue;
}
let Some(entry_id) = member.content_entry_id.as_ref() else {
continue;
};
let Some(file_id) = entries.get(entry_id) else {
continue;
};
let Some(relative_path) = files.get(file_id) else {
continue;
};
crate::state::instances::commands::remove_project(
instance_id,
relative_path,
&state,
)
.await?;
}
crate::state::sync_content_files(instance_id, &state).await?;
crate::state::instances::commands::reconcile_curseforge_members(
instance_id,
&metadata.applied_content_set.id,
&expected,
&state,
)
.await?;
crate::api::instance::edit(
instance_id,
EditInstance {
link: Some(InstanceLink::CurseForgeModpack {
project_id: project_id.to_string(),
version_id: file_id.to_string(),
}),
content_set_patch: Some(crate::state::AppliedContentSetPatch {
source_kind: Some(ContentSourceKind::CurseForge),
game_version: Some(game_version),
protocol_version: Some(None),
loader: content_set_loader,
loader_version: Some(None),
}),
..EditInstance::default()
},
)
.await?;
Ok(result)
}
struct CurseForgeUpdateBackup {
_directory: tempfile::TempDir,
files: Vec<(String, String, std::path::PathBuf)>,
}
async fn create_curseforge_update_backup(
metadata: &crate::state::InstanceMetadata,
members: &[crate::state::instances::PackMember],
entries: &HashMap<String, String>,
files: &HashMap<String, String>,
state: &State,
) -> crate::Result<CurseForgeUpdateBackup> {
crate::util::io::create_dir_all(&state.directories.caches_dir()).await?;
let directory = tempfile::Builder::new()
.prefix("curseforge-pack-update-")
.tempdir_in(state.directories.caches_dir())?;
let instance_path = state.directories.instance_game_dir(&metadata.instance);
let mut backups = Vec::new();
for member in members {
let Some(entry_id) = member.content_entry_id.as_ref() else {
continue;
};
let Some(file_id) = entries.get(entry_id) else {
continue;
};
let Some(relative_path) = files.get(file_id) else {
continue;
};
let source = instance_path.join(relative_path);
if !source.is_file() {
continue;
}
let backup_path = directory
.path()
.join(format!("{}.backup", uuid::Uuid::new_v4()));
tokio::fs::copy(&source, &backup_path).await?;
backups.push((
member.member_key.clone(),
relative_path.clone(),
backup_path,
));
}
Ok(CurseForgeUpdateBackup {
_directory: directory,
files: backups,
})
}
async fn rollback_curseforge_update(
instance_id: &str,
metadata: &crate::state::InstanceMetadata,
old_members: &[crate::state::instances::PackMember],
backup: &CurseForgeUpdateBackup,
installed: &[CurseForgeInstalledFile],
state: &State,
) -> crate::Result<()> {
let instance_path = state.directories.instance_game_dir(&metadata.instance);
let old_paths = backup
.files
.iter()
.map(|(_, relative_path, _)| relative_path.as_str())
.collect::<HashSet<_>>();
for installed_file in installed {
if old_paths.contains(installed_file.relative_path.as_str()) {
continue;
}
crate::state::instances::commands::remove_project(
instance_id,
&installed_file.relative_path,
state,
)
.await?;
}
for (member_key, relative_path, backup_path) in &backup.files {
let destination = instance_path.join(relative_path);
if let Some(parent) = destination.parent() {
crate::util::io::create_dir_all(parent).await?;
}
tokio::fs::copy(backup_path, &destination).await?;
let Some(member) = old_members
.iter()
.find(|member| member.member_key == *member_key)
else {
continue;
};
let (Some(project_id), Some(file_id)) = (
member.provider_project_id.as_deref(),
member.provider_release_id.as_deref(),
) else {
continue;
};
let provider_ref = ContentProviderRef::CurseForge {
project_id: CurseForgeProjectId::new(project_id.parse().map_err(
|_| {
ErrorKind::InputError(
"Stored CurseForge project ID is invalid".to_string(),
)
},
)?)?,
file_id: Some(CurseForgeFileId::new(file_id.parse().map_err(
|_| {
ErrorKind::InputError(
"Stored CurseForge file ID is invalid".to_string(),
)
},
)?)?),
};
let (size, sha1) = sha1_file_async(&destination).await?;
crate::state::record_project_file_atomic(
instance_id,
relative_path,
&sha1,
size,
member.project_type,
ContentSourceKind::CurseForge,
crate::state::instances::ContentOwnershipKind::PackManaged,
Some(&provider_ref),
true,
None,
state,
)
.await?;
}
let current_members = crate::state::instances::adapters::sqlite::content_rows::get_pack_members(
&metadata.applied_content_set.id,
&state.pool,
)
.await?;
let _instance_lock = state.lock_instance_content(instance_id).await;
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
let old_keys = old_members
.iter()
.map(|member| member.member_key.as_str())
.collect::<HashSet<_>>();
for member in current_members {
if !old_keys.contains(member.member_key.as_str()) {
sqlx::query("DELETE FROM instance_pack_members WHERE id = ?")
.bind(member.id)
.execute(&mut *tx)
.await?;
}
}
for old_member in old_members {
let current_entry_id = sqlx::query_scalar::<_, Option<String>>(
"SELECT content_entry_id FROM instance_pack_members
WHERE content_set_id = ? AND member_key = ?",
)
.bind(&metadata.applied_content_set.id)
.bind(&old_member.member_key)
.fetch_optional(&mut *tx)
.await?
.flatten();
let mut restored_member = old_member.clone();
if current_entry_id.is_some() {
restored_member.content_entry_id = current_entry_id;
}
crate::state::instances::adapters::sqlite::content_rows::upsert_pack_member_in_transaction(
&restored_member,
&mut tx,
)
.await?;
}
crate::state::instances::adapters::sqlite::content_rows::bump_content_set_revision_in_transaction(
&metadata.applied_content_set.id,
&mut tx,
)
.await?;
tx.commit().await?;
Ok(())
}
async fn cache_instance_icon_from_url(
icon_url: &str,
) -> crate::Result<std::path::PathBuf> {
let state = State::get().await?;
// CurseForge avatar/CDN assets are frequently broken via local system
// proxies, so always download icons with a direct client.
let permit = state.fetch_semaphore.0.acquire().await?;
let response = CLIENT.get(icon_url).send().await?;
drop(permit);
if !response.status().is_success() {
return Err(ErrorKind::OtherError(format!(
"CurseForge icon download failed with HTTP {}",
response.status().as_u16()
))
.into());
}
let icon_bytes = response.bytes().await?;
let filename = icon_url.rsplit('/').next().unwrap_or("icon.png");
crate::util::fetch::write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await
}
fn extract_modpack_overrides(
archive_path: &Path,
instance_path: &Path,
cancellation: Option<&tokio_util::sync::CancellationToken>,
) -> crate::Result<u32> {
let file = std::fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file).map_err(modpack_zip_error)?;
let manifest = read_modpack_manifest(&mut archive)?;
let prefix = format!("{}/", manifest.overrides.trim_matches('/'));
struct OverrideTask {
index: usize,
target: PathBuf,
}
let mut tasks_by_target = HashMap::<PathBuf, OverrideTask>::new();
let mut total_size = 0_u64;
for index in 0..archive.len() {
crate::api::pack::archive_util::check_cancellation(cancellation)?;
let entry = archive.by_index(index).map_err(modpack_zip_error)?;
let entry_name =
crate::pack::detect::decode_zip_entry_name(entry.name_raw());
if entry.is_dir() || !entry_name.starts_with(&prefix) {
continue;
}
let relative = &entry_name[prefix.len()..];
let safe_path = safe_archive_relative_path(relative)?;
total_size = total_size.saturating_add(entry.size());
if total_size > 2 * 1024 * 1024 * 1024 {
return Err(ErrorKind::InputError(
"CurseForge modpack overrides exceed the extraction limit"
.to_string(),
)
.into());
}
let target = instance_path.join(safe_path);
// Preserve archive order semantics for duplicate targets: the last
// entry wins, while unique targets can be extracted independently.
tasks_by_target.insert(target.clone(), OverrideTask { index, target });
}
drop(archive);
let tasks = tasks_by_target.into_values().collect::<Vec<_>>();
if tasks.is_empty() {
return Ok(0);
}
let worker_count = OVERRIDE_EXTRACTION_CONCURRENCY.min(tasks.len());
let queue = Arc::new(Mutex::new(VecDeque::from(tasks)));
std::thread::scope(|scope| {
let mut workers = Vec::with_capacity(worker_count);
for _ in 0..worker_count {
let queue = Arc::clone(&queue);
workers.push(scope.spawn(move || -> crate::Result<u32> {
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(modpack_zip_error)?;
let mut files_written = 0_u32;
loop {
crate::api::pack::archive_util::check_cancellation(
cancellation,
)?;
let Some(task) = queue.lock().unwrap().pop_front() else {
break;
};
let mut entry = archive
.by_index(task.index)
.map_err(modpack_zip_error)?;
if let Some(parent) = task.target.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
crate::util::io::IOError::with_path(error, parent)
})?;
}
let mut output = std::fs::File::create(&task.target)
.map_err(|error| {
crate::util::io::IOError::with_path(error, &task.target)
})?;
let written = crate::api::pack::archive_util::copy_with_cancellation(
&mut entry,
&mut output,
cancellation,
&task.target,
)?;
if written != entry.size() {
return Err(ErrorKind::InputError(
"CurseForge modpack override was truncated during extraction"
.to_string(),
)
.into());
}
files_written = files_written.checked_add(1).ok_or_else(|| {
ErrorKind::InputError(
"CurseForge modpack contains too many override files"
.to_string(),
)
})?;
}
Ok(files_written)
}));
}
let mut files_written = 0_u32;
for worker in workers {
let count = worker.join().map_err(|_| {
ErrorKind::OtherError(
"CurseForge override extraction worker panicked"
.to_string(),
)
})??;
files_written =
files_written.checked_add(count).ok_or_else(|| {
ErrorKind::InputError(
"CurseForge modpack contains too many override files"
.to_string(),
)
})?;
}
Ok(files_written)
})
}
fn read_modpack_manifest<R: Read + Seek>(
archive: &mut zip::ZipArchive<R>,
) -> crate::Result<CurseForgeModpackManifest> {
let mut entry = archive.by_name("manifest.json").map_err(|_| {
ErrorKind::InputError(
"CurseForge modpack is missing manifest.json".to_string(),
)
})?;
let mut json = String::new();
entry.read_to_string(&mut json)?;
Ok(serde_json::from_str::<CurseForgeModpackManifest>(&json)?)
}
fn read_modpack_override_content<R: Read + Seek>(
archive: &mut zip::ZipArchive<R>,
manifest: &CurseForgeModpackManifest,
) -> crate::Result<Vec<CurseForgePackExpectedOverride>> {
let prefix = format!("{}/", manifest.overrides.trim_matches('/'));
let mut overrides = Vec::new();
for index in 0..archive.len() {
let entry = archive.by_index(index).map_err(modpack_zip_error)?;
let entry_name =
crate::pack::detect::decode_zip_entry_name(entry.name_raw());
if entry.is_dir() || !entry_name.starts_with(&prefix) {
continue;
}
let Some(override_content) =
curseforge_override_content(&entry_name[prefix.len()..])?
else {
continue;
};
overrides.push(override_content);
}
overrides.sort_by(|left, right| {
left.expected_relative_path
.cmp(&right.expected_relative_path)
});
overrides.dedup_by(|left, right| {
left.expected_relative_path == right.expected_relative_path
});
Ok(overrides)
}
fn curseforge_override_content(
relative_path: &str,
) -> crate::Result<Option<CurseForgePackExpectedOverride>> {
let relative_path = safe_archive_relative_path(relative_path)?;
let Some(project_type) = crate::state::instances::adapters::filesystem::project_type_from_relative_path(
&relative_path,
) else {
return Ok(None);
};
if !crate::state::instances::adapters::filesystem::is_scannable_project_path(
project_type,
&relative_path,
) {
return Ok(None);
}
Ok(Some(CurseForgePackExpectedOverride {
project_type,
expected_relative_path: relative_path,
}))
}
fn modpack_target(
manifest: &CurseForgeModpackManifest,
) -> crate::Result<CurseForgeModpackTarget> {
let Some(manifest_loader) = manifest
.minecraft
.mod_loaders
.iter()
.find(|loader| loader.primary)
.or_else(|| manifest.minecraft.mod_loaders.first())
else {
return Ok(CurseForgeModpackTarget {
game_version: manifest.minecraft.version.clone(),
loader: ModLoader::Vanilla,
loader_version: None,
});
};
let family = loader_family(&manifest_loader.id);
let manifest_loader_version = manifest_loader
.id
.strip_prefix(family)
.and_then(|version| version.strip_prefix('-'))
.filter(|version| !version.is_empty());
let disguised_cleanroom = family == "forge"
&& manifest.minecraft.version == "1.12.2"
&& manifest_loader_version
.is_some_and(|version| version.starts_with("0."));
let loader = match family {
"cleanroom" => ModLoader::Cleanroom,
"forge" if disguised_cleanroom => ModLoader::Cleanroom,
"forge" => ModLoader::Forge,
"fabric" => ModLoader::Fabric,
"quilt" => ModLoader::Quilt,
"neo" | "neoforge" => ModLoader::NeoForge,
_ => {
return Err(ErrorKind::InputError(format!(
"CurseForge modpack uses unsupported loader {}",
manifest_loader.id
))
.into());
}
};
let loader_version = manifest_loader_version
.filter(|version| !disguised_cleanroom || version.starts_with("0."))
.map(str::to_string);
Ok(CurseForgeModpackTarget {
game_version: manifest.minecraft.version.clone(),
loader,
loader_version,
})
}
fn modpack_zip_error(error: zip::result::ZipError) -> crate::Error {
ErrorKind::InputError(format!(
"CurseForge modpack archive is invalid: {error}"
))
.into()
}
fn safe_archive_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(ErrorKind::InputError(
"CurseForge modpack contains an invalid override path".to_string(),
)
.into());
}
Ok(path.to_string_lossy().replace('\\', "/"))
}
pub(crate) fn loader_family(loader_id: &str) -> &str {
loader_id.split('-').next().unwrap_or(loader_id)
}
fn loader_type(loader: &str) -> Option<u32> {
match loader {
"forge" | "cleanroom" => Some(1),
"fabric" => Some(4),
"quilt" => Some(5),
"neoforge" => Some(6),
_ => None,
}
}
fn merge_install_result(
target: &mut CurseForgeInstallResult,
mut source: CurseForgeInstallResult,
) {
target.installed.append(&mut source.installed);
target.manual_downloads.append(&mut source.manual_downloads);
target.failed_downloads.append(&mut source.failed_downloads);
target
.optional_dependencies
.append(&mut source.optional_dependencies);
target
.incompatible_dependencies
.append(&mut source.incompatible_dependencies);
}
pub async fn update_installed_file(
instance_id: &str,
relative_path: &str,
) -> crate::Result<CurseForgeInstallResult> {
use sqlx::Row;
let state = State::get().await?;
let row = sqlx::query(
"SELECT ref.provider_project_id, ref.provider_release_id, entry.project_type,
entry.ownership_kind,
content_set.game_version, content_set.loader,
instance.update_channel
FROM instance_files file
INNER JOIN instance_content_entries entry ON entry.file_id = file.id
INNER JOIN instance_content_provider_refs ref
ON ref.content_entry_id = entry.id AND ref.provider = 'curseforge'
INNER JOIN instance_content_sets content_set
ON content_set.id = entry.content_set_id
INNER JOIN instances instance ON instance.id = file.instance_id
WHERE file.instance_id = ? AND file.relative_path = ?
ORDER BY entry.modified_at DESC
LIMIT 1",
)
.bind(instance_id)
.bind(relative_path)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"The selected file is not linked to CurseForge".to_string(),
)
})?;
let project_id = row
.try_get::<String, _>("provider_project_id")?
.parse::<u32>()
.map_err(|_| {
ErrorKind::InputError(
"Stored CurseForge project ID is invalid".to_string(),
)
})?;
let current_file_id = row
.try_get::<Option<String>, _>("provider_release_id")?
.and_then(|value| value.parse::<u32>().ok());
let project_type = row.try_get::<String, _>("project_type")?;
let ownership_kind =
crate::state::instances::ContentOwnershipKind::from_str(
&row.try_get::<String, _>("ownership_kind")?,
)?;
let game_version = row.try_get::<String, _>("game_version")?;
let loader = row.try_get::<String, _>("loader")?;
let update_channel = row
.try_get::<Option<String>, _>("update_channel")?
.as_deref()
.map(ReleaseChannel::from_key)
.unwrap_or(ReleaseChannel::Release);
let mod_loader_type = match loader.as_str() {
"forge" => Some(1),
"fabric" => Some(4),
"quilt" => Some(5),
"neoforge" => Some(6),
_ => None,
};
let latest = select_latest_compatible_file(
project_id,
Some(game_version.clone()),
mod_loader_type,
Some(update_channel),
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"No compatible CurseForge update was found".to_string(),
)
})?;
if current_file_id == Some(latest.id) {
return Ok(CurseForgeInstallResult::default());
}
install_selected_file(
instance_id,
relative_path,
project_id,
latest.id,
project_type,
ownership_kind,
game_version,
mod_loader_type,
)
.await
}
pub async fn switch_installed_file_version(
instance_id: &str,
relative_path: &str,
file_id: u32,
) -> crate::Result<CurseForgeInstallResult> {
use sqlx::Row;
let state = State::get().await?;
let row = sqlx::query(
"SELECT ref.provider_project_id, ref.provider_release_id, entry.project_type,
entry.ownership_kind,
content_set.game_version, content_set.loader
FROM instance_files file
INNER JOIN instance_content_entries entry ON entry.file_id = file.id
INNER JOIN instance_content_provider_refs ref
ON ref.content_entry_id = entry.id AND ref.provider = 'curseforge'
INNER JOIN instance_content_sets content_set
ON content_set.id = entry.content_set_id
WHERE file.instance_id = ? AND file.relative_path = ?
ORDER BY entry.modified_at DESC
LIMIT 1",
)
.bind(instance_id)
.bind(relative_path)
.fetch_optional(&state.pool)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"The selected file is not linked to CurseForge".to_string(),
)
})?;
let project_id = row
.try_get::<String, _>("provider_project_id")?
.parse::<u32>()
.map_err(|_| {
ErrorKind::InputError(
"Stored CurseForge project ID is invalid".to_string(),
)
})?;
let current_file_id = row
.try_get::<Option<String>, _>("provider_release_id")?
.and_then(|value| value.parse::<u32>().ok());
let project_type = row.try_get::<String, _>("project_type")?;
let ownership_kind =
crate::state::instances::ContentOwnershipKind::from_str(
&row.try_get::<String, _>("ownership_kind")?,
)?;
let game_version = row.try_get::<String, _>("game_version")?;
let loader = row.try_get::<String, _>("loader")?;
let mod_loader_type = match loader.as_str() {
"forge" => Some(1),
"fabric" => Some(4),
"quilt" => Some(5),
"neoforge" => Some(6),
_ => None,
};
if current_file_id == Some(file_id) {
return Ok(CurseForgeInstallResult::default());
}
install_selected_file(
instance_id,
relative_path,
project_id,
file_id,
project_type,
ownership_kind,
game_version,
mod_loader_type,
)
.await
}
async fn install_selected_file(
instance_id: &str,
relative_path: &str,
project_id: u32,
file_id: u32,
project_type: String,
ownership_kind: crate::state::instances::ContentOwnershipKind,
game_version: String,
mod_loader_type: Option<u32>,
) -> crate::Result<CurseForgeInstallResult> {
let result = install_file(CurseForgeInstallRequest {
instance_id: instance_id.to_string(),
project_id,
file_id,
project_type,
ownership_kind,
manual_operation_kind:
crate::state::instances::ManualDownloadOperationKind::ContentUpdate,
game_version: Some(game_version),
mod_loader_type,
world_name: None,
install_dependencies: true,
excluded_dependency_project_ids: Vec::new(),
force_dependency_project_ids: Vec::new(),
dependency_plan_id: None,
})
.await?;
if ownership_kind
== crate::state::instances::ContentOwnershipKind::PackManaged
&& let Some(installed) = result
.installed
.iter()
.find(|file| !file.dependency && file.project_id == project_id)
{
mark_pack_member_version_override(
instance_id,
&installed.relative_path,
)
.await?;
}
if let Some(new_path) = result
.installed
.iter()
.find(|file| {
!file.dependency
&& file.project_id == project_id
&& file.relative_path != relative_path
})
.map(|file| file.relative_path.clone())
{
let state = State::get().await?;
if crate::state::instances::commands::archive_project_file(
instance_id,
relative_path,
&new_path,
&state,
)
.await?
.is_none()
{
crate::api::instance::remove_project(instance_id, relative_path)
.await?;
}
}
Ok(result)
}
pub async fn recognize_instance_files(
instance_id: &str,
) -> crate::Result<CurseForgeRecognitionResult> {
let mut result = CurseForgeRecognitionResult::default();
if capability().status != CurseForgeCapabilityStatus::Ready {
return Ok(result);
}
let state = State::get().await?;
let instance_files =
crate::api::instance::sync_content_files(instance_id).await?;
let tracked_file_ids = match crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
instance_id,
&state.pool,
)
.await?
{
Some(content_set) => {
crate::state::instances::adapters::sqlite::content_rows::get_content_entries(
&content_set.id,
&state.pool,
)
.await?
.into_iter()
.filter_map(|entry| entry.file_id)
.collect::<HashSet<_>>()
}
None => HashSet::new(),
};
let instance_path =
crate::api::instance::get_full_path(instance_id).await?;
let mut fingerprints = Vec::new();
let mut paths_by_fingerprint =
HashMap::<u64, Vec<(String, ProjectType)>>::new();
for file in instance_files.into_iter().filter(|file| !file.missing) {
if tracked_file_ids.contains(&file.id) {
continue;
}
let Some(project_type) =
crate::state::instances::adapters::filesystem::project_type_from_relative_path(
&file.relative_path,
)
else {
continue;
};
let full_path = instance_path.join(&file.relative_path);
let Ok(bytes) = tokio::fs::read(&full_path).await else {
continue;
};
let fingerprint = compute_fingerprint(&bytes) as u64;
fingerprints.push(fingerprint);
paths_by_fingerprint
.entry(fingerprint)
.or_default()
.push((file.relative_path, project_type));
}
let mut matches = HashMap::new();
for chunk in fingerprints.chunks(1000) {
let response = match_fingerprints(chunk.to_vec()).await?;
for matched in response.exact_matches {
matches.insert(matched.file.file_fingerprint, matched.file);
}
}
result.scanned = fingerprints.len() as u32;
let matched_project_ids =
matches.values().map(|file| file.mod_id).collect::<Vec<_>>();
let matched_projects = if matched_project_ids.is_empty() {
HashMap::new()
} else {
match get_projects(matched_project_ids).await {
Ok(projects) => projects
.into_iter()
.map(|project| (project.id, project))
.collect(),
Err(error) => {
tracing::warn!(
%error,
"Failed to resolve CurseForge project types while recognizing instance files"
);
HashMap::new()
}
}
};
for (fingerprint, paths) in paths_by_fingerprint {
if let Some(file) = matches.get(&fingerprint) {
for (path, path_project_type) in paths {
let project_type = matched_projects
.get(&file.mod_id)
.and_then(|project| {
recognized_project_type(project.class_id)
})
.unwrap_or(path_project_type);
let full_path = instance_path.join(&path);
let Some((size, sha1)) = verify_recognized_curseforge_file(
&full_path,
file.file_fingerprint,
)
.await?
else {
continue;
};
crate::state::record_verified_curseforge_project_file_atomic(
instance_id,
&path,
&sha1,
size,
project_type,
ContentSourceKind::CurseForge,
crate::state::instances::ContentOwnershipKind::UserAdded,
CurseForgeProjectId::new(file.mod_id)?,
CurseForgeFileId::new(file.id)?,
true,
&state,
)
.await?;
result.linked.push(CurseForgeInstalledFile {
project_id: file.mod_id,
file_id: file.id,
relative_path: path,
dependency: false,
});
result.matched += 1;
}
} else {
result
.unmatched_paths
.extend(paths.into_iter().map(|(path, _)| path));
}
}
result.unmatched_paths.sort();
Ok(result)
}
async fn verify_recognized_curseforge_file(
path: &Path,
expected_fingerprint: u64,
) -> crate::Result<Option<(u64, String)>> {
if expected_fingerprint == 0 {
return Ok(None);
}
let bytes = tokio::fs::read(path).await?;
if compute_fingerprint(&bytes) as u64 != expected_fingerprint {
return Ok(None);
}
Ok(Some((
bytes.len() as u64,
sha1_smol::Sha1::from(&bytes).hexdigest(),
)))
}
pub async fn import_manual_downloads(
instance_id: &str,
scan_directory: Option<PathBuf>,
) -> crate::Result<CurseForgeManualDownloadScanResult> {
let Some(download_directory) = scan_directory.or_else(dirs::download_dir)
else {
finalize_curseforge_manual_download_import(instance_id, false).await?;
return Ok(CurseForgeManualDownloadScanResult::default());
};
let mut result = CurseForgeManualDownloadScanResult {
download_directory: Some(download_directory.to_string_lossy().into()),
..Default::default()
};
let downloads = list_pending_manual_downloads(instance_id).await?;
for download in downloads {
let candidate = match find_manual_download_candidate(
&download_directory,
&download,
)
.await
{
Ok(candidate) => candidate,
Err(error) => {
result.errors.push(CurseForgeManualDownloadImportError {
project_id: download.project_id,
file_id: download.file_id,
message: error.to_string(),
});
continue;
}
};
let Some((source_path, size, sha1)) = candidate else {
continue;
};
match install_manual_download(
instance_id,
&download,
&source_path,
size,
&sha1,
)
.await
{
Ok(relative_path) => {
result.imported.push(CurseForgeManualDownloadImport {
project_id: download.project_id,
file_id: download.file_id,
relative_path,
});
}
Err(error) => {
result.errors.push(CurseForgeManualDownloadImportError {
project_id: download.project_id,
file_id: download.file_id,
message: error.to_string(),
});
}
}
}
finalize_curseforge_manual_download_import(
instance_id,
!result.imported.is_empty(),
)
.await?;
Ok(result)
}
pub async fn configure_manual_download_watcher(
enabled: bool,
scan_directory: Option<PathBuf>,
) -> crate::Result<Option<String>> {
let state = State::get().await?;
let directory = if enabled {
let Some(directory) = scan_directory.or_else(dirs::download_dir) else {
state
.file_watcher
.configure_manual_import_directory(None)
.await?;
return Ok(None);
};
let directory = tokio::fs::canonicalize(directory).await?;
if !tokio::fs::metadata(&directory).await?.is_dir() {
return Err(ErrorKind::InputError(
"Manual import watch path is not a directory".to_string(),
)
.into());
}
Some(directory)
} else {
None
};
state
.file_watcher
.configure_manual_import_directory(directory.clone())
.await?;
Ok(directory.map(|path| path.to_string_lossy().into_owned()))
}
pub(crate) async fn scan_pending_manual_downloads_in(
download_directory: &Path,
) -> crate::Result<()> {
let _scan_guard = MANUAL_IMPORT_SCAN_LOCK.lock().await;
let state = State::get().await?;
let instance_ids = sqlx::query_scalar::<_, String>(
"SELECT DISTINCT instance_id
FROM instance_pending_manual_downloads
WHERE provider = 'curseforge'
AND state IN ('waiting', 'matched', 'error')",
)
.fetch_all(&state.pool)
.await?;
for instance_id in instance_ids {
let result = import_manual_downloads(
&instance_id,
Some(download_directory.to_path_buf()),
)
.await?;
for error in result.errors {
tracing::debug!(
instance_id,
project_id = error.project_id,
file_id = error.file_id,
"Pending manual download not ready: {}",
error.message
);
}
}
let waiting_instances = crate::install::store::list(false, &state)
.await?
.into_iter()
.filter(|job| {
job.status == crate::install::InstallJobStatus::WaitingForUser
&& is_curseforge_manual_download_job(&job.state)
})
.filter_map(|job| job.instance_id)
.collect::<HashSet<_>>();
for instance_id in waiting_instances {
reconcile_curseforge_waiting_jobs_for_instance(&instance_id).await?;
}
Ok(())
}
pub async fn list_pending_manual_downloads(
instance_id: &str,
) -> crate::Result<Vec<CurseForgeManualDownload>> {
let state = State::get().await?;
Ok(crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
instance_id,
&state.pool,
)
.await?
.into_iter()
.filter_map(pending_manual_download)
.collect())
}
fn pending_manual_download(
item: crate::state::instances::PendingManualDownload,
) -> Option<CurseForgeManualDownload> {
if item.provider != ContentProvider::CurseForge {
return None;
}
serde_json::from_value::<CurseForgeManualDownload>(item.context.clone())
.ok()
.or_else(|| {
Some(CurseForgeManualDownload {
project_id: item.provider_project_id.parse().ok()?,
file_id: item.provider_release_id.parse().ok()?,
file_name: item.file_name,
ownership_kind: item
.pack_member_id
.is_some()
.then_some(
crate::state::instances::ContentOwnershipKind::PackManaged,
)
.unwrap_or_default(),
operation_kind: item.operation_kind,
website_url: item.website_url,
project_type: item.project_type.get_name().to_string(),
project_slug: String::new(),
target_folder: Path::new(&item.target_relative_path)
.parent()
.map(|path| path.to_string_lossy().replace('\\', "/"))
.unwrap_or_default(),
hashes: item
.expected_sha1
.map(|value| vec![CurseForgeFileHash { value, algo: 1 }])
.unwrap_or_default(),
file_length: item.expected_size.unwrap_or_default(),
file_fingerprint: item.expected_fingerprint.unwrap_or_default(),
})
})
}
#[derive(Clone, Debug)]
struct CurseForgeManualDownloadIntegrityMetadata {
project_id: u32,
file_id: u32,
hashes: Vec<CurseForgeFileHash>,
file_length: u64,
file_fingerprint: u64,
}
impl From<CurseForgeFile> for CurseForgeManualDownloadIntegrityMetadata {
fn from(file: CurseForgeFile) -> Self {
Self {
project_id: file.mod_id,
file_id: file.id,
hashes: file.hashes,
file_length: file.file_length,
file_fingerprint: file.file_fingerprint,
}
}
}
fn manual_download_has_integrity_metadata(
download: &CurseForgeManualDownload,
) -> bool {
download
.hashes
.iter()
.any(|hash| hash.algo == 1 && !hash.value.trim().is_empty())
|| download.file_fingerprint != 0
}
async fn ensure_manual_download_integrity_metadata(
download: &CurseForgeManualDownload,
) -> crate::Result<CurseForgeManualDownload> {
ensure_manual_download_integrity_metadata_with(
download,
resolve_manual_download_integrity_metadata,
)
.await
}
async fn resolve_manual_download_integrity_metadata(
project_id: u32,
file_id: u32,
) -> crate::Result<CurseForgeManualDownloadIntegrityMetadata> {
Ok(get_file(project_id, file_id).await?.into())
}
async fn ensure_manual_download_integrity_metadata_with<F, Fut>(
download: &CurseForgeManualDownload,
resolve_metadata: F,
) -> crate::Result<CurseForgeManualDownload>
where
F: FnOnce(u32, u32) -> Fut,
Fut: std::future::Future<
Output = crate::Result<CurseForgeManualDownloadIntegrityMetadata>,
>,
{
if manual_download_has_integrity_metadata(download) {
return Ok(download.clone());
}
let metadata =
resolve_metadata(download.project_id, download.file_id).await?;
if metadata.project_id != download.project_id
|| metadata.file_id != download.file_id
{
return Err(ErrorKind::InputError(
"CurseForge returned metadata for a different project or file"
.to_string(),
)
.into());
}
let mut hydrated = download.clone();
hydrated.hashes = metadata.hashes;
hydrated.file_length = metadata.file_length;
hydrated.file_fingerprint = metadata.file_fingerprint;
if !manual_download_has_integrity_metadata(&hydrated) {
return Err(ErrorKind::InputError(
"The required CurseForge file has no usable integrity metadata"
.to_string(),
)
.into());
}
Ok(hydrated)
}
async fn find_manual_download_candidate(
download_directory: &Path,
download: &CurseForgeManualDownload,
) -> crate::Result<Option<(std::path::PathBuf, u64, String)>> {
validate_file_name(&download.file_name)?;
let mut entries = match tokio::fs::read_dir(download_directory).await {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(None);
}
Err(error) => return Err(error.into()),
};
let mut verified_download = None;
while let Some(entry) = entries.next_entry().await? {
let actual_file_name = entry.file_name().to_string_lossy().to_string();
if !crate::util::downloads::browser_download_file_name_matches(
&actual_file_name,
&download.file_name,
) {
continue;
}
let path = entry.path();
tracing::debug!(
project_id = download.project_id,
file_id = download.file_id,
candidate_path = %path.display(),
"Found CurseForge manual download filename candidate"
);
if verified_download.is_none() {
verified_download = Some(
ensure_manual_download_integrity_metadata(download).await?,
);
}
match verify_manual_download_candidate_with_integrity(
&path,
verified_download
.as_ref()
.expect("integrity metadata resolved"),
true,
)
.await
{
Ok(Some((size, sha1))) => {
return Ok(Some((path, size, sha1)));
}
Ok(None) => continue,
Err(error) => {
tracing::debug!(
project_id = download.project_id,
file_id = download.file_id,
candidate_path = %path.display(),
%error,
"Unable to inspect CurseForge manual download candidate"
);
continue;
}
}
}
Ok(None)
}
async fn verify_manual_download_candidate(
path: &Path,
download: &CurseForgeManualDownload,
require_matching_name: bool,
) -> crate::Result<Option<(u64, String)>> {
let download = ensure_manual_download_integrity_metadata(download).await?;
verify_manual_download_candidate_with_integrity(
path,
&download,
require_matching_name,
)
.await
}
async fn verify_manual_download_candidate_with_integrity(
path: &Path,
download: &CurseForgeManualDownload,
require_matching_name: bool,
) -> crate::Result<Option<(u64, String)>> {
let actual_file_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if require_matching_name
&& !crate::util::downloads::browser_download_file_name_matches(
&actual_file_name,
&download.file_name,
)
{
trace_manual_download_candidate_rejection(
path,
download,
"name_mismatch",
);
return Ok(None);
}
let metadata = tokio::fs::symlink_metadata(path).await?;
if crate::util::io::is_symlink_or_reparse(&metadata) {
trace_manual_download_candidate_rejection(
path,
download,
"symlink_or_reparse",
);
return Ok(None);
}
if !metadata.is_file() {
trace_manual_download_candidate_rejection(path, download, "non_file");
return Ok(None);
}
if download.file_length > 0 && metadata.len() != download.file_length {
trace_manual_download_candidate_rejection(
path,
download,
"size_mismatch",
);
return Ok(None);
}
let (size, sha1) = sha1_file_async(path).await?;
if let Some(expected_sha1) = download
.hashes
.iter()
.find(|hash| hash.algo == 1 && !hash.value.trim().is_empty())
.map(|hash| hash.value.as_str())
{
if !sha1.eq_ignore_ascii_case(expected_sha1) {
trace_manual_download_candidate_rejection(
path,
download,
"sha1_mismatch",
);
return Ok(None);
}
return Ok(Some((size, sha1)));
}
if download.file_fingerprint == 0 {
trace_manual_download_candidate_rejection(
path,
download,
"missing_integrity_metadata",
);
return Err(ErrorKind::InputError(
"The required CurseForge file has no usable integrity metadata"
.to_string(),
)
.into());
}
let bytes = tokio::fs::read(path).await?;
if compute_fingerprint(&bytes) as u64 != download.file_fingerprint {
trace_manual_download_candidate_rejection(
path,
download,
"fingerprint_mismatch",
);
return Ok(None);
}
Ok(Some((size, sha1)))
}
fn trace_manual_download_candidate_rejection(
path: &Path,
download: &CurseForgeManualDownload,
reason: &'static str,
) {
tracing::trace!(
project_id = download.project_id,
file_id = download.file_id,
candidate_path = %path.display(),
expected_file_name = %download.file_name,
rejection_reason = reason,
"Rejected CurseForge manual download candidate"
);
}
pub(crate) async fn import_pending_manual_download_from_path(
instance_id: &str,
source_path: &Path,
) -> crate::Result<Option<String>> {
let state = State::get().await?;
let pending = crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
instance_id,
&state.pool,
)
.await?;
let actual_file_name = source_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let mut matched_pending_name = false;
for download in pending.into_iter().filter_map(pending_manual_download) {
if !crate::util::downloads::browser_download_file_name_matches(
&actual_file_name,
&download.file_name,
) {
continue;
}
matched_pending_name = true;
let Some((size, sha1)) =
verify_manual_download_candidate(source_path, &download, true)
.await?
else {
continue;
};
let relative_path = install_manual_download(
instance_id,
&download,
source_path,
size,
&sha1,
)
.await?;
finalize_curseforge_manual_download_import(instance_id, false).await?;
return Ok(Some(relative_path));
}
if matched_pending_name {
return Err(ErrorKind::InputError(
"The selected file does not match the required CurseForge file"
.to_string(),
)
.into());
}
Ok(None)
}
pub async fn import_pending_manual_download_file(
instance_id: &str,
project_id: u32,
file_id: u32,
source_path: PathBuf,
) -> crate::Result<CurseForgeManualDownloadImport> {
import_pending_manual_download_file_with_integrity_resolver(
instance_id,
project_id,
file_id,
source_path,
resolve_manual_download_integrity_metadata,
)
.await
}
async fn import_pending_manual_download_file_with_integrity_resolver<F, Fut>(
instance_id: &str,
project_id: u32,
file_id: u32,
source_path: PathBuf,
resolve_metadata: F,
) -> crate::Result<CurseForgeManualDownloadImport>
where
F: FnOnce(u32, u32) -> Fut,
Fut: std::future::Future<
Output = crate::Result<CurseForgeManualDownloadIntegrityMetadata>,
>,
{
tracing::debug!(
instance_id,
project_id,
file_id,
source_path = %source_path.display(),
"Importing selected CurseForge manual download file"
);
let pending = list_pending_manual_downloads(instance_id).await?;
let pending_count = pending.len();
let download = pending.into_iter().find(|download| {
download.project_id == project_id && download.file_id == file_id
});
tracing::debug!(
instance_id,
project_id,
file_id,
pending_count,
pending_found = download.is_some(),
"Looked up selected CurseForge pending manual download"
);
let download = download.ok_or_else(|| {
ErrorKind::InputError(
"The selected CurseForge file is not pending for this instance"
.to_string(),
)
})?;
let download = ensure_manual_download_integrity_metadata_with(
&download,
resolve_metadata,
)
.await?;
let Some((size, sha1)) = verify_manual_download_candidate_with_integrity(
&source_path,
&download,
false,
)
.await?
else {
return Err(ErrorKind::InputError(
"The selected file does not match the required CurseForge file"
.to_string(),
)
.into());
};
let relative_path = install_manual_download(
instance_id,
&download,
&source_path,
size,
&sha1,
)
.await?;
finalize_curseforge_manual_download_import(instance_id, true).await?;
Ok(CurseForgeManualDownloadImport {
project_id,
file_id,
relative_path,
})
}
async fn finalize_curseforge_manual_download_import(
instance_id: &str,
emit_content_changed: bool,
) -> crate::Result<()> {
let reconciliation =
reconcile_curseforge_waiting_jobs_for_instance(instance_id).await;
let content_changed = if emit_content_changed {
crate::api::instance::emit_content_changed(instance_id).await
} else {
Ok(())
};
reconciliation?;
content_changed
}
pub(crate) async fn reconcile_curseforge_waiting_jobs_for_instance(
instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
reconcile_curseforge_waiting_jobs_for_instance_with_state(
instance_id,
&state,
)
.await
}
pub(crate) async fn reconcile_persisted_curseforge_waiting_jobs(
state: &State,
) -> crate::Result<()> {
let mut resume_job = resume_curseforge_install_job;
reconcile_persisted_curseforge_waiting_jobs_with_resume(
state,
&mut resume_job,
)
.await
}
async fn reconcile_persisted_curseforge_waiting_jobs_with_resume<F, Fut>(
state: &State,
resume_job: &mut F,
) -> crate::Result<()>
where
F: FnMut(uuid::Uuid) -> Fut,
Fut: std::future::Future<Output = crate::Result<()>>,
{
let jobs = crate::install::store::list(false, state).await?;
for instance_id in curseforge_waiting_job_instance_ids(&jobs) {
reconcile_curseforge_waiting_jobs_for_instance_with_resume(
&instance_id,
state,
resume_job,
)
.await?;
}
Ok(())
}
fn curseforge_waiting_job_instance_ids(
jobs: &[crate::install::store::InstallJobRecord],
) -> Vec<String> {
let mut instance_ids = jobs
.iter()
.filter(|job| is_reconcilable_curseforge_waiting_job(job))
.filter_map(|job| job.instance_id.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
instance_ids.sort_unstable();
instance_ids
}
async fn reconcile_curseforge_waiting_jobs_for_instance_with_state(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
let mut resume_job = resume_curseforge_install_job;
reconcile_curseforge_waiting_jobs_for_instance_with_resume(
instance_id,
state,
&mut resume_job,
)
.await
}
async fn resume_curseforge_install_job(
job_id: uuid::Uuid,
) -> crate::Result<()> {
crate::install::runner::resume_job(job_id).await?;
Ok(())
}
async fn reconcile_curseforge_waiting_jobs_for_instance_with_resume<F, Fut>(
instance_id: &str,
state: &State,
resume_job: &mut F,
) -> crate::Result<()>
where
F: FnMut(uuid::Uuid) -> Fut,
Fut: std::future::Future<Output = crate::Result<()>>,
{
let pending = crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
instance_id,
&state.pool,
)
.await?
.into_iter()
.filter(|download| download.provider == ContentProvider::CurseForge)
.map(|download| {
(download.provider_project_id, download.provider_release_id)
})
.collect::<HashSet<_>>();
let materialized = crate::state::instances::adapters::sqlite::content_rows::get_materialized_curseforge_downloads(
instance_id,
&state.pool,
)
.await?
.into_iter()
.collect::<HashSet<_>>();
let jobs = crate::install::store::list(false, &state).await?;
let waiting_job_count = jobs
.iter()
.filter(|job| {
job.instance_id.as_deref() == Some(instance_id)
&& is_reconcilable_curseforge_waiting_job(job)
})
.count();
tracing::debug!(
instance_id,
pending_key_count = pending.len(),
waiting_job_count,
"Starting CurseForge waiting install job reconciliation"
);
let mut recovered_item_count = 0usize;
let mut resume_job_called = false;
for job in jobs {
if job.instance_id.as_deref() != Some(instance_id)
|| !is_reconcilable_curseforge_waiting_job(&job)
{
continue;
}
let operation_lock = state
.install_job_operation_locks
.entry(job.id)
.or_default()
.clone();
let _operation_guard = operation_lock.lock().await;
let current =
crate::install::store::get_required(job.id, state).await?;
if current.instance_id.as_deref() != Some(instance_id)
|| !is_reconcilable_curseforge_waiting_job(&current)
{
continue;
}
let mut reconciled_materialized = materialized.clone();
reconciled_materialized.extend(
manually_imported_curseforge_world_downloads(
instance_id,
&current.state,
)
.await?,
);
let reconciliation = curseforge_manual_download_reconciliation(
&current.state.download_items(),
&pending,
&reconciled_materialized,
);
for inconsistent in &reconciliation.inconsistent {
tracing::warn!(
job_id = %current.id,
instance_id,
project_id = %inconsistent.project_id,
file_id = %inconsistent.file_id,
item_path = %inconsistent.path,
reason = inconsistent.reason,
"CurseForge manual download job state is inconsistent"
);
}
let recovered_count = reconciliation.recovered.len();
recovered_item_count += recovered_count;
if recovered_count > 0 {
InstallProgressReporter::new(current.id, current.state)
.record_events(reconciliation.recovered)
.await?;
}
let latest = crate::install::store::get_required(job.id, state).await?;
if latest.status != crate::install::InstallJobStatus::WaitingForUser {
continue;
}
let mut latest_materialized = materialized.clone();
latest_materialized.extend(
manually_imported_curseforge_world_downloads(
instance_id,
&latest.state,
)
.await?,
);
let latest_reconciliation = curseforge_manual_download_reconciliation(
&latest.state.download_items(),
&pending,
&latest_materialized,
);
tracing::debug!(
job_id = %latest.id,
instance_id,
pending_count = pending.len(),
manual_skipped_count = reconciliation.manual_skipped_count,
materialized_exact_match_count =
reconciliation.materialized_exact_match_count,
recovered_count,
unresolved_pending_count =
latest_reconciliation.unresolved_pending_count,
inconsistent_count = latest_reconciliation.inconsistent.len(),
"Reconciled CurseForge waiting install job"
);
if latest_reconciliation.should_resume() {
resume_job_called = true;
match resume_job(latest.id).await {
Ok(_) => {
tracing::info!(
job_id = %latest.id,
instance_id,
pending_count = pending.len(),
manual_skipped_count =
reconciliation.manual_skipped_count,
materialized_exact_match_count =
reconciliation.materialized_exact_match_count,
recovered_count,
unresolved_pending_count = 0,
inconsistent_count = 0,
"Reconciled completed CurseForge manual downloads and resuming install job"
);
}
Err(error) => {
let current =
crate::install::store::get_required(latest.id, state)
.await?;
if current.status
== crate::install::InstallJobStatus::WaitingForUser
{
return Err(error);
}
}
}
}
}
tracing::debug!(
instance_id,
pending_key_count = pending.len(),
waiting_job_count,
recovered_item_count,
resume_job_called,
"Completed CurseForge waiting install job reconciliation"
);
Ok(())
}
#[derive(Debug)]
struct CurseForgeManualDownloadInconsistency {
path: String,
project_id: String,
file_id: String,
reason: &'static str,
}
#[derive(Debug, Default)]
struct CurseForgeManualDownloadReconciliation {
recovered: Vec<InstallJobEventKind>,
inconsistent: Vec<CurseForgeManualDownloadInconsistency>,
manual_skipped_count: usize,
materialized_exact_match_count: usize,
unresolved_pending_count: usize,
}
impl CurseForgeManualDownloadReconciliation {
fn should_resume(&self) -> bool {
self.unresolved_pending_count == 0 && self.inconsistent.is_empty()
}
}
fn curseforge_manual_download_reconciliation(
items: &[crate::install::model::DownloadItemSnapshot],
pending: &HashSet<(String, String)>,
materialized: &HashSet<(String, String)>,
) -> CurseForgeManualDownloadReconciliation {
let mut result = CurseForgeManualDownloadReconciliation::default();
for item in items.iter().filter(|item| {
item.status == crate::install::model::DownloadItemStatus::Skipped
&& item.manual_url.is_some()
&& item.project_id.is_some()
&& item.version_id.is_some()
}) {
result.manual_skipped_count += 1;
let project_id = item.project_id.as_ref().expect("filtered project ID");
let version_id = item.version_id.as_ref().expect("filtered file ID");
if pending.contains(&(project_id.clone(), version_id.clone())) {
result.unresolved_pending_count += 1;
} else if materialized
.contains(&(project_id.clone(), version_id.clone()))
{
result.materialized_exact_match_count += 1;
result
.recovered
.push(InstallJobEventKind::ContentFileRecovered {
path: item.id.clone(),
bytes: item.bytes_total.unwrap_or(0),
});
} else {
result
.inconsistent
.push(CurseForgeManualDownloadInconsistency {
path: item.id.clone(),
project_id: project_id.clone(),
file_id: version_id.clone(),
reason: "pending_missing_but_not_materialized",
});
}
}
result
}
fn is_reconcilable_curseforge_waiting_job(
job: &crate::install::store::InstallJobRecord,
) -> bool {
job.status == crate::install::InstallJobStatus::WaitingForUser
&& job.instance_id.is_some()
&& is_curseforge_manual_download_job(&job.state)
&& matches!(
&job.state.pause_reason,
Some(crate::install::model::InstallPauseReason::MissingRequiredContent { .. })
)
&& job.state.download_items().iter().any(|item| {
item.status == crate::install::model::DownloadItemStatus::Skipped
&& item.manual_url.is_some()
&& item.project_id.is_some()
&& item.version_id.is_some()
})
}
fn is_curseforge_manual_download_job(
job: &crate::install::model::InstallJobState,
) -> bool {
if !matches!(
job.provider(),
crate::install::model::InstallJobProvider::CurseForge
| crate::install::model::InstallJobProvider::Local
) {
return false;
}
job.download_items().iter().any(|item| {
item.manual_url.is_some()
&& item.project_id.is_some()
&& item.version_id.is_some()
})
}
async fn manually_imported_curseforge_world_downloads(
instance_id: &str,
job_state: &crate::install::model::InstallJobState,
) -> crate::Result<HashSet<(String, String)>> {
let crate::install::model::InstallRequest::InstallCurseForgeWorld {
request,
..
} = &job_state.request
else {
return Ok(HashSet::new());
};
let project_id = request.project_id.to_string();
let file_id = request.file_id.to_string();
let instance_path =
crate::api::instance::get_full_path(instance_id).await?;
let imported = job_state.download_items().into_iter().any(|item| {
if item.status != crate::install::model::DownloadItemStatus::Skipped
|| item.project_id.as_deref() != Some(project_id.as_str())
|| item.version_id.as_deref() != Some(file_id.as_str())
{
return false;
}
let Some(file_name) = Path::new(&item.id).file_name() else {
return false;
};
let Some(world_name) = Path::new(file_name).file_stem() else {
return false;
};
instance_path
.join("saves")
.join(world_name)
.join("level.dat")
.is_file()
});
Ok(imported
.then(|| HashSet::from([(project_id, file_id)]))
.unwrap_or_default())
}
async fn install_manual_download(
instance_id: &str,
download: &CurseForgeManualDownload,
source_path: &Path,
size: u64,
sha1: &str,
) -> crate::Result<String> {
if download.project_type == "modpack" {
let verified_directory = tempfile::tempdir()?;
let verified_source =
verified_directory.path().join("manual-download.pack");
crate::state::materialize_verified_project_download_copy(
source_path,
&verified_source,
size,
sha1,
)
.await?;
crate::install::install_pack_to_existing_instance(
instance_id.to_string(),
crate::api::pack::install_from::CreatePackLocation::FromFile {
path: verified_source,
},
None,
)
.await?;
let state = State::get().await?;
let content_set = crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?;
let _instance_lock = state.lock_instance_content(instance_id).await;
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
crate::state::instances::adapters::sqlite::content_rows::complete_pending_manual_download(
instance_id,
&download.project_id.to_string(),
&download.file_id.to_string(),
None,
&mut tx,
)
.await?;
crate::state::instances::adapters::sqlite::content_rows::bump_content_set_revision_in_transaction(
&content_set.id,
&mut tx,
)
.await?;
tx.commit().await?;
let relative_path = source_path.to_string_lossy().to_string();
tracing::info!(
instance_id,
project_id = download.project_id,
file_id = download.file_id,
relative_path = %relative_path,
"Completed CurseForge pending manual download record"
);
return Ok(relative_path);
}
if download.project_type == "world" {
validate_world_archive_name(&download.file_name)?;
let verified_directory = tempfile::tempdir()?;
let verified_source =
verified_directory.path().join(&download.file_name);
crate::state::materialize_verified_project_download_copy(
source_path,
&verified_source,
size,
sha1,
)
.await?;
let state = State::get().await?;
let world_name = crate::state::instances::commands::import_world_save(
&state,
instance_id,
&verified_source,
None,
)
.await?;
complete_manual_world_download(instance_id, download).await?;
return Ok(format!("saves/{world_name}"));
}
let project_type = managed_project_type(&download.project_type)?;
let target_folder = manual_download_target_folder(download, project_type)?;
let state = State::get().await?;
let localized_candidate = if project_type == ProjectType::Mod {
None
} else {
chinese_file_title_for_curseforge_slug(&download.project_slug)
.and_then(|title| {
localized_content_file_name(&download.file_name, &title)
})
.map(|file_name| format!("{target_folder}/{file_name}"))
};
let relative_path = crate::state::resolve_content_install_relative_path(
instance_id,
format!("{target_folder}/{}", download.file_name),
localized_candidate,
&state.pool,
)
.await?;
let full_path = crate::api::instance::get_full_path(instance_id)
.await?
.join(&relative_path);
let previous_path =
crate::state::materialize_verified_project_download_copy(
source_path,
&full_path,
size,
sha1,
)
.await?;
let record_result =
crate::state::record_verified_curseforge_project_file_atomic(
instance_id,
&relative_path,
sha1,
size,
project_type,
ContentSourceKind::CurseForge,
download.ownership_kind,
CurseForgeProjectId::new(download.project_id)?,
CurseForgeFileId::new(download.file_id)?,
true,
&state,
)
.await;
match record_result {
Ok(()) => {
crate::state::finalize_project_materialization(
previous_path.as_deref(),
)
.await?;
}
Err(error) => {
crate::state::restore_project_materialization(
&full_path,
previous_path.as_deref(),
)
.await?;
return Err(error);
}
}
tracing::info!(
instance_id,
project_id = download.project_id,
file_id = download.file_id,
relative_path = %relative_path,
"Completed CurseForge pending manual download record"
);
if download.ownership_kind
== crate::state::instances::ContentOwnershipKind::PackManaged
&& download.operation_kind
== crate::state::instances::ManualDownloadOperationKind::ContentUpdate
{
mark_pack_member_version_override(instance_id, &relative_path).await?;
}
Ok(relative_path)
}
async fn mark_pack_member_version_override(
instance_id: &str,
relative_path: &str,
) -> crate::Result<()> {
let state = State::get().await?;
let _instance_lock = state.lock_instance_content(instance_id).await;
let content_set = crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?;
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
let updated = sqlx::query(
"UPDATE instance_pack_members
SET override_kind = 'version', modified_at = ?
WHERE content_entry_id IN (
SELECT entry.id
FROM instance_content_entries entry
INNER JOIN instance_files file ON file.id = entry.file_id
WHERE entry.content_set_id = ? AND file.relative_path = ?
)",
)
.bind(chrono::Utc::now().timestamp())
.bind(&content_set.id)
.bind(relative_path)
.execute(&mut *tx)
.await?;
if updated.rows_affected() > 0 {
crate::state::instances::adapters::sqlite::content_rows::bump_content_set_revision_in_transaction(
&content_set.id,
&mut tx,
)
.await?;
}
tx.commit().await?;
Ok(())
}
fn manual_download_target_folder(
download: &CurseForgeManualDownload,
project_type: ProjectType,
) -> crate::Result<String> {
let target_folder = if download.target_folder.is_empty() {
project_type.get_folder()
} else {
&download.target_folder
};
if target_folder == project_type.get_folder() {
return Ok(target_folder.to_string());
}
let components = Path::new(target_folder).components().collect::<Vec<_>>();
if project_type == ProjectType::DataPack
&& components.len() == 3
&& matches!(components[0], Component::Normal(value) if value == "saves")
&& matches!(components[1], Component::Normal(value) if !value.is_empty())
&& matches!(components[2], Component::Normal(value) if value == "datapacks")
{
return Ok(target_folder.to_string());
}
Err(ErrorKind::InputError(
"CurseForge manual download has an invalid target folder".to_string(),
)
.into())
}
pub(crate) async fn select_latest_compatible_file(
project_id: u32,
game_version: Option<String>,
mod_loader_type: Option<u32>,
release_channel: Option<ReleaseChannel>,
) -> crate::Result<Option<CurseForgeFile>> {
let response = get_files(
project_id,
CurseForgeFilesRequest {
game_version,
mod_loader_type,
game_version_type_id: None,
index: 0,
page_size: MAX_PAGE_SIZE,
},
)
.await?;
Ok(response.files.into_iter().find(|file| {
file.is_available
&& match release_channel {
Some(ReleaseChannel::Release) => file.release_type == 1,
Some(ReleaseChannel::Beta) => file.release_type <= 2,
Some(ReleaseChannel::Alpha) | None => true,
}
}))
}
/// Why a dependency file was selected. This makes fallback provenance
/// explicit rather than allowing it to look like a native declaration.
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CurseForgeDependencySelectionReason {
NativeStrictMatch,
Sha1VerifiedModrinthFallback,
}
/// A CurseForge file selected for a dependency after local target validation.
struct SelectedDependencyFile {
file: CurseForgeFile,
reason: CurseForgeDependencySelectionReason,
}
#[cfg(debug_assertions)]
fn log_dependency_file_selection(
project_id: u32,
target_game_version: &str,
target_mod_loader_type: Option<u32>,
source: &'static str,
file: &CurseForgeFile,
) {
tracing::warn!(
project_id,
target_game_version,
?target_mod_loader_type,
source,
selected_file_id = file.id,
selected_file_name = %file.file_name,
game_versions = ?file.game_versions,
sortable_game_versions = ?file.sortable_game_versions,
"CurseForge dependency file selected"
);
}
fn dependency_target_game_version(game_version: Option<&str>) -> Option<&str> {
game_version.filter(|version| !version.trim().is_empty())
}
async fn select_dependency_file(
project_id: u32,
game_version: Option<String>,
mod_loader_type: Option<u32>,
) -> crate::Result<Option<SelectedDependencyFile>> {
let Some(target_game_version) =
dependency_target_game_version(game_version.as_deref())
else {
#[cfg(debug_assertions)]
tracing::warn!(
project_id,
?mod_loader_type,
"CurseForge dependency target has no Minecraft version"
);
return Ok(None);
};
let filtered = get_files(
project_id,
CurseForgeFilesRequest {
game_version: game_version.clone(),
mod_loader_type,
game_version_type_id: None,
index: 0,
page_size: MAX_PAGE_SIZE,
},
)
.await?
.files;
if let Some(file) = select_best_dependency_file(
filtered,
Some(target_game_version),
mod_loader_type,
) {
#[cfg(debug_assertions)]
log_dependency_file_selection(
project_id,
target_game_version,
mod_loader_type,
"filtered",
&file,
);
return Ok(Some(SelectedDependencyFile {
file,
reason: CurseForgeDependencySelectionReason::NativeStrictMatch,
}));
}
let unfiltered = get_files(
project_id,
CurseForgeFilesRequest {
game_version: None,
mod_loader_type: None,
game_version_type_id: None,
index: 0,
page_size: MAX_PAGE_SIZE,
},
)
.await?
.files;
let selected = select_best_dependency_file(
unfiltered,
Some(target_game_version),
mod_loader_type,
);
#[cfg(debug_assertions)]
if let Some(file) = selected.as_ref() {
log_dependency_file_selection(
project_id,
target_game_version,
mod_loader_type,
"unfiltered",
file,
);
}
Ok(selected.map(|file| SelectedDependencyFile {
file,
reason: CurseForgeDependencySelectionReason::NativeStrictMatch,
}))
}
async fn resolve_modrinth_fallback_plan(
file: &CurseForgeFile,
item_type: ProjectType,
request: &CurseForgeInstallRequest,
state: &State,
) -> crate::Result<Option<modrinth_content_management::ResolveContentPlan>> {
let Some(sha1) = file
.hashes
.iter()
.find(|hash| hash.algo == 1)
.map(|hash| hash.value.as_str())
else {
return Ok(None);
};
let (Some(game_version), Some(loader_type)) =
(request.game_version.as_deref(), request.mod_loader_type)
else {
return Ok(None);
};
let Some(loader) = mod_loader_from_curseforge_type(loader_type) else {
return Ok(None);
};
let matches = CachedEntry::get_file_many(
&[sha1],
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
let Some(file_match) = matches.into_iter().next() else {
return Ok(None);
};
let version = CachedEntry::get_version(
&ModrinthVersionId::new(file_match.version_id)?,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
let Some(version) = version else {
return Ok(None);
};
if !modrinth_version_matches_target(&version, game_version, loader) {
return Ok(None);
}
crate::state::instances::commands::resolve_install_plan_for_target(
crate::state::instances::commands::InstanceInstallProjectRequest {
project_id: version.project_id,
version_id: Some(version.id),
content_type: item_type.into(),
selected: Default::default(),
excluded_project_ids: Vec::new(),
force_project_ids: Vec::new(),
},
game_version.to_string(),
loader,
state,
)
.await
.map(Some)
}
fn modrinth_version_matches_target(
version: &crate::state::Version,
game_version: &str,
loader: ModLoader,
) -> bool {
version
.game_versions
.iter()
.any(|value| value == game_version)
&& version
.loaders
.iter()
.any(|value| value.eq_ignore_ascii_case(loader.as_str()))
}
fn mod_loader_from_curseforge_type(mod_loader_type: u32) -> Option<ModLoader> {
match mod_loader_type {
1 => Some(ModLoader::Forge),
4 => Some(ModLoader::Fabric),
5 => Some(ModLoader::Quilt),
6 => Some(ModLoader::NeoForge),
_ => None,
}
}
async fn preview_modrinth_fallbacks(
plan: &modrinth_content_management::ResolveContentPlan,
parent_project_id: u32,
state: &State,
) -> crate::Result<Vec<CurseForgeModrinthFallbackPreview>> {
let mut preview = Vec::new();
for dependency in &plan.dependencies {
let version = CachedEntry::get_version(
&ModrinthVersionId::new(dependency.version_id.clone())?,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
let Some(version) = version else {
continue;
};
let project = CachedEntry::get_project(
&ModrinthProjectId::new(dependency.project_id.clone())?,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
preview.push(CurseForgeModrinthFallbackPreview {
project_id: dependency.project_id.clone(),
version_id: dependency.version_id.clone(),
title: project
.as_ref()
.map(|project| project.title.clone())
.unwrap_or_else(|| dependency.project_id.clone()),
version_number: version.version_number,
parent_project_id,
icon_url: project.and_then(|project| project.icon_url),
required: dependency.required,
});
}
Ok(preview)
}
async fn append_modrinth_fallback_plan(
resolution_plan: &mut DependencyResolutionPlan,
fallback_plan: &modrinth_content_management::ResolveContentPlan,
curseforge_parent: &ContentProviderRef,
state: &State,
) -> crate::Result<()> {
for dependency in &fallback_plan.dependencies {
let content = ContentProviderRef::Modrinth {
project_id: ModrinthProjectId::new(dependency.project_id.clone())?,
version_id: Some(ModrinthVersionId::new(
dependency.version_id.clone(),
)?),
};
let parent = match dependency.dependent_on_version_id.as_deref() {
Some(version_id)
if version_id == fallback_plan.primary.version_id =>
{
curseforge_parent.clone()
}
Some(version_id) => {
let Some(parent) = fallback_plan
.dependencies
.iter()
.find(|candidate| candidate.version_id == version_id)
else {
resolution_plan.issues.push(DependencyResolutionIssue {
provider: ContentProvider::Modrinth,
project_id: dependency.project_id.clone(),
parent: Some(curseforge_parent.clone()),
relation: Some(
crate::state::instances::ContentDependencyKind::Required,
),
reason: "dependency_parent_unresolved".to_string(),
});
continue;
};
ContentProviderRef::Modrinth {
project_id: ModrinthProjectId::new(
parent.project_id.clone(),
)?,
version_id: Some(ModrinthVersionId::new(
parent.version_id.clone(),
)?),
}
}
None => curseforge_parent.clone(),
};
let version = CachedEntry::get_version(
&ModrinthVersionId::new(dependency.version_id.clone())?,
Some(CacheBehaviour::MustRevalidate),
&state.pool,
&state.api_semaphore,
)
.await?;
let Some(version) = version else {
resolution_plan.issues.push(DependencyResolutionIssue {
provider: ContentProvider::Modrinth,
project_id: dependency.project_id.clone(),
parent: Some(parent),
relation: Some(
crate::state::instances::ContentDependencyKind::Required,
),
reason: "missing_version".to_string(),
});
continue;
};
let file = version
.files
.iter()
.find(|file| file.primary)
.or_else(|| version.files.first());
resolution_plan.edges.push(DependencyResolutionEdge {
parent: parent.clone(),
child: content.clone(),
relation: crate::state::instances::ContentDependencyKind::Required,
evidence_provider: ContentProvider::Modrinth,
});
if !resolution_plan
.nodes
.iter()
.any(|node| node.content == content)
{
resolution_plan.nodes.push(DependencyResolutionNode {
content,
parent: Some(parent),
relation:
crate::state::instances::ContentDependencyKind::Required,
source: ContentProvider::Modrinth,
selection_reason:
DependencySelectionReason::Sha1VerifiedModrinthFallback,
expected_sha1: file
.and_then(|file| file.hashes.get("sha1").cloned()),
expected_size: file.map(|file| file.size as u64),
});
}
}
Ok(())
}
fn curseforge_content_ref(
project_id: u32,
file_id: u32,
) -> crate::Result<ContentProviderRef> {
Ok(ContentProviderRef::CurseForge {
project_id: CurseForgeProjectId::new(project_id)?,
file_id: Some(CurseForgeFileId::new(file_id)?),
})
}
fn curseforge_file_sha1(file: &CurseForgeFile) -> Option<String> {
file.hashes
.iter()
.find(|hash| hash.algo == 1)
.map(|hash| hash.value.clone())
}
async fn instance_content_revision(
instance_id: &str,
state: &State,
) -> crate::Result<Option<u64>> {
if instance_id.is_empty() {
return Ok(None);
}
Ok(
crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
instance_id,
&state.pool,
)
.await?
.map(|content_set| content_set.revision),
)
}
fn store_dependency_resolution_plan(plan: DependencyResolutionPlan) {
let now = Instant::now();
DEPENDENCY_RESOLUTION_PLANS.retain(|_, cached| cached.expires_at > now);
DEPENDENCY_RESOLUTION_PLANS.insert(
plan.id.clone(),
CachedDependencyResolutionPlan {
plan,
expires_at: now + DEPENDENCY_PLAN_TTL,
},
);
}
impl From<CurseForgeDependencySelectionReason> for DependencySelectionReason {
fn from(reason: CurseForgeDependencySelectionReason) -> Self {
match reason {
CurseForgeDependencySelectionReason::NativeStrictMatch => {
Self::NativeStrictMatch
}
CurseForgeDependencySelectionReason::Sha1VerifiedModrinthFallback => {
Self::Sha1VerifiedModrinthFallback
}
}
}
}
fn select_best_dependency_file(
files: Vec<CurseForgeFile>,
game_version: Option<&str>,
mod_loader_type: Option<u32>,
) -> Option<CurseForgeFile> {
files
.into_iter()
.filter(|file| {
file.is_available
&& file_matches_dependency_target(
file,
game_version,
mod_loader_type,
)
})
.max_by(|left, right| {
dependency_release_rank(left)
.cmp(&dependency_release_rank(right))
.then_with(|| left.file_date.cmp(&right.file_date))
.then_with(|| left.id.cmp(&right.id))
})
}
fn dependency_release_rank(file: &CurseForgeFile) -> u8 {
match file.release_type {
1 => 3,
2 => 2,
3 => 1,
_ => 0,
}
}
/// Checks unfiltered fallback candidates against the instance target.
fn file_matches_dependency_target(
file: &CurseForgeFile,
game_version: Option<&str>,
mod_loader_type: Option<u32>,
) -> bool {
let game_version_matches = game_version.is_none_or(|target| {
let game_versions_match =
file.game_versions.iter().any(|version| version == target);
let sortable_game_versions_match =
file.sortable_game_versions.iter().any(|version| {
version.game_version.as_deref() == Some(target)
|| version.game_version_name == target
});
match (
file.game_versions.is_empty(),
file.sortable_game_versions.is_empty(),
) {
(true, true) => false,
(false, true) => game_versions_match,
(true, false) => sortable_game_versions_match,
(false, false) => {
game_versions_match && sortable_game_versions_match
}
}
});
let mod_loader_matches = mod_loader_type.is_none_or(|target| {
file.game_versions.iter().any(|version| {
version.eq_ignore_ascii_case(mod_loader_to_slug(target))
})
});
game_version_matches && mod_loader_matches
}
fn managed_project_type(value: &str) -> crate::Result<ProjectType> {
match value {
"mod" => Ok(ProjectType::Mod),
"datapack" => Ok(ProjectType::DataPack),
"resourcepack" => Ok(ProjectType::ResourcePack),
"shader" | "shaderpack" => Ok(ProjectType::ShaderPack),
other => Err(ErrorKind::InputError(format!(
"CurseForge project type {other} uses its dedicated installer"
))
.into()),
}
}
fn content_target_folder(
project_type: ProjectType,
world_name: Option<&str>,
) -> crate::Result<String> {
if project_type == ProjectType::DataPack
&& let Some(world_name) = world_name
{
validate_file_name(world_name)?;
Ok(format!("saves/{world_name}/datapacks"))
} else {
Ok(project_type.get_folder().to_string())
}
}
fn manual_download_from_file(
project_id: u32,
file_id: u32,
file: &CurseForgeFile,
project: &CurseForgeProject,
project_type: &str,
target_folder: String,
ownership_kind: crate::state::instances::ContentOwnershipKind,
operation_kind: crate::state::instances::ManualDownloadOperationKind,
) -> CurseForgeManualDownload {
CurseForgeManualDownload {
project_id,
file_id,
file_name: file.file_name.clone(),
ownership_kind,
operation_kind,
website_url: curseforge_file_page_url(
project.links.website_url.as_deref(),
file_id,
),
project_type: project_type.to_string(),
project_slug: project.slug.clone(),
target_folder,
hashes: file.hashes.clone(),
file_length: file.file_length,
file_fingerprint: file.file_fingerprint,
}
}
async fn persist_manual_modpack_archive(
instance_id: &str,
download: &CurseForgeManualDownload,
) -> crate::Result<()> {
let state = State::get().await?;
let _instance_lock = state.lock_instance_content(instance_id).await;
let content_set = crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?;
let now = chrono::Utc::now();
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
crate::state::instances::adapters::sqlite::content_rows::upsert_pending_manual_download_in_transaction(
&crate::state::instances::PendingManualDownload {
id: format!("manual-download:{}", uuid::Uuid::new_v4()),
instance_id: instance_id.to_string(),
pack_member_id: None,
content_entry_id: None,
operation_kind: download.operation_kind,
operation_target_id: Some(download.file_id.to_string()),
project_type: ProjectType::Mod,
provider: ContentProvider::CurseForge,
provider_project_id: download.project_id.to_string(),
provider_release_id: download.file_id.to_string(),
file_name: download.file_name.clone(),
website_url: download.website_url.clone(),
target_relative_path: download.file_name.clone(),
expected_sha1: download
.hashes
.iter()
.find(|hash| hash.algo == 1)
.map(|hash| hash.value.clone()),
expected_size: (download.file_length > 0)
.then_some(download.file_length),
expected_fingerprint: (download.file_fingerprint > 0)
.then_some(download.file_fingerprint),
state: crate::state::instances::ManualDownloadState::Waiting,
context: serde_json::to_value(download)?,
created_at: now,
modified_at: now,
},
&mut tx,
)
.await?;
crate::state::instances::adapters::sqlite::content_rows::bump_content_set_revision_in_transaction(
&content_set.id,
&mut tx,
)
.await?;
tx.commit().await?;
Ok(())
}
async fn persist_manual_world_archive(
instance_id: &str,
download: &CurseForgeManualDownload,
) -> crate::Result<()> {
validate_world_archive_name(&download.file_name)?;
let state = State::get().await?;
let _instance_lock = state.lock_instance_content(instance_id).await;
let now = chrono::Utc::now();
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
crate::state::instances::adapters::sqlite::content_rows::upsert_pending_manual_download_in_transaction(
&crate::state::instances::PendingManualDownload {
id: format!("manual-download:{}", uuid::Uuid::new_v4()),
instance_id: instance_id.to_string(),
pack_member_id: None,
content_entry_id: None,
operation_kind: download.operation_kind,
operation_target_id: None,
project_type: ProjectType::WorldSave,
provider: ContentProvider::CurseForge,
provider_project_id: download.project_id.to_string(),
provider_release_id: download.file_id.to_string(),
file_name: download.file_name.clone(),
website_url: download.website_url.clone(),
target_relative_path: format!("saves/{}", download.file_name),
expected_sha1: download
.hashes
.iter()
.find(|hash| hash.algo == 1)
.map(|hash| hash.value.clone()),
expected_size: (download.file_length > 0)
.then_some(download.file_length),
expected_fingerprint: (download.file_fingerprint > 0)
.then_some(download.file_fingerprint),
state: crate::state::instances::ManualDownloadState::Waiting,
context: serde_json::to_value(download)?,
created_at: now,
modified_at: now,
},
&mut tx,
)
.await?;
tx.commit().await?;
Ok(())
}
async fn complete_manual_world_download(
instance_id: &str,
download: &CurseForgeManualDownload,
) -> crate::Result<()> {
let state = State::get().await?;
let _instance_lock = state.lock_instance_content(instance_id).await;
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
crate::state::instances::adapters::sqlite::content_rows::complete_pending_manual_download(
instance_id,
&download.project_id.to_string(),
&download.file_id.to_string(),
None,
&mut tx,
)
.await?;
tx.commit().await?;
Ok(())
}
async fn persist_manual_download(
instance_id: &str,
download: &CurseForgeManualDownload,
) -> crate::Result<()> {
if download.project_type == "modpack" {
return persist_manual_modpack_archive(instance_id, download).await;
}
if download.project_type == "world" {
return persist_manual_world_archive(instance_id, download).await;
}
let project_type = managed_project_type(&download.project_type)?;
let state = State::get().await?;
let _instance_lock = state.lock_instance_content(instance_id).await;
let content_set = crate::state::instances::adapters::sqlite::content_rows::get_applied_content_set(
instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
ErrorKind::InputError(
"Instance has no applied content set".to_string(),
)
})?;
let target_relative_path = format!(
"{}/{}",
download.target_folder.trim_end_matches('/'),
download.file_name
);
let member_key = format!(
"curseforge:{}:{}",
download.project_id,
project_type.get_name()
);
let mut tx = state.pool.begin_with("BEGIN IMMEDIATE").await?;
let now = chrono::Utc::now();
let pack_member_id = if download.ownership_kind
== crate::state::instances::ContentOwnershipKind::PackManaged
{
let existing_id = sqlx::query_scalar::<_, String>(
"SELECT id FROM instance_pack_members
WHERE content_set_id = ? AND member_key = ?",
)
.bind(&content_set.id)
.bind(&member_key)
.fetch_optional(&mut *tx)
.await?;
if download.operation_kind
== crate::state::instances::ManualDownloadOperationKind::PackUpdate
{
existing_id
} else {
let member_id = existing_id.unwrap_or_else(|| {
format!("pack-member:{}", uuid::Uuid::new_v4())
});
crate::state::instances::adapters::sqlite::content_rows::upsert_pack_member_in_transaction(
&crate::state::instances::PackMember {
id: member_id.clone(),
content_set_id: content_set.id.clone(),
content_entry_id: None,
member_key,
project_type,
expected_relative_path: target_relative_path.clone(),
provider: Some(ContentProvider::CurseForge),
provider_project_id: Some(download.project_id.to_string()),
provider_release_id: Some(download.file_id.to_string()),
required: true,
expected_sha1: download
.hashes
.iter()
.find(|hash| hash.algo == 1)
.map(|hash| hash.value.clone()),
expected_size: (download.file_length > 0)
.then_some(download.file_length),
expected_fingerprint: (download.file_fingerprint > 0)
.then_some(download.file_fingerprint),
materialization_state: crate::state::instances::PackMemberMaterializationState::PendingManual,
override_kind: crate::state::instances::PackMemberOverrideKind::None,
reconciled: true,
created_at: now,
modified_at: now,
},
&mut tx,
)
.await?;
Some(member_id)
}
} else {
None
};
crate::state::instances::adapters::sqlite::content_rows::upsert_pending_manual_download_in_transaction(
&crate::state::instances::PendingManualDownload {
id: format!("manual-download:{}", uuid::Uuid::new_v4()),
instance_id: instance_id.to_string(),
pack_member_id,
content_entry_id: None,
operation_kind: download.operation_kind,
operation_target_id: None,
project_type,
provider: ContentProvider::CurseForge,
provider_project_id: download.project_id.to_string(),
provider_release_id: download.file_id.to_string(),
file_name: download.file_name.clone(),
website_url: download.website_url.clone(),
target_relative_path,
expected_sha1: download
.hashes
.iter()
.find(|hash| hash.algo == 1)
.map(|hash| hash.value.clone()),
expected_size: (download.file_length > 0)
.then_some(download.file_length),
expected_fingerprint: (download.file_fingerprint > 0)
.then_some(download.file_fingerprint),
state: crate::state::instances::ManualDownloadState::Waiting,
context: serde_json::to_value(download)?,
created_at: now,
modified_at: now,
},
&mut tx,
)
.await?;
crate::state::instances::adapters::sqlite::content_rows::bump_content_set_revision_in_transaction(
&content_set.id,
&mut tx,
)
.await?;
tx.commit().await?;
Ok(())
}
fn validate_file_name(file_name: &str) -> crate::Result<()> {
let path = Path::new(file_name);
if file_name.is_empty()
|| path.components().count() != 1
|| !matches!(path.components().next(), Some(Component::Normal(_)))
{
return Err(ErrorKind::InputError(
"CurseForge returned an invalid file name".to_string(),
)
.into());
}
Ok(())
}
fn validate_world_archive_name(file_name: &str) -> crate::Result<()> {
validate_file_name(file_name)?;
if Path::new(file_name)
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
{
return Ok(());
}
Err(ErrorKind::InputError(
"CurseForge world downloads must be ZIP archives".to_string(),
)
.into())
}
fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
async fn report_modpack_progress(
loading_bar: Option<&crate::event::LoadingBarId>,
reporter: Option<&InstallProgressReporter>,
details: InstallPhaseDetails,
files_done: &AtomicU64,
bytes_done: &AtomicU64,
active_downloads: &AtomicU64,
total_files: u64,
total_bytes: u64,
file_bytes: u64,
event: InstallJobEventKind,
) -> crate::Result<()> {
let current_files = files_done.fetch_add(1, Ordering::Relaxed) + 1;
let current_bytes =
bytes_done.fetch_add(file_bytes, Ordering::Relaxed) + file_bytes;
let active = active_downloads.load(Ordering::Relaxed);
let message = if total_bytes > 0 {
format!(
"{current_files}/{total_files} files · {} / {} · {active} downloading in parallel",
format_bytes(current_bytes.min(total_bytes)),
format_bytes(total_bytes)
)
} else {
format!(
"{current_files}/{total_files} files · {active} downloading in parallel"
)
};
if let Some(loading_bar) = loading_bar {
emit_loading(loading_bar, 1.0, Some(&message))?;
}
if let Some(reporter) = reporter {
reporter
.update_with_events(
InstallPhaseId::DownloadingContent,
Some(InstallProgress {
current: current_files,
total: total_files,
secondary: Some(InstallProgressSecondary {
current: current_bytes.min(total_bytes),
total: total_bytes,
}),
}),
details,
vec![event],
)
.await?;
}
Ok(())
}
fn is_forge_cdn_url(url: &reqwest::Url) -> bool {
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
host == "forgecdn.net" || host.ends_with(".forgecdn.net")
}
fn curseforge_file_page_url(
website_url: Option<&str>,
file_id: u32,
) -> Option<String> {
let website_url = website_url?;
let Ok(mut url) = reqwest::Url::parse(website_url) else {
return Some(website_url.to_owned());
};
if !matches!(
url.host_str(),
Some("curseforge.com" | "www.curseforge.com" | "legacy.curseforge.com")
) {
return Some(website_url.to_owned());
}
let mut path = url.path().trim_end_matches('/').to_string();
for marker in ["/files/", "/download/"] {
if let Some(index) = path.rfind(marker) {
path.truncate(index);
break;
}
}
url.set_path(&format!("{path}/download/{file_id}"));
url.set_query(None);
url.set_fragment(None);
Some(url.into())
}
fn validate_cdn_url(url: &reqwest::Url) -> crate::Result<()> {
#[cfg(debug_assertions)]
if url.scheme() == "http"
&& matches!(
url.host_str()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str(),
"127.0.0.1" | "localhost"
)
{
return Ok(());
}
if url.scheme() != "https" || !is_forge_cdn_url(url) {
return Err(ErrorKind::InputError(
"CurseForge returned a download URL outside its CDN".to_string(),
)
.into());
}
Ok(())
}
fn curseforge_content_validation(file_name: &str) -> ContentValidation {
match Path::new(file_name)
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("jar" | "zip" | "mrpack") => ContentValidation::Jar,
_ => ContentValidation::None,
}
}
fn curseforge_integrity(
file: &CurseForgeFile,
validation: ContentValidation,
) -> Integrity {
Integrity {
size: Some(file.file_length),
sha1: file
.hashes
.iter()
.find(|hash| hash.algo == 1)
.map(|hash| hash.value.clone()),
md5: file
.hashes
.iter()
.find(|hash| hash.algo == 2)
.map(|hash| hash.value.clone()),
content: validation,
..Integrity::default()
}
}
const fn curseforge_modpack_h2_range_concurrency() -> Option<usize> {
Some(16)
}
fn curseforge_candidate_urls(url: &str) -> crate::Result<Vec<String>> {
let parsed = reqwest::Url::parse(url).map_err(|_| {
ErrorKind::InputError(
"CurseForge returned an invalid download URL".to_string(),
)
})?;
validate_cdn_url(&parsed)?;
if !is_forge_cdn_url(&parsed) {
return Ok(Vec::new());
}
let original_host = parsed.host_str().unwrap_or_default();
let mut candidates = Vec::new();
for host in [
"edge.forgecdn.net",
"media.forgecdn.net",
"mediafilez.forgecdn.net",
] {
if host == original_host {
continue;
}
let mut candidate = parsed.clone();
candidate.set_host(Some(host)).map_err(|_| {
ErrorKind::InputError(
"CurseForge returned an invalid CDN URL".to_string(),
)
})?;
candidates.push(candidate.to_string());
}
Ok(candidates)
}
async fn download_curseforge_path(
url: &str,
file: &CurseForgeFile,
destination: &Path,
validation: ContentValidation,
progress: Option<&mut FetchProgressFn<'_>>,
tracking: Option<(&InstallProgressReporter, &str)>,
h2_range_concurrency: Option<usize>,
allow_http1_segmented_download: bool,
) -> crate::Result<crate::util::fetch::DownloadResult> {
let state = State::get().await?;
let mut request = DownloadRequest::new(url, ResourceClass::CurseForge)
.with_candidate_urls(curseforge_candidate_urls(url)?)
.with_integrity(curseforge_integrity(file, validation))
.with_http1_segmented_download(allow_http1_segmented_download);
if let Some(concurrency) = h2_range_concurrency {
request = request.with_h2_range_concurrency(concurrency);
}
let parsed = reqwest::Url::parse(url)?;
if is_forge_cdn_url(&parsed)
&& let Some(key) = api_key()
{
request = request.with_header("x-api-key", key);
}
if let Some((reporter, item_id)) = tracking {
request = request.with_install_tracking(
reporter.clone(),
item_id,
file.file_name.clone(),
);
}
download_to_path(
request,
destination,
&state.download_semaphore,
&state.pool,
progress,
)
.await
}
async fn download_curseforge_archive(
project_id: u32,
file_id: u32,
file: &CurseForgeFile,
url: &str,
progress: Option<&mut FetchProgressFn<'_>>,
reporter: Option<&InstallProgressReporter>,
) -> crate::Result<crate::util::fetch::DownloadResult> {
validate_file_name(&file.file_name)?;
let state = State::get().await?;
let path = state
.directories
.caches_dir()
.join("curseforge")
.join("modpacks")
.join(project_id.to_string())
.join(file_id.to_string())
.join(&file.file_name);
let tracking_item_id = path.display().to_string();
download_curseforge_path(
url,
file,
&path,
ContentValidation::Jar,
progress,
reporter.map(|reporter| (reporter, tracking_item_id.as_str())),
curseforge_modpack_h2_range_concurrency(),
true,
)
.await
}
async fn download_installed_file(
instance_id: &str,
url: &str,
file: &CurseForgeFile,
project_type: ProjectType,
world_name: Option<&str>,
project_id: u32,
file_id: u32,
project_slug: &str,
ownership_kind: crate::state::instances::ContentOwnershipKind,
download_metrics: Option<&CurseForgeDownloadMetrics>,
) -> crate::Result<String> {
if file.mod_id != project_id || file.id != file_id {
return Err(ErrorKind::InputError(
"CurseForge returned metadata for a different project or file"
.to_string(),
)
.into());
}
let state = State::get().await?;
validate_file_name(&file.file_name)?;
let folder = content_target_folder(project_type, world_name)?;
let localized_candidate = if project_type == ProjectType::Mod {
None
} else {
chinese_file_title_for_curseforge_slug(project_slug)
.and_then(|title| {
localized_content_file_name(&file.file_name, &title)
})
.map(|file_name| format!("{folder}/{file_name}"))
};
let relative_path = crate::state::resolve_content_install_relative_path(
instance_id,
format!("{folder}/{}", file.file_name),
localized_candidate,
&state.pool,
)
.await?;
let full_path = crate::api::instance::get_full_path(instance_id)
.await?
.join(&relative_path);
let mut download_path = full_path.as_os_str().to_os_string();
download_path.push(".installing.download");
let download_path = Path::new(&download_path);
let result = download_curseforge_path(
url,
file,
download_path,
curseforge_content_validation(&file.file_name),
None,
download_metrics
.and_then(|metrics| metrics.reporter.as_ref())
.map(|reporter| (reporter, relative_path.as_str())),
None,
false,
)
.await?;
if let Some(download_metrics) = download_metrics {
download_metrics.record(&result);
}
// Transfers remain concurrent; only publishing into an instance is serialized.
let _instance_lock = state.lock_instance_content(instance_id).await;
let previous_path =
crate::state::materialize_project_download(download_path, &full_path)
.await?;
crate::util::io::remove_file(download_path).await?;
let record_result = record_installed_curseforge_file(
instance_id,
&relative_path,
&full_path,
file,
project_type,
ownership_kind,
&state,
)
.await;
match record_result {
Ok(()) => {
crate::state::finalize_project_materialization(
previous_path.as_deref(),
)
.await?;
}
Err(error) => {
crate::state::restore_project_materialization(
&full_path,
previous_path.as_deref(),
)
.await?;
return Err(error);
}
}
Ok(relative_path)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CurseForgePendingCompletionProof {
None,
AuthoritativeSha1,
AuthoritativeFingerprint,
}
struct VerifiedInstalledCurseForgeFile {
size: u64,
sha1: String,
pending_completion: CurseForgePendingCompletionProof,
}
async fn verify_installed_curseforge_file(
path: &Path,
file: &CurseForgeFile,
) -> crate::Result<VerifiedInstalledCurseForgeFile> {
if let Some(expected_sha1) = file
.hashes
.iter()
.find(|hash| hash.algo == 1 && !hash.value.trim().is_empty())
.map(|hash| hash.value.as_str())
{
let (size, sha1) = sha1_file_async(path).await?;
if !sha1.eq_ignore_ascii_case(expected_sha1) {
return Err(
ErrorKind::HashError(expected_sha1.to_string(), sha1).into()
);
}
return Ok(VerifiedInstalledCurseForgeFile {
size,
sha1,
pending_completion:
CurseForgePendingCompletionProof::AuthoritativeSha1,
});
}
if file.file_fingerprint != 0 {
let bytes = tokio::fs::read(path).await?;
if compute_fingerprint(&bytes) as u64 != file.file_fingerprint {
return Err(ErrorKind::InputError(
"The downloaded file does not match the required CurseForge fingerprint"
.to_string(),
)
.into());
}
return Ok(VerifiedInstalledCurseForgeFile {
size: bytes.len() as u64,
sha1: sha1_smol::Sha1::from(&bytes).hexdigest(),
pending_completion:
CurseForgePendingCompletionProof::AuthoritativeFingerprint,
});
}
let (size, sha1) = sha1_file_async(path).await?;
Ok(VerifiedInstalledCurseForgeFile {
size,
sha1,
pending_completion: CurseForgePendingCompletionProof::None,
})
}
async fn record_installed_curseforge_file(
instance_id: &str,
relative_path: &str,
full_path: &Path,
file: &CurseForgeFile,
project_type: ProjectType,
ownership_kind: crate::state::instances::ContentOwnershipKind,
state: &State,
) -> crate::Result<()> {
let verified = verify_installed_curseforge_file(full_path, file).await?;
match verified.pending_completion {
CurseForgePendingCompletionProof::None => {
let provider_ref = ContentProviderRef::CurseForge {
project_id: CurseForgeProjectId::new(file.mod_id)?,
file_id: Some(CurseForgeFileId::new(file.id)?),
};
crate::state::record_project_file_atomic(
instance_id,
relative_path,
&verified.sha1,
verified.size,
project_type,
ContentSourceKind::CurseForge,
ownership_kind,
Some(&provider_ref),
true,
None,
state,
)
.await
}
CurseForgePendingCompletionProof::AuthoritativeSha1
| CurseForgePendingCompletionProof::AuthoritativeFingerprint => {
crate::state::record_verified_curseforge_project_file_atomic(
instance_id,
relative_path,
&verified.sha1,
verified.size,
project_type,
ContentSourceKind::CurseForge,
ownership_kind,
CurseForgeProjectId::new(file.mod_id)?,
CurseForgeFileId::new(file.id)?,
true,
state,
)
.await
}
}
}
pub fn compute_fingerprint(data: &[u8]) -> u32 {
let normalized = data
.iter()
.copied()
.filter(|byte| !matches!(byte, 9 | 10 | 13 | 32))
.collect::<Vec<_>>();
murmur2(&normalized, 1)
}
impl From<CurseForgeProject> for UnifiedSearchHit {
fn from(project: CurseForgeProject) -> Self {
let mut versions = Vec::new();
let mut seen_versions = HashSet::new();
let mut loaders = HashSet::new();
for index in &project.latest_files_indexes {
if seen_versions.insert(index.game_version.clone()) {
versions.push(index.game_version.clone());
}
if let Some(mod_loader) = index.mod_loader {
let loader_slug = mod_loader_to_slug(mod_loader);
loaders.insert(loader_slug);
}
}
let project_type = project_type_for_class(project.class_id);
let mut categories: Vec<String> = project
.categories
.iter()
.map(|category| category.slug.clone())
.collect();
categories.extend(loaders.iter().map(|s| s.to_string()));
Self {
provider: ContentProvider::CurseForge,
project_id: project.id.to_string(),
slug: Some(project.slug),
author: project
.authors
.first()
.map(|author| author.name.clone())
.unwrap_or_default(),
author_url: project
.authors
.first()
.map(|author| author.url.clone()),
title: project.name,
description: project.summary,
project_type: project_type.to_string(),
categories,
versions,
downloads: project.download_count,
icon_url: project.logo.map(|logo| logo.thumbnail_url),
date_created: project.date_created,
date_modified: project.date_modified,
latest_version: project
.latest_files
.first()
.map(|file| file.id.to_string()),
gallery: project
.screenshots
.into_iter()
.map(|screenshot| screenshot.url)
.collect(),
website_url: project.links.website_url,
source_url: project.links.source_url,
allow_mod_distribution: project.allow_mod_distribution,
}
}
}
fn mod_loader_to_slug(mod_loader_type: u32) -> &'static str {
match mod_loader_type {
1 => "forge",
4 => "fabric",
5 => "quilt",
6 => "neoforge",
_ => "unknown",
}
}
fn project_type_for_class(class_id: Option<u32>) -> &'static str {
match class_id {
Some(5) => "plugin",
Some(6) => "mod",
Some(12) => "resourcepack",
Some(17) => "world",
Some(6945) => "datapack",
Some(4471) => "modpack",
Some(6552) => "shader",
_ => "mod",
}
}
fn recognized_project_type(class_id: Option<u32>) -> Option<ProjectType> {
match class_id {
Some(6) => Some(ProjectType::Mod),
Some(12) => Some(ProjectType::ResourcePack),
Some(6552) => Some(ProjectType::ShaderPack),
Some(6945) => Some(ProjectType::DataPack),
Some(17) => Some(ProjectType::WorldSave),
_ => None,
}
}
fn filter_categories(
categories: Vec<CurseForgeCategory>,
class_id: Option<u32>,
) -> Vec<CurseForgeCategory> {
let Some(class_id) = class_id else {
return categories;
};
categories
.into_iter()
.filter(|category| {
category.id == class_id || category.class_id == Some(class_id)
})
.collect()
}
fn push_query<T: ToString>(
query: &mut Vec<(String, String)>,
name: &str,
value: Option<T>,
) {
if let Some(value) = value {
query.push((name.to_string(), value.to_string()));
}
}
fn api_key() -> Option<String> {
std::env::var("AXOLOTL_CURSEFORGE_API_KEY")
.ok()
.or_else(|| option_env!("CURSEFORGE_API_KEY").map(str::to_string))
.map(|key| key.trim().to_string())
.filter(|key| !key.is_empty())
}
fn api_base_url() -> String {
#[cfg(debug_assertions)]
if let Ok(value) = std::env::var("AXOLOTL_CURSEFORGE_API_BASE_URL")
&& value.starts_with("http://127.0.0.1:")
{
return value.trim_end_matches('/').to_string();
}
API_BASE_URL.to_string()
}
fn request_client(
_url: &str,
use_system_proxy: bool,
) -> &'static reqwest::Client {
#[cfg(debug_assertions)]
if _url.starts_with("http://127.0.0.1:") {
return &LOCAL_CLIENT;
}
if use_system_proxy {
&PROXY_CLIENT
} else {
&CLIENT
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MirrorPolicy {
MirrorFirst,
OfficialOnly,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RequestRouteSource {
Official,
Mirror,
}
struct RequestRoute {
url: String,
use_api_key: bool,
use_system_proxy: bool,
source: RequestRouteSource,
}
#[cfg(test)]
fn request_routes(
path: &str,
mirror_policy: MirrorPolicy,
) -> Vec<RequestRoute> {
let mode = match mirror_policy {
MirrorPolicy::MirrorFirst => DownloadSourceMode::MirrorPreferred,
MirrorPolicy::OfficialOnly => DownloadSourceMode::OfficialOnly,
};
request_routes_with_mode(path, mode)
}
fn request_routes_with_mode(
path: &str,
mode: DownloadSourceMode,
) -> Vec<RequestRoute> {
let base_url = api_base_url();
if base_url != API_BASE_URL {
return vec![RequestRoute {
url: format!("{base_url}{path}"),
use_api_key: true,
use_system_proxy: false,
source: RequestRouteSource::Official,
}];
}
resolve_download_routes_for(
&format!("{API_BASE_URL}{path}"),
ResourceClass::CurseForge,
mode,
)
.into_iter()
.map(|route| RequestRoute {
use_api_key: route.allow_sensitive_headers,
use_system_proxy: route.proxy == ProxyPolicy::System,
source: match route.source {
DownloadRouteSource::Bmclapi
| DownloadRouteSource::Mcim
| DownloadRouteSource::Tianpao => RequestRouteSource::Mirror,
DownloadRouteSource::Official | DownloadRouteSource::Alternate => {
RequestRouteSource::Official
}
},
url: route.url,
})
.collect()
}
async fn request_json<T: DeserializeOwned>(
method: Method,
path: &str,
query: Vec<(String, String)>,
body: Option<Value>,
mirror_policy: MirrorPolicy,
) -> crate::Result<T> {
let key = api_key();
let state = State::get().await?;
let source_mode = if method != Method::GET
|| mirror_policy == MirrorPolicy::OfficialOnly
{
DownloadSourceMode::OfficialOnly
} else {
state.curseforge_source()
};
let routes = request_routes_with_mode(path, source_mode);
let mut last_error = None;
for (route_index, route) in routes.iter().enumerate() {
let started = Instant::now();
tracing::info!(
source = ?route.source,
method = %method,
url = %route.url,
route = route_index + 1,
use_system_proxy = route.use_system_proxy,
"Attempting CurseForge API request"
);
let permit = state.api_semaphore.0.acquire().await?;
let mut request = request_client(&route.url, route.use_system_proxy)
.request(method.clone(), &route.url)
.header("accept", "application/json")
.query(&query);
if route.use_api_key {
let Some(key) = key.as_ref() else {
drop(permit);
let error: crate::Error = ErrorKind::InputError(
"CurseForge integration is waiting for an API key"
.to_string(),
)
.into();
if route_index + 1 < routes.len() {
last_error = Some(error);
continue;
}
return Err(error);
};
request = request.header("x-api-key", key);
}
if let Some(body) = &body {
request = request.json(body);
}
let response = match request.send().await {
Ok(response) => response,
Err(error) if route_index + 1 < routes.len() => {
drop(permit);
tracing::warn!(
url = %route.url,
route = route_index + 1,
%error,
"CurseForge request failed, retrying with another route"
);
last_error = Some(error.into());
continue;
}
Err(error) => return Err(error.into()),
};
drop(permit);
let status = response.status();
let retry_after = response
.headers()
.get("retry-after")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.map(|seconds| Duration::from_secs(seconds.min(30)));
let bytes = response.bytes().await?;
tracing::info!(
source = ?route.source,
method = %method,
url = %route.url,
route = route_index + 1,
status = status.as_u16(),
response_bytes = bytes.len(),
elapsed_ms = started.elapsed().as_millis(),
"Completed CurseForge API request"
);
if status.is_success() {
match serde_json::from_slice(&bytes) {
Ok(value) => {
UNAUTHORIZED.store(false, Ordering::Relaxed);
return Ok(value);
}
Err(error)
if route.source == RequestRouteSource::Mirror
&& route_index + 1 < routes.len() =>
{
tracing::warn!(
url = %route.url,
route = route_index + 1,
%error,
"CurseForge mirror returned incompatible response data; falling back to official source"
);
last_error = Some(error.into());
continue;
}
Err(error) => return Err(error.into()),
}
}
if status == StatusCode::UNAUTHORIZED {
UNAUTHORIZED.store(true, Ordering::Relaxed);
}
let message = response_error_message(status, &bytes);
let route_error = ErrorKind::OtherError(format!(
"CurseForge request to {} failed with HTTP {}: {message}",
route.url,
status.as_u16()
));
if should_try_next_route(route, status, route_index + 1 < routes.len())
{
if let Some(delay) = retry_after {
tokio::time::sleep(delay).await;
}
tracing::warn!(
url = %route.url,
route = route_index + 1,
status = status.as_u16(),
"CurseForge route rejected the request, trying another route"
);
last_error = Some(route_error.into());
continue;
}
return Err(route_error.into());
}
Err(last_error.unwrap_or_else(|| {
ErrorKind::OtherError("CurseForge request exhausted routes".to_string())
.into()
}))
}
fn should_try_next_route(
route: &RequestRoute,
status: StatusCode,
has_next_route: bool,
) -> bool {
has_next_route
&& (route.source == RequestRouteSource::Mirror
|| status == StatusCode::TOO_MANY_REQUESTS
|| status == StatusCode::FORBIDDEN
|| status.is_server_error())
}
fn response_error_message(status: StatusCode, bytes: &[u8]) -> String {
serde_json::from_slice::<Value>(bytes)
.ok()
.and_then(|value| {
value
.get("description")
.or_else(|| value.get("message"))
.or_else(|| value.get("error"))
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| {
status
.canonical_reason()
.unwrap_or("request failed")
.to_string()
})
}
fn murmur2(data: &[u8], seed: u32) -> u32 {
const M: u32 = 0x5bd1e995;
const R: u32 = 24;
let mut hash = seed ^ data.len() as u32;
let mut chunks = data.chunks_exact(4);
for chunk in &mut chunks {
let mut value =
u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
value = value.wrapping_mul(M);
value ^= value >> R;
value = value.wrapping_mul(M);
hash = hash.wrapping_mul(M);
hash ^= value;
}
match chunks.remainder() {
[a, b, c] => {
hash ^= (*c as u32) << 16;
hash ^= (*b as u32) << 8;
hash ^= *a as u32;
hash = hash.wrapping_mul(M);
}
[a, b] => {
hash ^= (*b as u32) << 8;
hash ^= *a as u32;
hash = hash.wrapping_mul(M);
}
[a] => {
hash ^= *a as u32;
hash = hash.wrapping_mul(M);
}
[] => {}
_ => unreachable!(),
}
hash ^= hash >> 13;
hash = hash.wrapping_mul(M);
hash ^= hash >> 15;
hash
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modpack_metadata_ids_are_deduplicated_before_batching() {
let mut ids = (1..=120).collect::<Vec<_>>();
ids.extend([1, 50, 120]);
let chunks = unique_metadata_chunks(ids);
assert_eq!(
chunks.iter().map(Vec::len).collect::<Vec<_>>(),
vec![50, 50, 20]
);
assert_eq!(
chunks
.iter()
.flatten()
.copied()
.collect::<HashSet<_>>()
.len(),
120
);
}
#[test]
fn modpack_archives_use_sixteen_h2_range_streams() {
assert_eq!(curseforge_modpack_h2_range_concurrency(), Some(16));
}
#[test]
fn dependency_fallback_requires_the_target_game_and_loader() {
let mut target_file =
stage8_curseforge_file(1, 101, "target.jar", 0, Vec::new(), 0);
target_file.game_versions =
vec!["1.21.1".to_string(), "NeoForge".to_string()];
let mut newer_game_file =
stage8_curseforge_file(1, 102, "newer-game.jar", 0, Vec::new(), 0);
newer_game_file.game_versions =
vec!["26.1.2".to_string(), "NeoForge".to_string()];
let mut wrong_loader_file = stage8_curseforge_file(
1,
103,
"wrong-loader.jar",
0,
Vec::new(),
0,
);
wrong_loader_file.game_versions =
vec!["1.21.1".to_string(), "Fabric".to_string()];
assert!(file_matches_dependency_target(
&target_file,
Some("1.21.1"),
Some(6),
));
assert!(!file_matches_dependency_target(
&newer_game_file,
Some("1.21.1"),
Some(6),
));
assert!(!file_matches_dependency_target(
&wrong_loader_file,
Some("1.21.1"),
Some(6),
));
}
#[test]
fn dependency_fallback_rejects_conflicting_game_version_metadata() {
let mut conflicting_file =
stage8_curseforge_file(1, 101, "conflicting.jar", 0, Vec::new(), 0);
conflicting_file.game_versions =
vec!["1.21.1".to_string(), "NeoForge".to_string()];
conflicting_file.sortable_game_versions =
vec![CurseForgeSortableGameVersion {
game_version_name: "26.1.2".to_string(),
game_version_padded: None,
game_version: Some("26.1.2".to_string()),
game_version_release_date: None,
game_version_type_id: None,
}];
assert!(!file_matches_dependency_target(
&conflicting_file,
Some("1.21.1"),
Some(6),
));
}
#[test]
fn dependency_selection_requires_a_minecraft_target() {
assert_eq!(dependency_target_game_version(None), None);
assert_eq!(dependency_target_game_version(Some("")), None);
assert_eq!(dependency_target_game_version(Some(" ")), None);
assert_eq!(
dependency_target_game_version(Some("1.21.1")),
Some("1.21.1"),
);
}
#[test]
fn dependency_selection_prefers_a_stable_target_match_over_newer_beta() {
let mut stable =
stage8_curseforge_file(1, 101, "stable.jar", 0, Vec::new(), 0);
stable.game_versions =
vec!["1.21.1".to_string(), "NeoForge".to_string()];
stable.release_type = 1;
stable.file_date = "2026-08-01T00:00:00Z".to_string();
let mut newer_beta =
stage8_curseforge_file(1, 102, "newer-beta.jar", 0, Vec::new(), 0);
newer_beta.game_versions =
vec!["1.21.1".to_string(), "NeoForge".to_string()];
newer_beta.release_type = 2;
newer_beta.file_date = "2026-08-02T00:00:00Z".to_string();
let selected = select_best_dependency_file(
vec![newer_beta, stable],
Some("1.21.1"),
Some(6),
)
.unwrap();
assert_eq!(selected.id, 101);
}
#[test]
fn waystones_fixture_selects_neoforge_balm_for_minecraft_1_21_1() {
let mut balm_1_21_1 = stage8_curseforge_file(
531_761,
21_065,
"balm-neoforge-1.21.1-21.0.65.jar",
0,
Vec::new(),
0,
);
balm_1_21_1.game_versions =
vec!["1.21.1".to_string(), "NeoForge".to_string()];
balm_1_21_1.release_type = 1;
let mut balm_26_1_2 = stage8_curseforge_file(
531_761,
26_102,
"balm-neoforge-26.1.2-26.1.2.jar",
0,
Vec::new(),
0,
);
balm_26_1_2.game_versions =
vec!["26.1.2".to_string(), "NeoForge".to_string()];
balm_26_1_2.release_type = 1;
let selected = select_best_dependency_file(
vec![balm_26_1_2, balm_1_21_1],
Some("1.21.1"),
Some(6),
)
.unwrap();
assert_eq!(selected.file_name, "balm-neoforge-1.21.1-21.0.65.jar");
}
fn skipped_curseforge_manual_item(
id: &str,
project_id: &str,
version_id: &str,
) -> crate::install::model::DownloadItemSnapshot {
crate::install::model::DownloadItemSnapshot {
id: id.to_string(),
name: id.to_string(),
project_id: Some(project_id.to_string()),
version_id: Some(version_id.to_string()),
status: crate::install::model::DownloadItemStatus::Skipped,
bytes_downloaded: 0,
bytes_total: Some(42),
attempt: Some(1),
max_attempts: Some(1),
error: Some("CurseForge requires manual download".to_string()),
manual_url: Some("https://www.curseforge.com/download".to_string()),
request_url: None,
source: None,
}
}
fn waiting_manual_job_record(
instance_id: &str,
request: crate::install::model::InstallRequest,
) -> crate::install::store::InstallJobRecord {
let mut state = crate::install::model::InstallJobState::new(request);
state.record_event(InstallJobEventKind::ContentFileSkipped {
path: "mods/one.jar".to_string(),
reason: "manual download required".to_string(),
project_id: Some("1".to_string()),
version_id: Some("10".to_string()),
manual_url: Some("https://www.curseforge.com/download".to_string()),
});
state.record_event(InstallJobEventKind::WaitingForUser {
reason:
crate::install::model::InstallPauseReason::MissingRequiredContent {
failed_files: 1,
paths: vec!["mods/one.jar".to_string()],
},
});
state.pause_reason = Some(
crate::install::model::InstallPauseReason::MissingRequiredContent {
failed_files: 1,
paths: vec!["mods/one.jar".to_string()],
},
);
let now = chrono::Utc::now();
crate::install::store::InstallJobRecord {
id: uuid::Uuid::new_v4(),
instance_id: Some(instance_id.to_string()),
kind: crate::install::model::InstallJobKind::InstallContent,
status: crate::install::model::InstallJobStatus::WaitingForUser,
state,
created: now,
modified: now,
finished: None,
dismissed: false,
}
}
fn apply_recovered_events(
items: &mut [crate::install::model::DownloadItemSnapshot],
events: &[InstallJobEventKind],
) {
for event in events {
let InstallJobEventKind::ContentFileRecovered { path, bytes } =
event
else {
continue;
};
let item = items.iter_mut().find(|item| item.id == *path).unwrap();
item.status = crate::install::model::DownloadItemStatus::Completed;
item.bytes_downloaded = *bytes;
}
}
fn reconcile_and_simulate_resume(
items: &mut [crate::install::model::DownloadItemSnapshot],
pending: &HashSet<(String, String)>,
materialized: &HashSet<(String, String)>,
resume_count: &mut usize,
) -> CurseForgeManualDownloadReconciliation {
let result = curseforge_manual_download_reconciliation(
items,
pending,
materialized,
);
apply_recovered_events(items, &result.recovered);
let latest = curseforge_manual_download_reconciliation(
items,
pending,
materialized,
);
if latest.should_resume() {
*resume_count += 1;
}
result
}
#[cfg(not(feature = "tauri"))]
async fn stage6_state() -> std::sync::Arc<State> {
crate::event::EventState::init().await.unwrap();
let state_root = tempfile::tempdir().unwrap().keep();
State::init_for_test(state_root.to_string_lossy().to_string())
.await
.unwrap()
}
#[cfg(not(feature = "tauri"))]
async fn create_stage6_instance(
label: &str,
) -> (std::sync::Arc<State>, String) {
let state = stage6_state().await;
let created = crate::api::instance::create(
format!("Stage 6 {label} {}", uuid::Uuid::new_v4()),
"1.20.1".to_string(),
ModLoader::Vanilla,
None,
None,
InstanceLink::Unmanaged,
None,
None,
)
.await
.unwrap();
(state, created.instance.id)
}
#[cfg(not(feature = "tauri"))]
fn stage6_manual_download(
project_id: u32,
file_id: u32,
file_name: &str,
expected_bytes: &[u8],
) -> CurseForgeManualDownload {
CurseForgeManualDownload {
project_id,
file_id,
file_name: file_name.to_string(),
ownership_kind:
crate::state::instances::ContentOwnershipKind::PackManaged,
operation_kind:
crate::state::instances::ManualDownloadOperationKind::PackInstall,
website_url: None,
project_type: "mod".to_string(),
project_slug: format!("stage-6-{project_id}-{file_id}"),
target_folder: "mods".to_string(),
hashes: vec![CurseForgeFileHash {
value: sha1_smol::Sha1::from(expected_bytes).hexdigest(),
algo: 1,
}],
file_length: expected_bytes.len() as u64,
file_fingerprint: 0,
}
}
fn stage8_legacy_manual_download(
project_id: u32,
file_id: u32,
file_name: &str,
) -> CurseForgeManualDownload {
CurseForgeManualDownload {
project_id,
file_id,
file_name: file_name.to_string(),
ownership_kind:
crate::state::instances::ContentOwnershipKind::PackManaged,
operation_kind:
crate::state::instances::ManualDownloadOperationKind::PackInstall,
website_url: None,
project_type: "mod".to_string(),
project_slug: format!("stage-8-{project_id}-{file_id}"),
target_folder: "mods".to_string(),
hashes: Vec::new(),
file_length: 0,
file_fingerprint: 0,
}
}
fn stage8_integrity_metadata(
download: &CurseForgeManualDownload,
hashes: Vec<CurseForgeFileHash>,
file_length: u64,
file_fingerprint: u64,
) -> CurseForgeManualDownloadIntegrityMetadata {
CurseForgeManualDownloadIntegrityMetadata {
project_id: download.project_id,
file_id: download.file_id,
hashes,
file_length,
file_fingerprint,
}
}
fn stage8_curseforge_file(
project_id: u32,
file_id: u32,
file_name: &str,
file_length: u64,
hashes: Vec<CurseForgeFileHash>,
file_fingerprint: u64,
) -> CurseForgeFile {
CurseForgeFile {
id: file_id,
game_id: MINECRAFT_GAME_ID,
mod_id: project_id,
is_available: true,
display_name: file_name.to_string(),
file_name: file_name.to_string(),
release_type: 1,
file_status: 4,
hashes,
file_date: String::new(),
file_length,
download_count: 0,
file_size_on_disk: Some(file_length),
download_url: None,
game_versions: Vec::new(),
sortable_game_versions: Vec::new(),
dependencies: Vec::new(),
expose_as_alternative: None,
parent_project_file_id: None,
alternate_file_id: None,
is_server_pack: None,
server_pack_file_id: None,
is_early_access_content: None,
early_access_end_date: None,
file_fingerprint,
modules: Vec::new(),
}
}
#[cfg(not(feature = "tauri"))]
async fn stage8_pending_keys(
state: &State,
instance_id: &str,
) -> HashSet<(String, String)> {
crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
instance_id,
&state.pool,
)
.await
.unwrap()
.into_iter()
.map(|download| {
(
download.provider_project_id,
download.provider_release_id,
)
})
.collect()
}
#[cfg(not(feature = "tauri"))]
async fn import_stage6_manual_download(
instance_id: &str,
download: &CurseForgeManualDownload,
bytes: &[u8],
) {
let source_directory = tempfile::tempdir().unwrap();
let source = source_directory.path().join(&download.file_name);
crate::util::io::write(&source, bytes).await.unwrap();
import_pending_manual_download_file(
instance_id,
download.project_id,
download.file_id,
source,
)
.await
.unwrap();
}
#[cfg(not(feature = "tauri"))]
fn stage6_waiting_manual_job_state(
instance_id: &str,
items: &[(&str, &str, &str)],
) -> crate::install::model::InstallJobState {
let mut state = crate::install::model::InstallJobState::new(
crate::install::model::InstallRequest::InstallPackToExistingInstance {
instance_id: instance_id.to_string(),
location: crate::api::pack::install_from::CreatePackLocation::FromFile {
path: PathBuf::from(format!(
"missing-stage-6-{}.mrpack",
uuid::Uuid::new_v4()
)),
},
post_install_edit: None,
},
);
let paths = items
.iter()
.map(|(path, _, _)| (*path).to_string())
.collect::<Vec<_>>();
for (path, project_id, file_id) in items {
state.record_event(InstallJobEventKind::ContentFileQueued {
path: (*path).to_string(),
bytes_total: Some(42),
max_attempts: 1,
});
state.record_event(InstallJobEventKind::ContentFileSkipped {
path: (*path).to_string(),
reason: "manual download required".to_string(),
project_id: Some((*project_id).to_string()),
version_id: Some((*file_id).to_string()),
manual_url: Some(
"https://www.curseforge.com/download".to_string(),
),
});
}
let reason =
crate::install::model::InstallPauseReason::MissingRequiredContent {
failed_files: paths.len() as u64,
paths,
};
state.pause_reason = Some(reason.clone());
state.record_event(InstallJobEventKind::WaitingForUser { reason });
state
}
#[cfg(not(feature = "tauri"))]
async fn insert_stage6_waiting_manual_job(
state: &State,
instance_id: &str,
items: &[(&str, &str, &str)],
) -> uuid::Uuid {
let job_id = uuid::Uuid::new_v4();
let job_state = stage6_waiting_manual_job_state(instance_id, items);
crate::install::store::insert(
job_id,
&job_state,
crate::install::model::InstallJobStatus::WaitingForUser,
state,
)
.await
.unwrap();
job_id
}
#[cfg(not(feature = "tauri"))]
async fn assert_stage6_job_is_unresolved(
state: &State,
job_id: uuid::Uuid,
item_path: &str,
) {
let job = crate::install::store::get_required(job_id, state)
.await
.unwrap();
assert_eq!(
job.status,
crate::install::model::InstallJobStatus::WaitingForUser
);
assert_eq!(
job.snapshot()
.items
.iter()
.find(|item| item.id == item_path)
.unwrap()
.status,
crate::install::model::DownloadItemStatus::Skipped
);
assert_eq!(
job.state
.events
.iter()
.filter(|event| matches!(
event.kind,
InstallJobEventKind::ContentFileRecovered { .. }
))
.count(),
0
);
}
#[test]
fn curseforge_final_manual_completion_recovers_and_resumes() {
let mut items =
vec![skipped_curseforge_manual_item("mods/one.jar", "1", "10")];
let materialized = HashSet::from([("1".to_string(), "10".to_string())]);
let mut resume_count = 0;
let result = reconcile_and_simulate_resume(
&mut items,
&HashSet::new(),
&materialized,
&mut resume_count,
);
assert_eq!(result.recovered.len(), 1);
assert_eq!(result.materialized_exact_match_count, 1);
assert_eq!(resume_count, 1);
assert_eq!(
items[0].status,
crate::install::model::DownloadItemStatus::Completed
);
}
#[test]
fn curseforge_missing_pending_without_materialization_does_not_resume() {
let mut items =
vec![skipped_curseforge_manual_item("mods/one.jar", "1", "10")];
let mut resume_count = 0;
let result = reconcile_and_simulate_resume(
&mut items,
&HashSet::new(),
&HashSet::new(),
&mut resume_count,
);
assert!(result.recovered.is_empty());
assert_eq!(result.inconsistent.len(), 1);
assert_eq!(
result.inconsistent[0].reason,
"pending_missing_but_not_materialized"
);
assert_eq!(resume_count, 0);
assert_eq!(
items[0].status,
crate::install::model::DownloadItemStatus::Skipped
);
}
#[test]
fn curseforge_pending_still_exists_does_not_resume() {
let mut items =
vec![skipped_curseforge_manual_item("mods/one.jar", "1", "10")];
let pending = HashSet::from([("1".to_string(), "10".to_string())]);
let mut resume_count = 0;
let result = reconcile_and_simulate_resume(
&mut items,
&pending,
&HashSet::new(),
&mut resume_count,
);
assert!(result.recovered.is_empty());
assert_eq!(result.unresolved_pending_count, 1);
assert!(result.inconsistent.is_empty());
assert_eq!(resume_count, 0);
}
#[test]
fn curseforge_partial_manual_completion_does_not_resume() {
let mut items = vec![
skipped_curseforge_manual_item("mods/a.jar", "1", "10"),
skipped_curseforge_manual_item("mods/b.jar", "2", "20"),
skipped_curseforge_manual_item("mods/c.jar", "3", "30"),
];
let keys = [
("1".to_string(), "10".to_string()),
("2".to_string(), "20".to_string()),
("3".to_string(), "30".to_string()),
];
let mut pending = keys.iter().cloned().collect::<HashSet<_>>();
let mut materialized = HashSet::new();
let mut resume_count = 0;
for (index, key) in keys.into_iter().enumerate() {
assert!(pending.remove(&key));
assert!(materialized.insert(key));
let result = reconcile_and_simulate_resume(
&mut items,
&pending,
&materialized,
&mut resume_count,
);
assert_eq!(result.recovered.len(), 1);
assert!(items[..=index].iter().all(|item| {
item.status
== crate::install::model::DownloadItemStatus::Completed
}));
assert!(items[index + 1..].iter().all(|item| {
item.status
== crate::install::model::DownloadItemStatus::Skipped
}));
assert_eq!(resume_count, usize::from(index == 2));
}
}
#[test]
fn curseforge_repeated_reconciliation_is_idempotent() {
let mut items =
vec![skipped_curseforge_manual_item("mods/one.jar", "1", "10")];
let materialized = HashSet::from([("1".to_string(), "10".to_string())]);
let mut resume_count = 0;
let mut waiting_for_user = true;
let mut recovered_count = 0;
for _ in 0..3 {
if !waiting_for_user {
continue;
}
let result = reconcile_and_simulate_resume(
&mut items,
&HashSet::new(),
&materialized,
&mut resume_count,
);
recovered_count += result.recovered.len();
if resume_count > 0 {
waiting_for_user = false;
}
}
assert_eq!(recovered_count, 1);
assert_eq!(resume_count, 1);
}
#[test]
fn curseforge_startup_selects_only_reconcilable_jobs() {
let curseforge_job = waiting_manual_job_record(
"curseforge-instance",
crate::install::model::InstallRequest::InstallCurseForgeContent {
request: CurseForgeInstallRequest {
instance_id: "curseforge-instance".to_string(),
project_id: 1,
file_id: 10,
project_type: "mod".to_string(),
ownership_kind:
crate::state::instances::ContentOwnershipKind::PackManaged,
manual_operation_kind: crate::state::instances::ManualDownloadOperationKind::PackInstall,
game_version: None,
mod_loader_type: None,
world_name: None,
install_dependencies: true,
excluded_dependency_project_ids: Vec::new(),
force_dependency_project_ids: Vec::new(),
dependency_plan_id: None,
},
display_title: "CurseForge".to_string(),
display_icon: None,
},
);
let modrinth_job = waiting_manual_job_record(
"modrinth-instance",
crate::install::model::InstallRequest::InstallPackToExistingInstance {
instance_id: "modrinth-instance".to_string(),
location: crate::api::pack::install_from::CreatePackLocation::FromVersionId {
project_id: "project".to_string(),
version_id: "version".to_string(),
title: "Modrinth".to_string(),
icon_url: None,
},
post_install_edit: None,
},
);
assert_eq!(
curseforge_waiting_job_instance_ids(&[
curseforge_job,
modrinth_job,
]),
vec!["curseforge-instance".to_string()]
);
let mut items =
vec![skipped_curseforge_manual_item("mods/one.jar", "1", "10")];
let materialized = HashSet::from([("1".to_string(), "10".to_string())]);
let mut resume_count = 0;
let result = reconcile_and_simulate_resume(
&mut items,
&HashSet::new(),
&materialized,
&mut resume_count,
);
assert_eq!(result.recovered.len(), 1);
assert_eq!(resume_count, 1);
}
#[test]
fn curseforge_wrong_instance_or_release_never_recovers() {
let mut items =
vec![skipped_curseforge_manual_item("mods/one.jar", "1", "10")];
let other_instance_or_release = HashSet::from([
("1".to_string(), "11".to_string()),
("2".to_string(), "10".to_string()),
]);
let mut resume_count = 0;
let result = reconcile_and_simulate_resume(
&mut items,
&HashSet::new(),
&other_instance_or_release,
&mut resume_count,
);
assert!(result.recovered.is_empty());
assert_eq!(result.inconsistent.len(), 1);
assert_eq!(resume_count, 0);
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_pending_is_scoped_by_instance() {
let (state, instance_a) = create_stage6_instance("scope A").await;
let (_, instance_b) = create_stage6_instance("scope B").await;
let completed = stage6_manual_download(
101,
1001,
"scope-one.jar",
b"scope-one-content",
);
let other_release = stage6_manual_download(
101,
1002,
"scope-two.jar",
b"scope-two-content",
);
persist_manual_download(&instance_a, &completed)
.await
.unwrap();
persist_manual_download(&instance_a, &other_release)
.await
.unwrap();
persist_manual_download(&instance_b, &completed)
.await
.unwrap();
let job_a = insert_stage6_waiting_manual_job(
&state,
&instance_a,
&[
("mods/scope-one.jar", "101", "1001"),
("mods/scope-two.jar", "101", "1002"),
],
)
.await;
let job_b = insert_stage6_waiting_manual_job(
&state,
&instance_b,
&[("mods/scope-one.jar", "101", "1001")],
)
.await;
import_stage6_manual_download(
&instance_a,
&completed,
b"scope-one-content",
)
.await;
let pending_a = crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
&instance_a,
&state.pool,
)
.await
.unwrap();
let pending_b = crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
&instance_b,
&state.pool,
)
.await
.unwrap();
assert_eq!(pending_a.len(), 1);
assert_eq!(pending_a[0].provider_project_id, "101");
assert_eq!(pending_a[0].provider_release_id, "1002");
assert_eq!(pending_b.len(), 1);
assert_eq!(pending_b[0].provider_project_id, "101");
assert_eq!(pending_b[0].provider_release_id, "1001");
let reconciled_a = crate::install::store::get_required(job_a, &state)
.await
.unwrap();
assert_eq!(
reconciled_a.status,
crate::install::model::InstallJobStatus::WaitingForUser
);
let items_a = reconciled_a.snapshot().items;
assert_eq!(
items_a
.iter()
.find(|item| item.id == "mods/scope-one.jar")
.unwrap()
.status,
crate::install::model::DownloadItemStatus::Completed
);
assert_eq!(
items_a
.iter()
.find(|item| item.id == "mods/scope-two.jar")
.unwrap()
.status,
crate::install::model::DownloadItemStatus::Skipped
);
assert_eq!(
reconciled_a
.state
.events
.iter()
.filter(|event| matches!(
event.kind,
InstallJobEventKind::ContentFileRecovered { .. }
))
.count(),
1
);
assert_stage6_job_is_unresolved(&state, job_b, "mods/scope-one.jar")
.await;
crate::install::store::dismiss(job_a, &state).await.unwrap();
crate::install::store::dismiss(job_b, &state).await.unwrap();
crate::api::instance::remove(&instance_a).await.unwrap();
crate::api::instance::remove(&instance_b).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_missing_pending_without_materialization_keeps_real_job_waiting()
{
let (state, instance_id) =
create_stage6_instance("missing materialization").await;
let job_id = insert_stage6_waiting_manual_job(
&state,
&instance_id,
&[("mods/missing.jar", "201", "2001")],
)
.await;
reconcile_curseforge_waiting_jobs_for_instance_with_state(
&instance_id,
&state,
)
.await
.unwrap();
assert_stage6_job_is_unresolved(&state, job_id, "mods/missing.jar")
.await;
crate::install::store::dismiss(job_id, &state)
.await
.unwrap();
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_pending_still_exists_keeps_real_job_waiting() {
let (state, instance_id) = create_stage6_instance("pending").await;
let download = stage6_manual_download(
301,
3001,
"pending.jar",
b"pending-content",
);
persist_manual_download(&instance_id, &download)
.await
.unwrap();
let job_id = insert_stage6_waiting_manual_job(
&state,
&instance_id,
&[("mods/pending.jar", "301", "3001")],
)
.await;
reconcile_curseforge_waiting_jobs_for_instance_with_state(
&instance_id,
&state,
)
.await
.unwrap();
assert_stage6_job_is_unresolved(&state, job_id, "mods/pending.jar")
.await;
crate::install::store::dismiss(job_id, &state)
.await
.unwrap();
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_wrong_release_keeps_real_job_waiting() {
let (state, instance_id) =
create_stage6_instance("wrong release").await;
let other_release = stage6_manual_download(
401,
4002,
"other-release.jar",
b"other-release-content",
);
persist_manual_download(&instance_id, &other_release)
.await
.unwrap();
import_stage6_manual_download(
&instance_id,
&other_release,
b"other-release-content",
)
.await;
let job_id = insert_stage6_waiting_manual_job(
&state,
&instance_id,
&[("mods/target-release.jar", "401", "4001")],
)
.await;
reconcile_curseforge_waiting_jobs_for_instance_with_state(
&instance_id,
&state,
)
.await
.unwrap();
assert_stage6_job_is_unresolved(
&state,
job_id,
"mods/target-release.jar",
)
.await;
crate::install::store::dismiss(job_id, &state)
.await
.unwrap();
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_startup_reconciliation_recovers_and_resumes_once() {
let (state, instance_id) = create_stage6_instance("startup").await;
let download = stage6_manual_download(
501,
5001,
"startup.jar",
b"startup-content",
);
persist_manual_download(&instance_id, &download)
.await
.unwrap();
import_stage6_manual_download(
&instance_id,
&download,
b"startup-content",
)
.await;
let job_id = insert_stage6_waiting_manual_job(
&state,
&instance_id,
&[("mods/startup.jar", "501", "5001")],
)
.await;
let resume_count =
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let state_for_resume = std::sync::Arc::clone(&state);
let resume_count_for_callback = std::sync::Arc::clone(&resume_count);
let mut resume_job = move |job_id| {
let state = std::sync::Arc::clone(&state_for_resume);
let resume_count =
std::sync::Arc::clone(&resume_count_for_callback);
async move {
let current =
crate::install::store::get_required(job_id, &state).await?;
let claimed = crate::install::store::update_status_if(
job_id,
crate::install::model::InstallJobStatus::WaitingForUser,
crate::install::model::InstallJobStatus::Queued,
&current.state,
&state,
)
.await?;
assert!(
claimed.is_some(),
"resume status claim must succeed once"
);
resume_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
};
for _ in 0..3 {
reconcile_persisted_curseforge_waiting_jobs_with_resume(
&state,
&mut resume_job,
)
.await
.unwrap();
}
let reconciled = crate::install::store::get_required(job_id, &state)
.await
.unwrap();
assert_eq!(
reconciled.status,
crate::install::model::InstallJobStatus::Queued
);
assert_eq!(
reconciled
.snapshot()
.items
.iter()
.find(|item| item.id == "mods/startup.jar")
.unwrap()
.status,
crate::install::model::DownloadItemStatus::Completed
);
assert_eq!(
reconciled
.state
.events
.iter()
.filter(|event| matches!(
event.kind,
InstallJobEventKind::ContentFileRecovered { .. }
))
.count(),
1
);
assert_eq!(resume_count.load(std::sync::atomic::Ordering::SeqCst), 1);
crate::install::store::dismiss(job_id, &state)
.await
.unwrap();
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[test]
fn unified_search_hit_accepts_null_gallery() {
let hit: UnifiedSearchHit = serde_json::from_value(serde_json::json!({
"provider": "curseforge",
"project_id": "250419",
"slug": null,
"author": "DarkhaxDev",
"author_url": null,
"title": "Enchantment Descriptions",
"description": "description",
"project_type": "mod",
"categories": [],
"versions": [],
"downloads": 0,
"icon_url": null,
"date_created": "2026-01-01T00:00:00Z",
"date_modified": "2026-01-01T00:00:00Z",
"latest_version": null,
"gallery": null,
"website_url": null,
"source_url": null,
"allow_mod_distribution": null,
}))
.unwrap();
assert!(hit.gallery.is_empty());
}
#[test]
fn curseforge_file_accepts_null_modules() {
let file: CurseForgeFile = serde_json::from_value(serde_json::json!({
"id": 4031925,
"gameId": 432,
"modId": 250419,
"isAvailable": true,
"displayName": "old.jar",
"fileName": "old.jar",
"releaseType": 1,
"fileStatus": 4,
"fileDate": "2026-01-01T00:00:00Z",
"fileLength": 1,
"downloadCount": 0,
"fileFingerprint": 0,
"modules": null,
}))
.unwrap();
assert!(file.modules.is_empty());
}
#[test]
fn fingerprint_ignores_curseforge_whitespace() {
assert_eq!(
compute_fingerprint(b"abc\r\n def\t"),
compute_fingerprint(b"abcdef")
);
}
#[test]
fn project_types_are_provider_qualified() {
assert_eq!(project_type_for_class(Some(6)), "mod");
assert_eq!(project_type_for_class(Some(4471)), "modpack");
assert_eq!(project_type_for_class(Some(6552)), "shader");
assert_eq!(project_type_for_class(Some(6945)), "datapack");
}
#[test]
fn official_only_requests_exclude_mirror_routes() {
let routes =
request_routes("/v1/mods/search", MirrorPolicy::OfficialOnly);
assert!(
routes
.iter()
.all(|route| route.source == RequestRouteSource::Official)
);
}
#[test]
fn mirror_not_found_tries_next_route() {
let route = RequestRoute {
url: String::new(),
use_api_key: false,
use_system_proxy: false,
source: RequestRouteSource::Mirror,
};
assert!(should_try_next_route(&route, StatusCode::NOT_FOUND, true));
}
#[test]
fn official_forbidden_response_tries_proxy_route() {
let route = RequestRoute {
url: String::new(),
use_api_key: true,
use_system_proxy: false,
source: RequestRouteSource::Official,
};
assert!(should_try_next_route(&route, StatusCode::FORBIDDEN, true));
}
#[test]
fn projects_accept_negative_popularity_ranks() {
let project = serde_json::from_value::<CurseForgeProject>(json!({
"id": 1,
"gameId": MINECRAFT_GAME_ID,
"name": "Fixture",
"slug": "fixture",
"links": {},
"summary": "Fixture project",
"status": 4,
"downloadCount": 0,
"isFeatured": false,
"primaryCategoryId": 6,
"categories": [],
"classId": 6,
"authors": [],
"logo": null,
"screenshots": [],
"mainFileId": 0,
"latestFiles": [],
"latestFilesIndexes": [],
"dateCreated": "2026-01-01T00:00:00Z",
"dateModified": "2026-01-01T00:00:00Z",
"dateReleased": "2026-01-01T00:00:00Z",
"allowModDistribution": true,
"gamePopularityRank": -10,
"isAvailable": true
}))
.unwrap();
assert_eq!(project.game_popularity_rank, Some(-10));
}
#[test]
fn category_cache_can_be_filtered_for_each_project_class() {
let categories = vec![
category(6, None, true),
category(406, Some(6), false),
category(4471, None, true),
category(4481, Some(4471), false),
];
let mods = filter_categories(categories.clone(), Some(6));
assert_eq!(
mods.iter().map(|category| category.id).collect::<Vec<_>>(),
vec![6, 406]
);
let modpacks = filter_categories(categories, Some(4471));
assert_eq!(
modpacks
.iter()
.map(|category| category.id)
.collect::<Vec<_>>(),
vec![4471, 4481]
);
}
fn category(
id: u32,
class_id: Option<u32>,
is_class: bool,
) -> CurseForgeCategory {
CurseForgeCategory {
id,
game_id: MINECRAFT_GAME_ID,
name: id.to_string(),
slug: id.to_string(),
url: String::new(),
icon_url: None,
date_modified: String::new(),
is_class: Some(is_class),
class_id,
parent_category_id: class_id,
display_index: Some(0),
}
}
#[test]
fn manual_downloads_open_the_official_download_page() {
assert_eq!(
curseforge_file_page_url(
Some("https://www.curseforge.com/minecraft/mc-mods/example"),
12345,
),
Some(
"https://www.curseforge.com/minecraft/mc-mods/example/download/12345"
.to_string()
)
);
assert_eq!(
curseforge_file_page_url(
Some("https://www.curseforge.com/minecraft/mc-mods/example/files/12345?tab=files"),
12345,
),
Some(
"https://www.curseforge.com/minecraft/mc-mods/example/download/12345"
.to_string()
)
);
assert_eq!(
curseforge_file_page_url(
Some("https://example.com/project"),
12345
),
Some("https://example.com/project".to_string())
);
}
#[test]
fn browser_duplicate_download_names_match_the_expected_file() {
assert!(crate::util::downloads::browser_download_file_name_matches(
"example-mod (1).jar",
"example-mod.jar"
));
assert!(crate::util::downloads::browser_download_file_name_matches(
"EXAMPLE-MOD.JAR",
"example-mod.jar"
));
assert!(!crate::util::downloads::browser_download_file_name_matches(
"example-mod-fabric.jar",
"example-mod.jar"
));
assert!(!crate::util::downloads::browser_download_file_name_matches(
"example-mod.jar.crdownload",
"example-mod.jar"
));
}
#[tokio::test]
async fn curseforge_metadata_less_exact_name_is_not_trusted() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("example-mod.jar");
crate::util::io::write(&path, b"wrong exact-name jar")
.await
.unwrap();
let download = stage8_legacy_manual_download(1, 2, "example-mod.jar");
let error = verify_manual_download_candidate_with_integrity(
&path, &download, true,
)
.await
.expect_err("filename alone must not verify a candidate");
assert!(matches!(
error.raw.as_ref(),
ErrorKind::InputError(message)
if message
== "The required CurseForge file has no usable integrity metadata"
));
}
#[tokio::test]
async fn curseforge_hydration_is_scoped_to_exact_release() {
let download =
stage8_legacy_manual_download(11, 101, "exact-release.jar");
let error = ensure_manual_download_integrity_metadata_with(
&download,
|project_id, file_id| async move {
assert_eq!(project_id, 11);
assert_eq!(file_id, 101);
Ok(CurseForgeManualDownloadIntegrityMetadata {
project_id,
file_id: 102,
hashes: vec![CurseForgeFileHash {
value: "wrong-release-sha1".to_string(),
algo: 1,
}],
file_length: 1,
file_fingerprint: 0,
})
},
)
.await
.expect_err("metadata for another file must be rejected");
assert!(matches!(
error.raw.as_ref(),
ErrorKind::InputError(message)
if message
== "CurseForge returned metadata for a different project or file"
));
}
#[tokio::test]
async fn curseforge_existing_integrity_skips_hydration() {
let mut sha1_download =
stage8_legacy_manual_download(12, 201, "existing-sha1.jar");
sha1_download.hashes = vec![CurseForgeFileHash {
value: "existing-sha1".to_string(),
algo: 1,
}];
let hydrated = ensure_manual_download_integrity_metadata_with(
&sha1_download,
|_, _| async {
Err(ErrorKind::InputError(
"metadata resolver must not run".to_string(),
)
.into())
},
)
.await
.unwrap();
assert_eq!(hydrated.hashes[0].value, "existing-sha1");
let mut fingerprint_download =
stage8_legacy_manual_download(12, 202, "existing-fingerprint.jar");
fingerprint_download.file_fingerprint = 12345;
let hydrated = ensure_manual_download_integrity_metadata_with(
&fingerprint_download,
|_, _| async {
Err(ErrorKind::InputError(
"metadata resolver must not run".to_string(),
)
.into())
},
)
.await
.unwrap();
assert_eq!(hydrated.file_fingerprint, 12345);
}
#[tokio::test]
async fn curseforge_hydrated_fingerprint_verifies_candidate() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("fingerprint.jar");
let bytes = b"legacy fingerprint content";
crate::util::io::write(&path, bytes).await.unwrap();
let download =
stage8_legacy_manual_download(13, 301, "fingerprint.jar");
let metadata = stage8_integrity_metadata(
&download,
Vec::new(),
bytes.len() as u64,
compute_fingerprint(bytes) as u64,
);
let hydrated = ensure_manual_download_integrity_metadata_with(
&download,
move |project_id, file_id| async move {
assert_eq!((project_id, file_id), (13, 301));
Ok(metadata)
},
)
.await
.unwrap();
assert!(
verify_manual_download_candidate_with_integrity(
&path, &hydrated, true,
)
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn curseforge_manual_materialization_is_copy_isolated() {
let source_directory = tempfile::tempdir().unwrap();
let destination_directory = tempfile::tempdir().unwrap();
let source = source_directory.path().join("manual.jar");
let destination = destination_directory.path().join("manual.jar");
let verified_bytes = b"verified manual bytes";
crate::util::io::write(&source, verified_bytes)
.await
.unwrap();
let (verified_size, verified_sha1) =
sha1_file_async(&source).await.unwrap();
crate::state::materialize_verified_project_download_copy(
&source,
&destination,
verified_size,
&verified_sha1,
)
.await
.unwrap();
crate::util::io::write(&source, b"mutated source content")
.await
.unwrap();
assert_eq!(
crate::util::io::read(&destination).await.unwrap(),
verified_bytes
);
assert_eq!(
sha1_file_async(&destination).await.unwrap(),
(verified_size, verified_sha1)
);
}
#[tokio::test]
async fn curseforge_fingerprint_recognition_rechecks_current_bytes() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("recognized.jar");
let matched_bytes = b"matched fingerprint bytes";
let expected_fingerprint = compute_fingerprint(matched_bytes) as u64;
crate::util::io::write(&path, matched_bytes).await.unwrap();
assert!(
verify_recognized_curseforge_file(&path, expected_fingerprint)
.await
.unwrap()
.is_some()
);
crate::util::io::write(&path, b"changed after fingerprint match")
.await
.unwrap();
assert!(
verify_recognized_curseforge_file(&path, expected_fingerprint)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn curseforge_localized_candidate_still_requires_integrity() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("[测试]example-mod (1).jar");
crate::util::io::write(&path, b"expected curseforge file")
.await
.unwrap();
let (_, sha1) = sha1_file_async(&path).await.unwrap();
let download = CurseForgeManualDownload {
project_id: 1,
file_id: 2,
file_name: "example-mod.jar".to_string(),
ownership_kind:
crate::state::instances::ContentOwnershipKind::PackManaged,
operation_kind:
crate::state::instances::ManualDownloadOperationKind::PackInstall,
website_url: None,
project_type: "mod".to_string(),
project_slug: String::new(),
target_folder: "mods".to_string(),
hashes: vec![CurseForgeFileHash {
value: sha1,
algo: 1,
}],
file_length: 24,
file_fingerprint: 0,
};
assert!(
find_manual_download_candidate(directory.path(), &download)
.await
.unwrap()
.is_some()
);
crate::util::io::write(&path, b"same-length-wrong-bytes!")
.await
.unwrap();
assert!(
find_manual_download_candidate(directory.path(), &download)
.await
.unwrap()
.is_none()
);
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_legacy_pending_imports_after_exact_metadata_hydration()
{
let (state, instance_id) =
create_stage6_instance("legacy hydration").await;
let download =
stage8_legacy_manual_download(14, 401, "legacy-hydration.jar");
persist_manual_download(&instance_id, &download)
.await
.unwrap();
let source_directory = tempfile::tempdir().unwrap();
let source = source_directory.path().join(&download.file_name);
let bytes = b"legacy hydrated content";
crate::util::io::write(&source, bytes).await.unwrap();
let metadata = stage8_integrity_metadata(
&download,
vec![CurseForgeFileHash {
value: sha1_smol::Sha1::from(bytes).hexdigest(),
algo: 1,
}],
bytes.len() as u64,
0,
);
let imported =
import_pending_manual_download_file_with_integrity_resolver(
&instance_id,
download.project_id,
download.file_id,
source.clone(),
move |project_id, file_id| async move {
assert_eq!((project_id, file_id), (14, 401));
Ok(metadata)
},
)
.await
.unwrap();
assert!(source.exists());
assert_eq!(imported.relative_path, "mods/legacy-hydration.jar");
assert!(
crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
&instance_id,
&state.pool,
)
.await
.unwrap()
.is_empty()
);
let snapshot = crate::api::instance::get_content_snapshot(&instance_id)
.await
.unwrap();
assert!(snapshot.items.iter().any(|item| {
item.provider_project_id.as_deref() == Some("14")
&& item.provider_release_id.as_deref() == Some("401")
&& item.materialization_state
== crate::state::instances::PackMemberMaterializationState::Present
&& item.content.is_some()
}));
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_missing_integrity_after_hydration_keeps_pending_unresolved()
{
let (state, instance_id) =
create_stage6_instance("missing hydrated integrity").await;
let download =
stage8_legacy_manual_download(15, 501, "missing-integrity.jar");
persist_manual_download(&instance_id, &download)
.await
.unwrap();
let job_id = insert_stage6_waiting_manual_job(
&state,
&instance_id,
&[("mods/missing-integrity.jar", "15", "501")],
)
.await;
let source_directory = tempfile::tempdir().unwrap();
let source = source_directory.path().join(&download.file_name);
crate::util::io::write(&source, b"unverifiable content")
.await
.unwrap();
let metadata = stage8_integrity_metadata(&download, Vec::new(), 20, 0);
let fetch_error =
import_pending_manual_download_file_with_integrity_resolver(
&instance_id,
download.project_id,
download.file_id,
source.clone(),
|project_id, file_id| async move {
assert_eq!((project_id, file_id), (15, 501));
Err(ErrorKind::InputError(
"simulated CurseForge metadata fetch failure"
.to_string(),
)
.into())
},
)
.await
.expect_err("metadata fetch failures must block import");
assert!(matches!(
fetch_error.raw.as_ref(),
ErrorKind::InputError(message)
if message
== "simulated CurseForge metadata fetch failure"
));
let error =
import_pending_manual_download_file_with_integrity_resolver(
&instance_id,
download.project_id,
download.file_id,
source.clone(),
move |project_id, file_id| async move {
assert_eq!((project_id, file_id), (15, 501));
Ok(metadata)
},
)
.await
.expect_err("missing authoritative integrity must block import");
assert!(matches!(
error.raw.as_ref(),
ErrorKind::InputError(message)
if message
== "The required CurseForge file has no usable integrity metadata"
));
assert!(source.exists());
assert_eq!(
crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
&instance_id,
&state.pool,
)
.await
.unwrap()
.len(),
1
);
let target = crate::api::instance::get_full_path(&instance_id)
.await
.unwrap()
.join("mods/missing-integrity.jar");
assert!(!target.exists());
assert_stage6_job_is_unresolved(
&state,
job_id,
"mods/missing-integrity.jar",
)
.await;
let job = crate::install::store::get_required(job_id, &state)
.await
.unwrap();
assert_eq!(
job.state
.events
.iter()
.filter(|event| matches!(
event.kind,
InstallJobEventKind::ContentFileRecovered { .. }
))
.count(),
0
);
crate::install::store::dismiss(job_id, &state)
.await
.unwrap();
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_generic_record_does_not_complete_pending() {
let (state, instance_id) =
create_stage6_instance("generic record provenance").await;
let download =
stage8_legacy_manual_download(16, 601, "generic-record.jar");
persist_manual_download(&instance_id, &download)
.await
.unwrap();
let relative_path = "mods/generic-record.jar";
let full_path = crate::api::instance::get_full_path(&instance_id)
.await
.unwrap()
.join(relative_path);
let bytes = b"generic recorded bytes";
crate::util::io::write(&full_path, bytes).await.unwrap();
let (_, sha1) = sha1_file_async(&full_path).await.unwrap();
let provider_ref = ContentProviderRef::CurseForge {
project_id: CurseForgeProjectId::new(16).unwrap(),
file_id: Some(CurseForgeFileId::new(601).unwrap()),
};
crate::state::record_project_file_atomic(
&instance_id,
relative_path,
&sha1,
bytes.len() as u64,
ProjectType::Mod,
ContentSourceKind::CurseForge,
crate::state::instances::ContentOwnershipKind::PackManaged,
Some(&provider_ref),
true,
None,
&state,
)
.await
.unwrap();
assert!(
stage8_pending_keys(&state, &instance_id)
.await
.contains(&("16".to_string(), "601".to_string()))
);
let snapshot = crate::api::instance::get_content_snapshot(&instance_id)
.await
.unwrap();
assert!(snapshot.items.iter().any(|item| {
item.provider_project_id.as_deref() == Some("16")
&& item.provider_release_id.as_deref() == Some("601")
&& item.materialization_state
== crate::state::instances::PackMemberMaterializationState::Present
&& item.content.is_some()
}));
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_verified_record_completes_only_exact_pending() {
let (state, instance_a) =
create_stage6_instance("verified record A").await;
let (_, instance_b) = create_stage6_instance("verified record B").await;
let file_1 = stage8_legacy_manual_download(17, 701, "one.jar");
let file_2 = stage8_legacy_manual_download(17, 702, "two.jar");
persist_manual_download(&instance_a, &file_1).await.unwrap();
persist_manual_download(&instance_a, &file_2).await.unwrap();
persist_manual_download(&instance_b, &file_1).await.unwrap();
let relative_path = "mods/one.jar";
let full_path = crate::api::instance::get_full_path(&instance_a)
.await
.unwrap()
.join(relative_path);
let bytes = b"verified exact record";
crate::util::io::write(&full_path, bytes).await.unwrap();
let (_, sha1) = sha1_file_async(&full_path).await.unwrap();
crate::state::record_verified_curseforge_project_file_atomic(
&instance_a,
relative_path,
&sha1,
bytes.len() as u64,
ProjectType::Mod,
ContentSourceKind::CurseForge,
crate::state::instances::ContentOwnershipKind::PackManaged,
CurseForgeProjectId::new(17).unwrap(),
CurseForgeFileId::new(701).unwrap(),
true,
&state,
)
.await
.unwrap();
assert_eq!(
stage8_pending_keys(&state, &instance_a).await,
HashSet::from([("17".to_string(), "702".to_string())])
);
assert_eq!(
stage8_pending_keys(&state, &instance_b).await,
HashSet::from([("17".to_string(), "701".to_string())])
);
crate::api::instance::remove(&instance_a).await.unwrap();
crate::api::instance::remove(&instance_b).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_manual_materialization_detects_source_change() {
let (state, instance_id) =
create_stage6_instance("manual copy TOCTOU").await;
let download = stage8_legacy_manual_download(18, 801, "toctou.jar");
persist_manual_download(&instance_id, &download)
.await
.unwrap();
let source_directory = tempfile::tempdir().unwrap();
let source = source_directory.path().join("toctou.jar");
crate::util::io::write(&source, b"original verified bytes")
.await
.unwrap();
let (verified_size, verified_sha1) =
sha1_file_async(&source).await.unwrap();
crate::util::io::write(&source, b"changed after verification")
.await
.unwrap();
let destination = crate::api::instance::get_full_path(&instance_id)
.await
.unwrap()
.join("mods/toctou.jar");
let previous_bytes = b"previous destination";
crate::util::io::write(&destination, previous_bytes)
.await
.unwrap();
crate::state::materialize_verified_project_download_copy(
&source,
&destination,
verified_size,
&verified_sha1,
)
.await
.expect_err("changed source must fail copied-byte identity");
assert_eq!(
crate::util::io::read(&destination).await.unwrap(),
previous_bytes
);
assert!(
stage8_pending_keys(&state, &instance_id)
.await
.contains(&("18".to_string(), "801".to_string()))
);
let snapshot = crate::api::instance::get_content_snapshot(&instance_id)
.await
.unwrap();
assert!(!snapshot.items.iter().any(|item| {
item.provider_project_id.as_deref() == Some("18")
&& item.provider_release_id.as_deref() == Some("801")
&& item.materialization_state
== crate::state::instances::PackMemberMaterializationState::Present
&& item.content.is_some()
}));
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_automatic_sha1_record_can_complete_pending() {
let (state, instance_id) =
create_stage6_instance("automatic SHA1").await;
let download =
stage8_legacy_manual_download(19, 901, "automatic-sha1.jar");
persist_manual_download(&instance_id, &download)
.await
.unwrap();
let relative_path = "mods/automatic-sha1.jar";
let full_path = crate::api::instance::get_full_path(&instance_id)
.await
.unwrap()
.join(relative_path);
let bytes = b"automatic SHA1 bytes";
crate::util::io::write(&full_path, bytes).await.unwrap();
let (_, sha1) = sha1_file_async(&full_path).await.unwrap();
let file = stage8_curseforge_file(
19,
901,
"automatic-sha1.jar",
bytes.len() as u64,
vec![CurseForgeFileHash {
value: sha1,
algo: 1,
}],
0,
);
record_installed_curseforge_file(
&instance_id,
relative_path,
&full_path,
&file,
ProjectType::Mod,
crate::state::instances::ContentOwnershipKind::PackManaged,
&state,
)
.await
.unwrap();
assert!(stage8_pending_keys(&state, &instance_id).await.is_empty());
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_automatic_fingerprint_record_can_complete_pending() {
let (state, instance_id) =
create_stage6_instance("automatic fingerprint").await;
let download = stage8_legacy_manual_download(
20,
1001,
"automatic-fingerprint.jar",
);
persist_manual_download(&instance_id, &download)
.await
.unwrap();
let relative_path = "mods/automatic-fingerprint.jar";
let full_path = crate::api::instance::get_full_path(&instance_id)
.await
.unwrap()
.join(relative_path);
let bytes = b"automatic fingerprint bytes";
crate::util::io::write(&full_path, bytes).await.unwrap();
let file = stage8_curseforge_file(
20,
1001,
"automatic-fingerprint.jar",
bytes.len() as u64,
Vec::new(),
compute_fingerprint(bytes) as u64,
);
record_installed_curseforge_file(
&instance_id,
relative_path,
&full_path,
&file,
ProjectType::Mod,
crate::state::instances::ContentOwnershipKind::PackManaged,
&state,
)
.await
.unwrap();
assert!(stage8_pending_keys(&state, &instance_id).await.is_empty());
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_automatic_weak_record_keeps_pending() {
let (state, instance_id) =
create_stage6_instance("automatic weak metadata").await;
let download =
stage8_legacy_manual_download(21, 1101, "automatic-md5.jar");
persist_manual_download(&instance_id, &download)
.await
.unwrap();
let relative_path = "mods/automatic-md5.jar";
let full_path = crate::api::instance::get_full_path(&instance_id)
.await
.unwrap()
.join(relative_path);
let bytes = b"automatic MD5-only bytes";
crate::util::io::write(&full_path, bytes).await.unwrap();
let file = stage8_curseforge_file(
21,
1101,
"automatic-md5.jar",
bytes.len() as u64,
vec![CurseForgeFileHash {
value: "validated-md5".to_string(),
algo: 2,
}],
0,
);
record_installed_curseforge_file(
&instance_id,
relative_path,
&full_path,
&file,
ProjectType::Mod,
crate::state::instances::ContentOwnershipKind::PackManaged,
&state,
)
.await
.unwrap();
assert!(
stage8_pending_keys(&state, &instance_id)
.await
.contains(&("21".to_string(), "1101".to_string()))
);
let snapshot = crate::api::instance::get_content_snapshot(&instance_id)
.await
.unwrap();
assert!(snapshot.items.iter().any(|item| {
item.provider_project_id.as_deref() == Some("21")
&& item.provider_release_id.as_deref() == Some("1101")
&& item.materialization_state
== crate::state::instances::PackMemberMaterializationState::Present
}));
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_rollback_identity_record_does_not_complete_pending() {
let (state, instance_id) =
create_stage6_instance("rollback provenance").await;
let download = stage8_legacy_manual_download(22, 1201, "rollback.jar");
persist_manual_download(&instance_id, &download)
.await
.unwrap();
let relative_path = "mods/rollback.jar";
let full_path = crate::api::instance::get_full_path(&instance_id)
.await
.unwrap()
.join(relative_path);
let bytes = b"historical rollback bytes";
crate::util::io::write(&full_path, bytes).await.unwrap();
let (_, sha1) = sha1_file_async(&full_path).await.unwrap();
let provider_ref = ContentProviderRef::CurseForge {
project_id: CurseForgeProjectId::new(22).unwrap(),
file_id: Some(CurseForgeFileId::new(1201).unwrap()),
};
crate::state::record_project_file_atomic(
&instance_id,
relative_path,
&sha1,
bytes.len() as u64,
ProjectType::Mod,
ContentSourceKind::CurseForge,
crate::state::instances::ContentOwnershipKind::PackManaged,
Some(&provider_ref),
true,
None,
&state,
)
.await
.unwrap();
assert!(
stage8_pending_keys(&state, &instance_id)
.await
.contains(&("22".to_string(), "1201".to_string()))
);
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_non_pending_import_fails_before_verification() {
let (state, instance_id) =
create_stage6_instance("non-pending import").await;
let source_directory = tempfile::tempdir().unwrap();
let source = source_directory.path().join("invalid.jar");
crate::util::io::write(&source, b"not the required file")
.await
.unwrap();
let error = import_pending_manual_download_file(
&instance_id,
601,
6001,
source.clone(),
)
.await
.expect_err("missing pending identity must be rejected");
assert!(matches!(
error.raw.as_ref(),
ErrorKind::InputError(message)
if message
== "The selected CurseForge file is not pending for this instance"
));
assert!(source.exists());
assert!(
crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
&instance_id,
&state.pool,
)
.await
.unwrap()
.is_empty()
);
let target = crate::api::instance::get_full_path(&instance_id)
.await
.unwrap()
.join("mods/invalid.jar");
assert!(!target.exists());
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn curseforge_wrong_pending_file_fails_integrity() {
let (state, instance_id) =
create_stage6_instance("wrong pending file").await;
let download = stage6_manual_download(
701,
7001,
"expected.jar",
b"required pending bytes",
);
persist_manual_download(&instance_id, &download)
.await
.unwrap();
let source_directory = tempfile::tempdir().unwrap();
let source = source_directory.path().join("selected.jar");
crate::util::io::write(&source, b"wrong pending bytes")
.await
.unwrap();
let error = import_pending_manual_download_file(
&instance_id,
download.project_id,
download.file_id,
source.clone(),
)
.await
.expect_err("wrong pending file must fail integrity");
assert!(matches!(
error.raw.as_ref(),
ErrorKind::InputError(message)
if message
== "The selected file does not match the required CurseForge file"
));
assert!(source.exists());
let pending = crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
&instance_id,
&state.pool,
)
.await
.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].provider_project_id, "701");
assert_eq!(pending[0].provider_release_id, "7001");
let snapshot = crate::api::instance::get_content_snapshot(&instance_id)
.await
.unwrap();
assert!(!snapshot.items.iter().any(|item| {
item.provider_project_id.as_deref() == Some("701")
&& item.provider_release_id.as_deref() == Some("7001")
&& item.materialization_state
== crate::state::instances::PackMemberMaterializationState::Present
&& item.content.is_some()
}));
crate::api::instance::remove(&instance_id).await.unwrap();
}
#[cfg(not(feature = "tauri"))]
#[tokio::test]
async fn manual_import_completes_persisted_pack_member() {
crate::event::EventState::init().await.unwrap();
let state_root = tempfile::tempdir().unwrap().keep();
let state =
State::init_for_test(state_root.to_string_lossy().to_string())
.await
.unwrap();
let created = crate::api::instance::create(
format!("CurseForge drag {}", uuid::Uuid::new_v4()),
"1.12.2".to_string(),
ModLoader::Forge,
Some("14.23.5.2860".to_string()),
None,
InstanceLink::Unmanaged,
None,
None,
)
.await
.unwrap();
let source_directory = tempfile::tempdir().unwrap();
let source = source_directory.path().join("example-mod.jar");
let bytes = b"verified curseforge drag";
crate::util::io::write(&source, bytes).await.unwrap();
let (_, sha1) = sha1_file_async(&source).await.unwrap();
let download = CurseForgeManualDownload {
project_id: 123,
file_id: 456,
file_name: "example-mod.jar".to_string(),
ownership_kind:
crate::state::instances::ContentOwnershipKind::PackManaged,
operation_kind:
crate::state::instances::ManualDownloadOperationKind::PackInstall,
website_url: None,
project_type: "mod".to_string(),
project_slug: "example-mod".to_string(),
target_folder: "mods".to_string(),
hashes: vec![CurseForgeFileHash {
value: sha1,
algo: 1,
}],
file_length: bytes.len() as u64,
file_fingerprint: 0,
};
persist_manual_download(&created.instance.id, &download)
.await
.unwrap();
crate::util::io::write(&source, b"same-length-wrong-bytes!")
.await
.unwrap();
assert!(
import_pending_manual_download_from_path(
&created.instance.id,
&source,
)
.await
.is_err()
);
assert_eq!(
crate::state::instances::adapters::sqlite::content_rows::get_pending_manual_downloads(
&created.instance.id,
&state.pool,
)
.await
.unwrap()
.len(),
1
);
let selected = source_directory.path().join("renamed-local-file.jar");
crate::util::io::write(&selected, bytes).await.unwrap();
let relative_path = import_pending_manual_download_file(
&created.instance.id,
123,
456,
selected.clone(),
)
.await
.unwrap()
.relative_path;
let snapshot =
crate::api::instance::get_content_snapshot(&created.instance.id)
.await
.unwrap();
let imported = snapshot
.items
.iter()
.find(|item| {
item.provider_project_id.as_deref() == Some("123")
&& item.provider_release_id.as_deref() == Some("456")
})
.unwrap();
assert!(snapshot.pending_manual_downloads.is_empty());
assert_eq!(
imported.ownership_kind,
crate::state::instances::ContentOwnershipKind::PackManaged
);
assert_eq!(
imported.materialization_state,
crate::state::instances::PackMemberMaterializationState::Present
);
assert_eq!(imported.expected_relative_path, relative_path);
let instance_path = state
.directories
.instance_game_dir(&created.instance)
.join(relative_path);
assert_eq!(crate::util::io::read(&instance_path).await.unwrap(), bytes);
assert!(source.exists());
assert!(selected.exists());
crate::util::io::write(&selected, b"mutated selected source")
.await
.unwrap();
assert_eq!(crate::util::io::read(instance_path).await.unwrap(), bytes);
}
#[test]
fn archive_paths_stay_inside_the_instance() {
assert_eq!(
safe_archive_relative_path("config/example.toml").unwrap(),
"config/example.toml"
);
assert!(safe_archive_relative_path("../options.txt").is_err());
assert!(safe_archive_relative_path("/options.txt").is_err());
}
#[test]
fn curseforge_override_content_joins_the_pack_group() {
assert_eq!(
curseforge_override_content("mods/cc-tweaked.jar").unwrap(),
Some(CurseForgePackExpectedOverride {
project_type: ProjectType::Mod,
expected_relative_path: "mods/cc-tweaked.jar".to_string(),
})
);
assert_eq!(
curseforge_override_content("datapacks/sawmill.zip").unwrap(),
Some(CurseForgePackExpectedOverride {
project_type: ProjectType::DataPack,
expected_relative_path: "datapacks/sawmill.zip".to_string(),
})
);
assert_eq!(
curseforge_override_content("config/example.toml").unwrap(),
None
);
}
#[test]
fn cdn_urls_are_restricted_to_forgecdn() {
assert!(
validate_cdn_url(
&reqwest::Url::parse(
"https://edge.forgecdn.net/files/1/2/a.jar"
)
.unwrap()
)
.is_ok()
);
assert!(
validate_cdn_url(
&reqwest::Url::parse("https://forgecdn.net.evil.test/a.jar")
.unwrap()
)
.is_err()
);
}
#[test]
fn blank_download_urls_are_treated_as_unavailable() {
assert_eq!(normalized_download_url(None), None);
assert_eq!(normalized_download_url(Some(String::new())), None);
assert_eq!(normalized_download_url(Some(" \t".to_string())), None);
assert_eq!(
normalized_download_url(Some(
" https://edge.forgecdn.net/files/1/2/a.jar ".to_string(),
)),
Some("https://edge.forgecdn.net/files/1/2/a.jar".to_string()),
);
}
#[test]
fn derived_download_urls_follow_curseforge_file_id_layout() {
assert_eq!(
derived_curseforge_download_url(
6_767_951,
"jei 1.21.5+neoforge.jar"
)
.unwrap(),
"https://edge.forgecdn.net/files/6767/951/jei%201.21.5%2Bneoforge.jar"
);
assert_eq!(
derived_curseforge_download_url(1_234_005, "example.jar").unwrap(),
"https://edge.forgecdn.net/files/1234/5/example.jar"
);
assert!(
derived_curseforge_download_url(1_234_005, "../example.jar")
.is_err()
);
}
#[test]
fn curseforge_loader_ids_map_to_instance_loaders() {
assert_eq!(loader_family("forge-47.4.0"), "forge");
assert_eq!(loader_family("fabric-0.16.10"), "fabric");
assert_eq!(loader_type("cleanroom"), Some(1));
assert_eq!(loader_type("neoforge"), Some(6));
}
#[test]
fn modpack_target_uses_primary_forge_loader_and_version() {
let manifest = CurseForgeModpackManifest {
minecraft: CurseForgeManifestMinecraft {
version: "1.12.2".to_string(),
mod_loaders: vec![CurseForgeManifestLoader {
id: "forge-14.23.5.2860".to_string(),
primary: true,
}],
},
files: Vec::new(),
overrides: "overrides".to_string(),
name: None,
version: None,
};
assert_eq!(
modpack_target(&manifest).unwrap(),
CurseForgeModpackTarget {
game_version: "1.12.2".to_string(),
loader: ModLoader::Forge,
loader_version: Some("14.23.5.2860".to_string()),
}
);
}
#[test]
fn modpack_target_recognizes_pcl_cleanroom_version_convention() {
let manifest = CurseForgeModpackManifest {
minecraft: CurseForgeManifestMinecraft {
version: "1.12.2".to_string(),
mod_loaders: vec![CurseForgeManifestLoader {
id: "forge-0.3.0-alpha".to_string(),
primary: true,
}],
},
files: Vec::new(),
overrides: "overrides".to_string(),
name: None,
version: None,
};
assert_eq!(
modpack_target(&manifest).unwrap(),
CurseForgeModpackTarget {
game_version: "1.12.2".to_string(),
loader: ModLoader::Cleanroom,
loader_version: Some("0.3.0-alpha".to_string()),
}
);
}
#[test]
fn modpack_target_without_loader_is_vanilla() {
let manifest = CurseForgeModpackManifest {
minecraft: CurseForgeManifestMinecraft {
version: "1.20.1".to_string(),
mod_loaders: Vec::new(),
},
files: Vec::new(),
overrides: "overrides".to_string(),
name: None,
version: None,
};
assert_eq!(
modpack_target(&manifest).unwrap(),
CurseForgeModpackTarget {
game_version: "1.20.1".to_string(),
loader: ModLoader::Vanilla,
loader_version: None,
}
);
}
#[test]
fn recognized_project_types_map_curseforge_classes() {
assert_eq!(recognized_project_type(Some(6)), Some(ProjectType::Mod));
assert_eq!(
recognized_project_type(Some(12)),
Some(ProjectType::ResourcePack),
);
assert_eq!(
recognized_project_type(Some(6552)),
Some(ProjectType::ShaderPack),
);
assert_eq!(
recognized_project_type(Some(6945)),
Some(ProjectType::DataPack),
);
assert_eq!(
recognized_project_type(Some(17)),
Some(ProjectType::WorldSave),
);
assert_eq!(recognized_project_type(Some(4471)), None);
assert_eq!(recognized_project_type(None), None);
}
#[test]
fn world_archives_must_use_a_zip_file_name() {
assert!(validate_world_archive_name("world.zip").is_ok());
assert!(validate_world_archive_name("WORLD.ZIP").is_ok());
assert!(validate_world_archive_name("world.rar").is_err());
assert!(validate_world_archive_name("../world.zip").is_err());
}
}