You've already forked AstralRinth
forked from didirus/AstralRinth
Implement Curseforge profile codes
This commit is contained in:
604
packages/app-lib/src/api/pack/import/curseforge_profile.rs
Normal file
604
packages/app-lib/src/api/pack/import/curseforge_profile.rs
Normal file
@@ -0,0 +1,604 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use async_zip::base::read::seek::ZipFileReader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
State,
|
||||
event::{LoadingBarType, ProfilePayloadType},
|
||||
prelude::ModLoader,
|
||||
state::{LinkedData, ProfileInstallStage},
|
||||
util::fetch::fetch,
|
||||
};
|
||||
|
||||
use super::copy_dotminecraft;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurseForgeManifest {
|
||||
pub minecraft: CurseForgeMinecraft,
|
||||
pub manifest_type: String,
|
||||
pub manifest_version: i32,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub author: String,
|
||||
pub files: Vec<CurseForgeFile>,
|
||||
pub overrides: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurseForgeMinecraft {
|
||||
pub version: String,
|
||||
pub mod_loaders: Vec<CurseForgeModLoader>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurseForgeModLoader {
|
||||
pub id: String,
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct CurseForgeFile {
|
||||
#[serde(rename = "projectID")]
|
||||
pub project_id: u32,
|
||||
#[serde(rename = "fileID")]
|
||||
pub file_id: u32,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct CurseForgeProfileMetadata {
|
||||
pub name: String,
|
||||
pub download_url: String,
|
||||
}
|
||||
|
||||
/// Fetch CurseForge profile metadata from profile code
|
||||
pub async fn fetch_curseforge_profile_metadata(
|
||||
profile_code: &str,
|
||||
) -> crate::Result<CurseForgeProfileMetadata> {
|
||||
let state = State::get().await?;
|
||||
|
||||
// Make initial request to get redirect URL
|
||||
let url = format!(
|
||||
"https://api.curseforge.com/v1/shared-profile/{}",
|
||||
profile_code
|
||||
);
|
||||
|
||||
// Try to fetch the profile - the CurseForge API should redirect to the ZIP file
|
||||
let response = fetch(&url, None, &state.fetch_semaphore, &state.pool).await;
|
||||
|
||||
let download_url = match response {
|
||||
Ok(_bytes) => {
|
||||
// If we get bytes back, use the original URL
|
||||
url
|
||||
}
|
||||
Err(e) => {
|
||||
// If we get an error, it might contain redirect information
|
||||
let error_msg = format!("{:?}", e);
|
||||
if let Some(redirect_start) =
|
||||
error_msg.find("https://shared-profile-media.forgecdn.net/")
|
||||
{
|
||||
let redirect_end = error_msg[redirect_start..]
|
||||
.find(' ')
|
||||
.unwrap_or(error_msg.len() - redirect_start);
|
||||
error_msg[redirect_start..redirect_start + redirect_end]
|
||||
.to_string()
|
||||
} else {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Failed to fetch CurseForge profile metadata: {}",
|
||||
e
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Now fetch the ZIP file and extract the name from manifest.json
|
||||
let zip_bytes =
|
||||
fetch(&download_url, None, &state.fetch_semaphore, &state.pool).await?;
|
||||
|
||||
// Create a cursor for the ZIP data
|
||||
let cursor = std::io::Cursor::new(zip_bytes);
|
||||
let mut zip_reader =
|
||||
ZipFileReader::with_tokio(cursor).await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read profile ZIP: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Find and extract manifest.json
|
||||
let manifest_index = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == "manifest.json"
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"No manifest.json found in profile".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut manifest_content = String::new();
|
||||
let mut reader = zip_reader
|
||||
.reader_with_entry(manifest_index)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read manifest.json: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
reader.read_to_string_checked(&mut manifest_content).await?;
|
||||
|
||||
// Parse the manifest to get the actual name
|
||||
let manifest: CurseForgeManifest = serde_json::from_str(&manifest_content)?;
|
||||
|
||||
let profile_name = if manifest.name.is_empty() {
|
||||
format!("CurseForge Profile {}", profile_code)
|
||||
} else {
|
||||
manifest.name.clone()
|
||||
};
|
||||
|
||||
Ok(CurseForgeProfileMetadata {
|
||||
name: profile_name,
|
||||
download_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Import a CurseForge profile from profile code
|
||||
pub async fn import_curseforge_profile(
|
||||
profile_code: &str,
|
||||
profile_path: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
|
||||
// Initialize loading bar
|
||||
let loading_bar = crate::event::emit::init_loading(
|
||||
LoadingBarType::CurseForgeProfileDownload {
|
||||
profile_name: profile_path.to_string(),
|
||||
},
|
||||
100.0,
|
||||
"Importing CurseForge profile...",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// First, fetch the profile metadata to get the download URL
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
10.0,
|
||||
Some("Fetching profile metadata..."),
|
||||
)?;
|
||||
let metadata = fetch_curseforge_profile_metadata(profile_code).await?;
|
||||
|
||||
// Download the profile ZIP file
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Downloading profile ZIP..."),
|
||||
)?;
|
||||
let zip_bytes = fetch(
|
||||
&metadata.download_url,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create a cursor for the ZIP data
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Extracting ZIP contents..."),
|
||||
)?;
|
||||
let cursor = Cursor::new(zip_bytes);
|
||||
let mut zip_reader =
|
||||
ZipFileReader::with_tokio(cursor).await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read profile ZIP: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Find and extract manifest.json
|
||||
let manifest_index = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == "manifest.json"
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"No manifest.json found in profile".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut manifest_content = String::new();
|
||||
let mut reader = zip_reader
|
||||
.reader_with_entry(manifest_index)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read manifest.json: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
reader.read_to_string_checked(&mut manifest_content).await?;
|
||||
|
||||
// Parse the manifest
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Parsing profile manifest..."),
|
||||
)?;
|
||||
let manifest: CurseForgeManifest = serde_json::from_str(&manifest_content)?;
|
||||
|
||||
// Determine modloader and version
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Configuring profile..."),
|
||||
)?;
|
||||
let (mod_loader, loader_version) = if let Some(primary_loader) =
|
||||
manifest.minecraft.mod_loaders.iter().find(|l| l.primary)
|
||||
{
|
||||
parse_modloader(&primary_loader.id)
|
||||
} else if let Some(first_loader) = manifest.minecraft.mod_loaders.first() {
|
||||
parse_modloader(&first_loader.id)
|
||||
} else {
|
||||
(ModLoader::Vanilla, None)
|
||||
};
|
||||
|
||||
let game_version = manifest.minecraft.version.clone();
|
||||
|
||||
// Get appropriate loader version if needed
|
||||
let final_loader_version = if mod_loader != ModLoader::Vanilla {
|
||||
crate::launcher::get_loader_version_from_profile(
|
||||
&game_version,
|
||||
mod_loader,
|
||||
loader_version.as_deref(),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Set profile data
|
||||
crate::api::profile::edit(profile_path, |prof| {
|
||||
prof.name = if manifest.name.is_empty() {
|
||||
format!("CurseForge Profile {}", profile_code)
|
||||
} else {
|
||||
manifest.name.clone()
|
||||
};
|
||||
prof.install_stage = ProfileInstallStage::PackInstalling;
|
||||
prof.game_version = game_version.clone();
|
||||
prof.loader_version = final_loader_version.clone().map(|x| x.id);
|
||||
prof.loader = mod_loader;
|
||||
|
||||
// Set linked data for modpack management
|
||||
prof.linked_data = Some(LinkedData {
|
||||
project_id: String::new(),
|
||||
version_id: String::new(),
|
||||
locked: false,
|
||||
});
|
||||
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Create a temporary directory to extract overrides
|
||||
let temp_dir = state
|
||||
.directories
|
||||
.caches_dir()
|
||||
.join(format!("curseforge_profile_{}", profile_code));
|
||||
tokio::fs::create_dir_all(&temp_dir).await?;
|
||||
|
||||
// Extract overrides directory if it exists
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
10.0,
|
||||
Some("Extracting profile files..."),
|
||||
)?;
|
||||
let overrides_dir = temp_dir.join(&manifest.overrides);
|
||||
tokio::fs::create_dir_all(&overrides_dir).await?;
|
||||
|
||||
// Extract all files that are in the overrides directory
|
||||
// First collect the entries we need to extract to avoid borrowing conflicts
|
||||
let entries_to_extract: Vec<(usize, String)> = {
|
||||
let zip_file = zip_reader.file();
|
||||
zip_file
|
||||
.entries()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, entry)| {
|
||||
let file_path = entry.filename().as_str().unwrap_or_default();
|
||||
if file_path.starts_with(&format!("{}/", manifest.overrides)) {
|
||||
Some((index, file_path.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Now extract each file
|
||||
for (index, file_path) in entries_to_extract {
|
||||
let relative_path = file_path
|
||||
.strip_prefix(&format!("{}/", manifest.overrides))
|
||||
.unwrap();
|
||||
let output_path = overrides_dir.join(relative_path);
|
||||
|
||||
// Create parent directories
|
||||
if let Some(parent) = output_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
// Extract file
|
||||
let mut reader =
|
||||
zip_reader.reader_with_entry(index).await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read file {}: {}",
|
||||
file_path, e
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut file_content = Vec::new();
|
||||
reader.read_to_end_checked(&mut file_content).await?;
|
||||
|
||||
tokio::fs::write(&output_path, file_content).await?;
|
||||
}
|
||||
|
||||
// Copy overrides to profile
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Copying profile files..."),
|
||||
)?;
|
||||
let _loading_bar = copy_dotminecraft(
|
||||
profile_path,
|
||||
overrides_dir,
|
||||
&state.io_semaphore,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Download and install mods from CurseForge
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
10.0,
|
||||
Some("Downloading mods..."),
|
||||
)?;
|
||||
install_curseforge_mods(
|
||||
&manifest.files,
|
||||
profile_path,
|
||||
&state,
|
||||
&loading_bar,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Clean up temporary directory
|
||||
tokio::fs::remove_dir_all(&temp_dir).await.ok();
|
||||
|
||||
// Install Minecraft if needed
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
20.0,
|
||||
Some("Installing Minecraft..."),
|
||||
)?;
|
||||
if let Some(profile_val) = crate::api::profile::get(profile_path).await? {
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(_loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Mark the profile as fully installed
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
20.0,
|
||||
Some("Finalizing profile..."),
|
||||
)?;
|
||||
crate::api::profile::edit(profile_path, |prof| {
|
||||
prof.install_stage = ProfileInstallStage::Installed;
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Emit profile sync event to trigger file system watcher refresh
|
||||
crate::event::emit::emit_profile(profile_path, ProfilePayloadType::Synced)
|
||||
.await?;
|
||||
|
||||
// Complete the loading bar
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Import completed!"),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse CurseForge modloader ID into ModLoader and version
|
||||
fn parse_modloader(id: &str) -> (ModLoader, Option<String>) {
|
||||
if id.starts_with("forge-") {
|
||||
let version = id.strip_prefix("forge-").unwrap_or("").to_string();
|
||||
(ModLoader::Forge, Some(version))
|
||||
} else if id.starts_with("fabric-") {
|
||||
let version = id.strip_prefix("fabric-").unwrap_or("").to_string();
|
||||
(ModLoader::Fabric, Some(version))
|
||||
} else if id.starts_with("quilt-") {
|
||||
let version = id.strip_prefix("quilt-").unwrap_or("").to_string();
|
||||
(ModLoader::Quilt, Some(version))
|
||||
} else if id.starts_with("neoforge-") {
|
||||
let version = id.strip_prefix("neoforge-").unwrap_or("").to_string();
|
||||
(ModLoader::NeoForge, Some(version))
|
||||
} else {
|
||||
(ModLoader::Vanilla, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Install mods from CurseForge files list
|
||||
async fn install_curseforge_mods(
|
||||
files: &[CurseForgeFile],
|
||||
profile_path: &str,
|
||||
state: &State,
|
||||
loading_bar: &crate::event::LoadingBarId,
|
||||
) -> crate::Result<()> {
|
||||
if files.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let num_files = files.len();
|
||||
tracing::info!("Installing {} CurseForge mods", num_files);
|
||||
|
||||
// Download mods sequentially to track progress properly
|
||||
for (index, file) in files.iter().enumerate() {
|
||||
// Update progress message with current mod
|
||||
let progress_message =
|
||||
format!("Downloading mod {} of {}", index + 1, num_files);
|
||||
crate::event::emit::emit_loading(
|
||||
loading_bar,
|
||||
0.0, // Don't increment here, just update message
|
||||
Some(&progress_message),
|
||||
)?;
|
||||
|
||||
download_curseforge_mod(file, profile_path, state).await?;
|
||||
|
||||
// Emit progress for each downloaded mod (20% total for mods, divided by number of mods)
|
||||
let mod_progress = 20.0 / num_files as f64;
|
||||
crate::event::emit::emit_loading(
|
||||
loading_bar,
|
||||
mod_progress,
|
||||
Some(&format!("Downloaded mod {} of {}", index + 1, num_files)),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a single mod from CurseForge
|
||||
async fn download_curseforge_mod(
|
||||
file: &CurseForgeFile,
|
||||
profile_path: &str,
|
||||
_state: &State,
|
||||
) -> crate::Result<()> {
|
||||
// Log the download attempt
|
||||
tracing::info!(
|
||||
"Downloading CurseForge mod: project_id={}, file_id={}",
|
||||
file.project_id,
|
||||
file.file_id
|
||||
);
|
||||
|
||||
// Get profile path and create mods directory first
|
||||
let profile_full_path =
|
||||
crate::api::profile::get_full_path(profile_path).await?;
|
||||
let mods_dir = profile_full_path.join("mods");
|
||||
tokio::fs::create_dir_all(&mods_dir).await?;
|
||||
|
||||
// First, get the file metadata to get the correct filename
|
||||
let metadata_url = format!(
|
||||
"https://www.curseforge.com/api/v1/mods/{}/files/{}",
|
||||
file.project_id, file.file_id
|
||||
);
|
||||
|
||||
tracing::info!("Fetching metadata from: {}", metadata_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let metadata_response =
|
||||
client.get(&metadata_url).send().await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to fetch metadata for mod {}/{}: {}",
|
||||
file.project_id, file.file_id, e
|
||||
))
|
||||
})?;
|
||||
|
||||
if !metadata_response.status().is_success() {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"HTTP error fetching metadata for mod {}/{}: {}",
|
||||
file.project_id,
|
||||
file.file_id,
|
||||
metadata_response.status()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
// Parse the metadata JSON to get the filename
|
||||
let metadata_json: serde_json::Value =
|
||||
metadata_response.json().await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to parse metadata JSON for mod {}/{}: {}",
|
||||
file.project_id, file.file_id, e
|
||||
))
|
||||
})?;
|
||||
|
||||
let original_filename = metadata_json
|
||||
.get("data")
|
||||
.and_then(|data| data.get("fileName"))
|
||||
.and_then(|name| name.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback to the old format if API response is unexpected
|
||||
format!("mod_{}_{}.jar", file.project_id, file.file_id)
|
||||
});
|
||||
|
||||
tracing::info!("Original filename: {}", original_filename);
|
||||
|
||||
// Now download the mod using the direct download URL
|
||||
let download_url = format!(
|
||||
"https://www.curseforge.com/api/v1/mods/{}/files/{}/download",
|
||||
file.project_id, file.file_id
|
||||
);
|
||||
|
||||
tracing::info!("Downloading from: {}", download_url);
|
||||
|
||||
let response = client.get(&download_url).send().await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to download mod {}/{}: {}",
|
||||
file.project_id, file.file_id, e
|
||||
))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"HTTP error downloading mod {}/{}: {}",
|
||||
file.project_id,
|
||||
file.file_id,
|
||||
response.status()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
// Write the file with its original name
|
||||
let final_path = mods_dir.join(&original_filename);
|
||||
let bytes = response.bytes().await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read response bytes for mod {}/{}: {}",
|
||||
file.project_id, file.file_id, e
|
||||
))
|
||||
})?;
|
||||
|
||||
tokio::fs::write(&final_path, &bytes).await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to write mod file {:?}: {}",
|
||||
final_path, e
|
||||
))
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"Successfully downloaded mod: {} ({} bytes)",
|
||||
original_filename,
|
||||
bytes.len()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use crate::{
|
||||
|
||||
pub mod atlauncher;
|
||||
pub mod curseforge;
|
||||
pub mod curseforge_profile;
|
||||
pub mod gdlauncher;
|
||||
pub mod mmc;
|
||||
|
||||
|
||||
@@ -176,6 +176,9 @@ pub enum LoadingBarType {
|
||||
import_location: PathBuf,
|
||||
profile_name: String,
|
||||
},
|
||||
CurseForgeProfileDownload {
|
||||
profile_name: String,
|
||||
},
|
||||
CheckingForUpdates,
|
||||
LauncherUpdate {
|
||||
version: String,
|
||||
|
||||
Reference in New Issue
Block a user