Files
Modrinth-plus/packages/app-lib/src/api/pack/install_from.rs
aecsocket e8dc3c3150 Add update download reason to analytics (#6023)
* Add  download reason to analytics

* mark modpack updates as actual updates in analytics

* fmt
2026-05-07 13:07:20 +00:00

505 lines
15 KiB
Rust

use crate::State;
use crate::api::profile;
use crate::data::ModLoader;
use crate::event::emit::{emit_loading, init_loading};
use crate::event::{LoadingBarId, LoadingBarType};
use crate::state::{
CacheBehaviour, CachedEntry, LinkedData, Profile, ProfileInstallStage,
SideType,
};
use crate::util::fetch::{
DownloadMeta, DownloadReason, fetch, fetch_advanced, write_cached_icon,
};
use crate::util::io;
use path_util::SafeRelativeUtf8UnixPathBuf;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PackFormat {
pub game: String,
pub format_version: i32,
pub version_id: String,
pub name: String,
pub summary: Option<String>,
pub files: Vec<PackFile>,
pub dependencies: HashMap<PackDependency, String>,
}
#[derive(Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PackFile {
pub path: SafeRelativeUtf8UnixPathBuf,
pub hashes: HashMap<PackFileHash, String>,
pub env: Option<HashMap<EnvType, SideType>>,
pub downloads: Vec<String>,
pub file_size: u32,
}
#[derive(Serialize, Deserialize, Eq, PartialEq, Hash)]
#[serde(rename_all = "camelCase", from = "String")]
pub enum PackFileHash {
Sha1,
Sha512,
Unknown(String),
}
impl From<String> for PackFileHash {
fn from(s: String) -> Self {
match s.as_str() {
"sha1" => PackFileHash::Sha1,
"sha512" => PackFileHash::Sha512,
_ => PackFileHash::Unknown(s),
}
}
}
#[derive(Serialize, Deserialize, Eq, PartialEq, Hash)]
#[serde(rename_all = "camelCase")]
pub enum EnvType {
Client,
Server,
}
#[derive(Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq, Debug)]
pub enum PackDependency {
#[serde(rename = "forge")]
Forge,
#[serde(rename = "neoforge")]
#[serde(alias = "neo-forge")]
NeoForge,
#[serde(rename = "fabric-loader")]
FabricLoader,
#[serde(rename = "quilt-loader")]
QuiltLoader,
#[serde(rename = "minecraft")]
Minecraft,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum CreatePackLocation {
// Create a pack from a modrinth version ID (such as a modpack)
FromVersionId {
project_id: String,
version_id: String,
title: String,
icon_url: Option<String>,
},
// Create a pack from a file (such as an .mrpack for installing from a file, or a folder name for importing)
FromFile {
path: PathBuf,
},
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatePackProfile {
pub name: String, // the name of the profile, and relative path
pub game_version: String, // the game version of the profile
pub modloader: ModLoader, // the modloader to use
pub loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader. defaults to latest
pub icon: Option<PathBuf>, // the icon for the profile
pub icon_url: Option<String>, // the URL icon for a profile (ONLY USED FOR TEMPORARY PROFILES)
pub linked_data: Option<LinkedData>, // the linked project ID (mainly for modpacks)- used for updating
pub unknown_file: bool, // true when pack file isn't found on Modrinth via hash lookup
pub skip_install_profile: Option<bool>,
pub no_watch: Option<bool>,
}
// default
impl Default for CreatePackProfile {
fn default() -> Self {
CreatePackProfile {
name: "Untitled".to_string(),
game_version: "1.19.4".to_string(),
modloader: ModLoader::Vanilla,
loader_version: None,
icon: None,
icon_url: None,
linked_data: None,
unknown_file: false,
skip_install_profile: Some(true),
no_watch: Some(false),
}
}
}
#[derive(Clone)]
pub struct CreatePack {
pub file: bytes::Bytes,
pub description: CreatePackDescription,
}
#[derive(Clone, Debug)]
pub struct CreatePackDescription {
pub icon: Option<PathBuf>,
pub override_title: Option<String>,
pub project_id: Option<String>,
pub version_id: Option<String>,
pub existing_loading_bar: Option<LoadingBarId>,
pub profile_path: String,
}
pub async fn get_profile_from_pack(
location: CreatePackLocation,
) -> crate::Result<CreatePackProfile> {
match location {
CreatePackLocation::FromVersionId {
project_id,
version_id,
title,
icon_url,
} => Ok(CreatePackProfile {
name: title,
icon_url,
linked_data: Some(LinkedData {
project_id,
version_id,
locked: true,
}),
..Default::default()
}),
CreatePackLocation::FromFile { path } => {
let file_name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let state = State::get().await?;
let file_bytes = io::read(&path).await?;
let hash =
crate::util::fetch::sha1_async(bytes::Bytes::from(file_bytes))
.await?;
let is_known_file = match CachedEntry::get_file_many(
&[&hash],
Some(CacheBehaviour::StaleWhileRevalidateSkipOffline),
&state.pool,
&state.api_semaphore,
)
.await
{
Ok(files) => !files.is_empty(),
Err(err) => {
tracing::warn!(
"Failed to check Modrinth file hash for {}: {}",
path.display(),
err
);
false
}
};
Ok(CreatePackProfile {
name: file_name,
unknown_file: !is_known_file,
..Default::default()
})
}
}
}
#[tracing::instrument]
pub async fn generate_pack_from_version_id(
project_id: String,
version_id: String,
title: String,
icon_url: Option<String>,
profile_path: String,
initialized_loading_bar: Option<LoadingBarId>,
reason: DownloadReason,
) -> crate::Result<CreatePack> {
let state = State::get().await?;
let has_icon_url = icon_url.is_some();
let loading_bar = if let Some(bar) = initialized_loading_bar {
emit_loading(&bar, 0.0, Some("Downloading pack file"))?;
bar
} else {
init_loading(
LoadingBarType::PackFileDownload {
profile_path: profile_path.clone(),
pack_name: title.clone(),
icon: icon_url,
pack_version: version_id.clone(),
},
100.0,
"Downloading pack file",
)
.await?
};
emit_loading(&loading_bar, 0.0, Some("Fetching version"))?;
let version = CachedEntry::get_version(
&version_id,
Some(CacheBehaviour::Bypass),
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Invalid version ID specified!".to_string(),
)
})?;
emit_loading(&loading_bar, 10.0, None)?;
// Update profile with correct loader and game version from the API version metadata,
// so the UI shows accurate info while the pack file is still downloading.
if let Some(game_version) = version.game_versions.first() {
let loader = version
.loaders
.first()
.map(|l| ModLoader::from_string(l))
.unwrap_or(ModLoader::Vanilla);
let game_version = game_version.clone();
let profile_path_clone = profile_path.clone();
profile::edit(&profile_path_clone, |prof| {
prof.game_version.clone_from(&game_version);
prof.loader = loader;
async { Ok(()) }
})
.await?;
}
let (url, hash) =
if let Some(file) = version.files.iter().find(|x| x.primary) {
Some((file.url.clone(), file.hashes.get("sha1")))
} else {
version
.files
.first()
.map(|file| (file.url.clone(), file.hashes.get("sha1")))
}
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Specified version has no files".to_string(),
)
})?;
let profile =
Profile::get(&profile_path, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::UnmanagedProfileError(
profile_path.to_string(),
)
.as_error()
})?;
let download_meta = DownloadMeta {
reason,
game_version: profile.game_version.clone(),
loader: profile.loader.as_str().to_string(),
};
let file = fetch_advanced(
Method::GET,
&url,
hash.map(|x| &**x),
None,
None,
Some(&download_meta),
Some((&loading_bar, 70.0)),
&state.fetch_semaphore,
&state.pool,
)
.await?;
emit_loading(&loading_bar, 0.0, Some("Fetching project metadata"))?;
let project = CachedEntry::get_project(
&version.project_id,
None,
&state.pool,
&state.api_semaphore,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Invalid project ID specified!".to_string(),
)
})?;
// Only fetch the pack icon when icon_url is provided (new profile).
// When installing to an existing profile (e.g. server projects),
// icon_url is None and we preserve the profile's existing icon.
let icon = if has_icon_url {
emit_loading(&loading_bar, 10.0, Some("Retrieving icon"))?;
let fetched = if let Some(icon_url) = project.icon_url {
let state = State::get().await?;
let icon_bytes = fetch(
&icon_url,
None,
None,
&state.fetch_semaphore,
&state.pool,
)
.await?;
let filename = icon_url.rsplit('/').next();
if let Some(filename) = filename {
Some(
write_cached_icon(
filename,
&state.directories.caches_dir(),
icon_bytes,
&state.io_semaphore,
)
.await?,
)
} else {
None
}
} else {
None
};
emit_loading(&loading_bar, 10.0, None)?;
fetched
} else {
emit_loading(&loading_bar, 20.0, None)?;
None
};
// Set the icon immediately so the UI shows it during download.
if let Some(ref icon_path) = icon {
let _ =
profile::edit_icon(&profile_path, Some(icon_path.as_path())).await;
}
Ok(CreatePack {
file,
description: CreatePackDescription {
icon,
override_title: Some(title),
project_id: Some(project_id),
version_id: Some(version_id),
existing_loading_bar: Some(loading_bar),
profile_path,
},
})
}
#[tracing::instrument]
pub async fn generate_pack_from_file(
path: PathBuf,
profile_path: String,
) -> crate::Result<CreatePack> {
let file = io::read(&path).await?;
Ok(CreatePack {
file: bytes::Bytes::from(file),
description: CreatePackDescription {
icon: None,
override_title: None,
project_id: None,
version_id: None,
existing_loading_bar: None,
profile_path,
},
})
}
/// Sets generated profile attributes to the pack ones (using profile::edit)
/// This includes the pack name, icon, game version, loader version, and loader
pub async fn set_profile_information(
profile_path: String,
description: &CreatePackDescription,
backup_name: &str,
dependencies: &HashMap<PackDependency, String>,
ignore_lock: bool, // do not change locked status
) -> crate::Result<()> {
let mut game_version: Option<&String> = None;
let mut mod_loader = None;
let mut loader_version = None;
for (key, value) in dependencies {
match key {
PackDependency::Forge => {
mod_loader = Some(ModLoader::Forge);
loader_version = Some(value);
}
PackDependency::NeoForge => {
mod_loader = Some(ModLoader::NeoForge);
loader_version = Some(value);
}
PackDependency::FabricLoader => {
mod_loader = Some(ModLoader::Fabric);
loader_version = Some(value);
}
PackDependency::QuiltLoader => {
mod_loader = Some(ModLoader::Quilt);
loader_version = Some(value);
}
PackDependency::Minecraft => game_version = Some(value),
}
}
let Some(game_version) = game_version else {
return Err(crate::ErrorKind::InputError(
"Pack did not specify Minecraft version".to_string(),
)
.into());
};
let mod_loader = mod_loader.unwrap_or(ModLoader::Vanilla);
let loader_version = if mod_loader != ModLoader::Vanilla {
crate::launcher::get_loader_version_from_profile(
game_version,
mod_loader,
loader_version.cloned().as_deref(),
)
.await?
} else {
None
};
// Sets values in profile
crate::api::profile::edit(&profile_path, |prof| {
prof.name = description
.override_title
.clone()
.unwrap_or_else(|| backup_name.to_string());
prof.install_stage = ProfileInstallStage::PackInstalling;
if let Some(ref project_id) = description.project_id
&& let Some(ref version_id) = description.version_id
{
prof.linked_data = Some(LinkedData {
project_id: project_id.clone(),
version_id: version_id.clone(),
locked: if !ignore_lock {
true
} else {
prof.linked_data.as_ref().is_none_or(|x| x.locked)
},
})
}
// Only update the icon if the pack provides one.
// When installing to an existing profile, icon is None
// and we preserve the profile's existing icon.
if let Some(ref icon) = description.icon {
prof.icon_path = Some(icon.to_string_lossy().to_string());
}
prof.game_version.clone_from(game_version);
prof.loader_version = loader_version.clone().map(|x| x.id);
prof.loader = mod_loader;
async { Ok(()) }
})
.await?;
Ok(())
}