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

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

View File

@ -0,0 +1,510 @@
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use crate::model::{
ContentType, Dependency, DependencyType, Error, ResolutionPreferences,
ResolveContentPlan, ResolveContentRequest, ResolvedContent, SkippedContent,
SkippedReason, Version,
};
use crate::provider::ContentMetadataProvider;
// Fabric API is replaced by Quilted Fabric API when the install target is
// quilt. See Prism Launcher's override table for the equivalent mapping.
const FABRIC_API_PROJECT_ID: &str = "P7dR8mSH";
const QUILTED_FABRIC_API_PROJECT_ID: &str = "qvIfYCYJ";
const IRIS_PROJECT_ID: &str = "YL57xq9U";
const SODIUM_PROJECT_ID: &str = "AANobbMI";
const MAX_DEPENDENCY_DEPTH: usize = 32;
// Some Modrinth versions omit required dependencies from their metadata even
// though the mod JAR itself declares them (e.g. several Iris releases omit
// Sodium). Correct those records locally so install plans match what the mod
// actually requires; entries can be removed once upstream metadata is fixed.
struct MissingDependencyCorrection {
project_id: &'static str,
version_id: &'static str,
dependency_project_id: &'static str,
}
const MISSING_DEPENDENCY_CORRECTIONS: &[MissingDependencyCorrection] = &[
MissingDependencyCorrection {
project_id: IRIS_PROJECT_ID,
version_id: "Cjwm9s3i", // Iris 1.6.14 for Minecraft 1.20.2
dependency_project_id: SODIUM_PROJECT_ID,
},
MissingDependencyCorrection {
project_id: IRIS_PROJECT_ID,
version_id: "G5dd9TM4", // Iris 1.7.1 for Minecraft 1.20.1
dependency_project_id: SODIUM_PROJECT_ID,
},
MissingDependencyCorrection {
project_id: IRIS_PROJECT_ID,
version_id: "keLlmlCc", // Iris 1.7.1 for Minecraft 1.20.5/1.20.6
dependency_project_id: SODIUM_PROJECT_ID,
},
MissingDependencyCorrection {
project_id: IRIS_PROJECT_ID,
version_id: "Kdz76qQt", // Iris 1.8.0-beta.3 for Fabric 1.21
dependency_project_id: SODIUM_PROJECT_ID,
},
MissingDependencyCorrection {
project_id: IRIS_PROJECT_ID,
version_id: "di7sM681", // Iris 1.8.0-beta.2 for Fabric 1.21
dependency_project_id: SODIUM_PROJECT_ID,
},
];
pub async fn resolve_content<P: ContentMetadataProvider>(
mut provider: P,
request: ResolveContentRequest,
) -> Result<ResolveContentPlan, Error> {
let primary_version =
resolve_primary_version(&mut provider, &request).await?;
let primary = ResolvedContent {
project_id: primary_version.project_id.clone(),
version_id: primary_version.id.clone(),
dependent_on_version_id: None,
required: true,
};
let mut resolver = InstallResolver::new(provider, &request);
resolver
.resolve_dependencies_for_version(primary_version)
.await?;
Ok(ResolveContentPlan {
primary,
dependencies: resolver.dependencies,
skipped: resolver.skipped,
})
}
/// Resolves exact versions by identity and applies target filters only when
/// choosing a version automatically.
async fn resolve_primary_version<P: ContentMetadataProvider>(
provider: &mut P,
request: &ResolveContentRequest,
) -> Result<Version, Error> {
if let Some(version_id) = &request.version_id {
let version = provider
.get_version(version_id)
.await?
.ok_or_else(|| Error::VersionNotFound(version_id.clone()))?;
if version.project_id != request.project_id {
return Err(Error::VersionProjectMismatch {
version_id: version.id,
project_id: request.project_id.clone(),
});
}
return Ok(version);
}
let versions = provider.get_project_versions(&request.project_id).await?;
if versions.is_empty() {
return Err(Error::ProjectNotFound(request.project_id.clone()));
}
select_newest_matching_version(
versions,
request.content_type,
&request.selected,
&request.target,
)
.ok_or_else(|| Error::NoCompatibleVersion(request.project_id.clone()))
}
struct InstallResolver<'a, P> {
provider: P,
content_type: ContentType,
selected: &'a ResolutionPreferences,
target: &'a ResolutionPreferences,
existing_project_ids: HashSet<String>,
force_project_ids: HashSet<String>,
excluded_project_ids: HashSet<String>,
planned_project_versions: HashMap<String, String>,
visited_versions: HashSet<String>,
dependencies: Vec<ResolvedContent>,
skipped: Vec<SkippedContent>,
}
impl<'a, P: ContentMetadataProvider> InstallResolver<'a, P> {
fn new(provider: P, request: &'a ResolveContentRequest) -> Self {
let mut planned_project_versions = HashMap::new();
planned_project_versions.insert(
request.project_id.clone(),
request.version_id.clone().unwrap_or_default(),
);
Self {
provider,
content_type: request.content_type,
selected: &request.selected,
target: &request.target,
existing_project_ids: request
.existing_project_ids
.iter()
.cloned()
.collect(),
force_project_ids: request
.force_project_ids
.iter()
.cloned()
.collect(),
excluded_project_ids: request
.excluded_project_ids
.iter()
.cloned()
.collect(),
planned_project_versions,
visited_versions: HashSet::new(),
dependencies: Vec::new(),
skipped: Vec::new(),
}
}
async fn resolve_dependencies_for_version(
&mut self,
version: Version,
) -> Result<(), Error> {
let mut stack = vec![(version, 0_usize)];
while let Some((version, depth)) = stack.pop() {
if !self.visited_versions.insert(version.id.clone()) {
self.skipped.push(SkippedContent {
project_id: version.project_id,
version_id: Some(version.id),
dependent_on_version_id: None,
reason: SkippedReason::DependencyCycle,
});
continue;
}
if depth >= MAX_DEPENDENCY_DEPTH {
self.skipped.push(SkippedContent {
project_id: version.project_id,
version_id: Some(version.id),
dependent_on_version_id: None,
reason: SkippedReason::DependencyDepthExceeded,
});
continue;
}
let corrected_dependencies =
dependency_metadata_corrections(&version);
for original_dependency in version
.dependencies
.iter()
.chain(corrected_dependencies.iter())
{
if !matches!(
original_dependency.dependency_type,
DependencyType::Required | DependencyType::Optional
) {
continue;
}
let overridden_dependency;
let dependency = if should_use_quilted_fabric_api(
original_dependency,
self.target,
) {
overridden_dependency = Dependency {
project_id: Some(
QUILTED_FABRIC_API_PROJECT_ID.to_string(),
),
version_id: None,
file_name: original_dependency.file_name.clone(),
dependency_type: DependencyType::Required,
};
&overridden_dependency
} else {
original_dependency
};
let Some(dependency_version) =
self.resolve_dependency_version(dependency).await?
else {
continue;
};
let project_id = dependency
.project_id
.clone()
.unwrap_or_else(|| dependency_version.project_id.clone());
let required = matches!(
dependency.dependency_type,
DependencyType::Required
);
if self.excluded_project_ids.contains(&project_id) {
self.skipped.push(SkippedContent {
project_id,
version_id: Some(dependency_version.id),
dependent_on_version_id: Some(version.id.clone()),
reason: SkippedReason::ExcludedByUser,
});
continue;
}
if self.existing_project_ids.contains(&project_id)
&& !self.force_project_ids.contains(&project_id)
{
self.skipped.push(SkippedContent {
project_id,
version_id: Some(dependency_version.id),
dependent_on_version_id: Some(version.id.clone()),
reason: SkippedReason::AlreadyInstalled,
});
continue;
}
if let Some(planned_version_id) =
self.planned_project_versions.get(&project_id)
{
if required {
if let Some(existing) = self
.dependencies
.iter_mut()
.find(|existing| existing.project_id == project_id)
{
existing.required = true;
}
}
let reason = if planned_version_id.is_empty()
|| planned_version_id == &dependency_version.id
{
SkippedReason::DuplicateProject
} else {
SkippedReason::ConflictingDependency
};
self.skipped.push(SkippedContent {
project_id,
version_id: Some(dependency_version.id),
dependent_on_version_id: Some(version.id.clone()),
reason,
});
continue;
}
self.planned_project_versions
.insert(project_id.clone(), dependency_version.id.clone());
self.dependencies.push(ResolvedContent {
project_id,
version_id: dependency_version.id.clone(),
dependent_on_version_id: Some(version.id.clone()),
required,
});
stack.push((dependency_version, depth + 1));
}
}
Ok(())
}
async fn resolve_dependency_version(
&mut self,
dependency: &Dependency,
) -> Result<Option<Version>, Error> {
if let Some(version_id) = &dependency.version_id {
let version = self.provider.get_version(version_id).await?;
if version.is_none() {
self.skipped.push(SkippedContent {
project_id: dependency
.project_id
.clone()
.unwrap_or_default(),
version_id: Some(version_id.clone()),
dependent_on_version_id: None,
reason: SkippedReason::MissingVersion,
});
}
return Ok(version);
}
let Some(project_id) = &dependency.project_id else {
return Ok(None);
};
let versions = self.provider.get_project_versions(project_id).await?;
let version = select_newest_matching_version(
versions,
self.content_type,
self.selected,
self.target,
);
if version.is_none() {
self.skipped.push(SkippedContent {
project_id: project_id.clone(),
version_id: None,
dependent_on_version_id: None,
reason: SkippedReason::NoCompatibleVersion,
});
}
Ok(version)
}
}
fn dependency_metadata_corrections(version: &Version) -> Vec<Dependency> {
MISSING_DEPENDENCY_CORRECTIONS
.iter()
.filter(|correction| {
correction.project_id == version.project_id
&& correction.version_id == version.id.as_str()
})
.map(|correction| Dependency {
project_id: Some(correction.dependency_project_id.to_string()),
version_id: None,
file_name: None,
dependency_type: DependencyType::Required,
})
.filter(|dependency| {
!version.dependencies.iter().any(|declared| {
declared.project_id.as_deref()
== dependency.project_id.as_deref()
})
})
.collect()
}
fn select_newest_matching_version(
versions: Vec<Version>,
content_type: ContentType,
selected: &ResolutionPreferences,
target: &ResolutionPreferences,
) -> Option<Version> {
select_matching_version(
versions,
content_type,
selected,
target,
&[
ReleaseChannel::Release,
ReleaseChannel::Beta,
ReleaseChannel::Alpha,
],
)
}
fn select_matching_version(
mut versions: Vec<Version>,
content_type: ContentType,
selected: &ResolutionPreferences,
target: &ResolutionPreferences,
channel_order: &[ReleaseChannel],
) -> Option<Version> {
versions.sort_by_key(|version| {
(
channel_order
.iter()
.position(|channel| {
*channel == release_channel(&version.version_type)
})
.unwrap_or(channel_order.len()),
Reverse(version.date_published),
)
});
let merged = selected.merge(target);
versions
.iter()
.find(|version| version_matches(version, content_type, &merged))
.or_else(|| {
versions
.iter()
.find(|version| version_matches(version, content_type, target))
})
.cloned()
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum ReleaseChannel {
Release,
Beta,
Alpha,
}
fn release_channel(version_type: &str) -> ReleaseChannel {
if version_type.eq_ignore_ascii_case("beta") {
ReleaseChannel::Beta
} else if version_type.eq_ignore_ascii_case("alpha") {
ReleaseChannel::Alpha
} else {
ReleaseChannel::Release
}
}
trait MergePreferences {
fn merge(&self, target: &Self) -> Self;
}
impl MergePreferences for ResolutionPreferences {
fn merge(&self, target: &Self) -> Self {
Self {
game_versions: if self.game_versions.is_empty() {
target.game_versions.clone()
} else {
self.game_versions.clone()
},
loaders: if self.loaders.is_empty() {
target.loaders.clone()
} else {
self.loaders.clone()
},
}
}
}
fn version_matches(
version: &Version,
content_type: ContentType,
preferences: &ResolutionPreferences,
) -> bool {
matches_game_versions(version, preferences)
&& matches_loaders(version, content_type, preferences)
}
fn matches_game_versions(
version: &Version,
preferences: &ResolutionPreferences,
) -> bool {
preferences.game_versions.is_empty()
|| preferences.game_versions.iter().any(|game_version| {
version
.game_versions
.iter()
.any(|candidate| candidate == game_version)
})
}
fn matches_loaders(
version: &Version,
_content_type: ContentType,
preferences: &ResolutionPreferences,
) -> bool {
if preferences.loaders.is_empty() {
return true;
}
preferences.loaders.iter().any(|loader| {
version
.loaders
.iter()
.any(|candidate| loaders_match(loader, candidate))
})
}
fn loaders_match(expected: &str, candidate: &str) -> bool {
expected.eq_ignore_ascii_case(candidate)
}
fn should_use_quilted_fabric_api(
dependency: &Dependency,
target: &ResolutionPreferences,
) -> bool {
dependency.project_id.as_deref() == Some(FABRIC_API_PROJECT_ID)
&& target
.loaders
.iter()
.any(|loader| loaders_match(loader, "quilt"))
}

View File

@ -0,0 +1,11 @@
pub mod install;
pub mod model;
pub mod provider;
pub use install::resolve_content;
pub use model::{
ContentType, Dependency, DependencyType, Error, ResolutionPreferences,
ResolveContentPlan, ResolveContentRequest, ResolvedContent, SkippedContent,
SkippedReason, Version,
};
pub use provider::ContentMetadataProvider;

View File

@ -0,0 +1,133 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("metadata provider error: {0}")]
Provider(String),
#[error("project `{0}` was not found")]
ProjectNotFound(String),
#[error("version `{0}` was not found")]
VersionNotFound(String),
#[error("version `{version_id}` does not belong to project `{project_id}`")]
VersionProjectMismatch {
version_id: String,
project_id: String,
},
#[error("no compatible version was found for project `{0}`")]
NoCompatibleVersion(String),
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ContentType {
Mod,
Plugin,
DataPack,
ResourcePack,
Shader,
ModPack,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ResolutionPreferences {
#[serde(default)]
pub game_versions: Vec<String>,
#[serde(default)]
pub loaders: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ResolveContentRequest {
pub project_id: String,
pub version_id: Option<String>,
pub content_type: ContentType,
#[serde(default)]
pub selected: ResolutionPreferences,
#[serde(default)]
pub target: ResolutionPreferences,
#[serde(default)]
pub existing_project_ids: Vec<String>,
#[serde(default)]
pub excluded_project_ids: Vec<String>,
#[serde(default)]
pub force_project_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ResolveContentPlan {
pub primary: ResolvedContent,
pub dependencies: Vec<ResolvedContent>,
pub skipped: Vec<SkippedContent>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ResolvedContent {
pub project_id: String,
pub version_id: String,
pub dependent_on_version_id: Option<String>,
#[serde(default = "default_true")]
pub required: bool,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SkippedContent {
pub project_id: String,
pub version_id: Option<String>,
pub dependent_on_version_id: Option<String>,
pub reason: SkippedReason,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SkippedReason {
AlreadyInstalled,
DuplicateProject,
ConflictingDependency,
NoCompatibleVersion,
MissingVersion,
QuiltFabricApi,
ExcludedByUser,
DependencyCycle,
DependencyDepthExceeded,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Version {
pub id: String,
pub project_id: String,
pub date_published: DateTime<Utc>,
#[serde(default = "default_version_type")]
pub version_type: String,
#[serde(default)]
pub dependencies: Vec<Dependency>,
#[serde(default)]
pub game_versions: Vec<String>,
#[serde(default)]
pub loaders: Vec<String>,
}
fn default_version_type() -> String {
"release".to_string()
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Dependency {
pub version_id: Option<String>,
pub project_id: Option<String>,
pub file_name: Option<String>,
pub dependency_type: DependencyType,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DependencyType {
Required,
Optional,
Incompatible,
Embedded,
}

View File

@ -0,0 +1,16 @@
use async_trait::async_trait;
use crate::model::{Error, Version};
#[async_trait]
pub trait ContentMetadataProvider: Send + Sync {
async fn get_version(
&mut self,
version_id: &str,
) -> Result<Option<Version>, Error>;
async fn get_project_versions(
&mut self,
project_id: &str,
) -> Result<Vec<Version>, Error>;
}