feat(core): package downloading with dependencies actually downloads required dependencies now

This commit is contained in:
LynithDev 2026-07-30 21:32:26 +02:00
parent 7b300ca5a5
commit 8e82c1e26a
9 changed files with 666 additions and 11 deletions

View file

@ -698,7 +698,11 @@ impl Actions {
let spec = match &install.result {
Ok(name) => NotificationSpec {
title: "Installed".to_string(),
body: format!("Added {name}"),
body: crate::install::install_body(
name,
&install.dependencies,
&install.missing_dependencies,
),
level: Level::Info,
icon: Some(IconType::Download01),
progress: None,

View file

@ -161,6 +161,42 @@ pub async fn combined_cluster_update_spec(
pub struct PackageInstall {
pub session_id: Option<uuid::Uuid>,
pub result: anyhow::Result<String>,
/// Required dependencies pulled in alongside the package, by display name.
pub dependencies: Vec<String>,
/// Required dependencies that couldn't be resolved or downloaded. The
/// package still installs; the caller says so in the notification.
pub missing_dependencies: Vec<String>,
}
/// "Added Sodium with 2 dependencies. Could not add: Fabric API."
pub fn install_body(name: &str, dependencies: &[String], missing: &[String]) -> String {
let mut body = format!("Added {name}");
if !dependencies.is_empty() {
body.push_str(&format!(
" with {} dependenc{}",
dependencies.len(),
if dependencies.len() == 1 { "y" } else { "ies" }
));
}
body.push('.');
if !missing.is_empty() {
body.push_str(&format!(" Could not add: {}.", missing.join(", ")));
}
body
}
impl PackageInstall {
fn failed(err: anyhow::Error) -> Self {
Self {
session_id: None,
result: Err(err),
dependencies: Vec::new(),
missing_dependencies: Vec::new(),
}
}
}
pub async fn install_package(
@ -184,19 +220,88 @@ pub async fn install_package(
let (project, version) = match lookup {
Ok(found) => found,
Err(err) => {
return PackageInstall {
session_id: None,
result: Err(err),
};
}
Err(err) => return PackageInstall::failed(err),
};
// Worked out before the session starts so its children can be announced up
// front; a failure here leaves the package itself installable.
let mut resolution = oneclient_content::packages::DependencyResolution::default();
if oneclient_content::packages::resolves_dependencies(project.content_type) {
match oneclient_content::packages::resolve_required(
provider,
&version,
cluster_id,
&state.services.content(),
)
.await
{
Ok(resolved) => resolution = resolved,
Err(err) => {
tracing::warn!(%err, "dependency resolution failed, installing package alone");
}
}
}
let session = oneclient_events::GroupedProgressSession::start(
&state.services.events,
format!("Installing {}", project.name),
);
let size = version.primary_file().map(|f| f.size).unwrap_or(0);
let dependency_bytes: u64 = resolution
.install
.iter()
.map(|dep| dep.version.primary_file().map(|f| f.size).unwrap_or(0))
.sum();
session.expect(
oneclient_events::TaskCategory::Packages,
1 + resolution.install.len() as u64,
size + dependency_bytes,
);
let mut installed_dependencies = Vec::new();
let mut missing_dependencies = resolution.unresolved;
// Dependencies go in first so the package is never sitting in a cluster
// without them, however the run ends.
for dependency in &resolution.install {
let child = session.child(
dependency.project.name.clone(),
dependency
.version
.primary_file()
.map(|f| f.size)
.unwrap_or(0),
oneclient_events::TaskCategory::Packages,
);
let result = PackageStore::install_to_cluster(
provider,
&dependency.project,
&dependency.version,
cluster_id,
false,
false,
Some(&child),
&state.services.content(),
)
.await;
child.finish();
match result {
Ok(_) => installed_dependencies.push(dependency.project.name.clone()),
Err(err) => {
tracing::warn!(
dependency = %dependency.project.name,
%err,
"failed to install dependency"
);
missing_dependencies.push(dependency.project.name.clone());
}
}
}
let child = session.child(
project.name.clone(),
size,
@ -220,5 +325,7 @@ pub async fn install_package(
PackageInstall {
session_id: Some(session.detach()),
result: result.map(|_| project.name).map_err(anyhow::Error::from),
dependencies: installed_dependencies,
missing_dependencies,
}
}

View file

@ -0,0 +1,352 @@
use std::collections::{HashSet, VecDeque};
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
use oneclient_db::models::ClusterRow;
use crate::ctx::ContentCtx;
use crate::error::ContentResult;
use crate::packages::provider::PackageProvider;
use crate::packages::store::PackageStore;
use crate::packages::types::{
DependencyKind, ProjectDetail, ReleaseType, VersionDependency, VersionDetail, VersionSummary,
};
/// How far the transitive walk goes. Real graphs are two or three deep; the cap
/// is only here so a provider serving a cycle can't spin forever.
const MAX_DEPTH: usize = 4;
/// How many of a project's versions to consider when picking one. The providers
/// already filter by game version and loader, so the answer is near the top.
const VERSION_WINDOW: usize = 50;
#[derive(Debug, Clone)]
pub struct ResolvedDependency {
pub project: ProjectDetail,
pub version: VersionDetail,
}
#[derive(Debug, Clone, Default)]
pub struct DependencyResolution {
/// Dependencies that need installing, ordered breadth-first from the root.
/// Already-present ones are filtered out.
pub install: Vec<ResolvedDependency>,
/// Required dependencies that couldn't be resolved, by whatever id the
/// provider gave for them. The caller decides how loud to be about these.
pub unresolved: Vec<String>,
}
impl DependencyResolution {
pub fn is_empty(&self) -> bool {
self.install.is_empty() && self.unresolved.is_empty()
}
}
/// Collects the required dependencies of `root` that `cluster_id` doesn't
/// already have, transitively.
#[tracing::instrument(level = "debug", skip(root, ctx), fields(project_id = %root.project_id, version_id = %root.version_id))]
pub async fn resolve_required(
provider_id: ProviderId,
root: &VersionDetail,
cluster_id: i64,
ctx: &ContentCtx,
) -> ContentResult<DependencyResolution> {
let mut resolution = DependencyResolution::default();
if !root.dependencies.iter().any(is_required) {
return Ok(resolution);
}
let provider = ctx.providers.get(provider_id)?;
let cluster = PackageStore::get_cluster(cluster_id, ctx).await?;
// Seeded with what the cluster already has so an installed library is never
// fetched twice, plus the root itself against self-referencing graphs.
let mut seen = installed_project_ids(provider_id, cluster_id, ctx).await?;
seen.insert(root.project_id.clone());
let mut queue: VecDeque<(VersionDependency, usize)> = root
.dependencies
.iter()
.filter(|dep| is_required(dep))
.map(|dep| (dep.clone(), 1))
.collect();
while let Some((dep, depth)) = queue.pop_front() {
if let Some(project_id) = &dep.project_id
&& seen.contains(project_id)
{
continue;
}
let resolved = match resolve_one(provider, &dep, &cluster, ctx).await {
Ok(Some(resolved)) => resolved,
Ok(None) => {
tracing::warn!(?dep, "no compatible version for dependency");
resolution.unresolved.push(dependency_label(&dep));
continue;
}
Err(err) => {
tracing::warn!(?dep, %err, "failed to resolve dependency");
resolution.unresolved.push(dependency_label(&dep));
continue;
}
};
// A version-pinned dependency only reveals its project once fetched, so
// the second dedupe check has to happen here rather than up front.
if !seen.insert(resolved.version.project_id.clone()) {
continue;
}
if depth < MAX_DEPTH {
for next in resolved.version.dependencies.iter().filter(|d| is_required(d)) {
queue.push_back((next.clone(), depth + 1));
}
}
resolution.install.push(resolved);
}
Ok(resolution)
}
fn is_required(dep: &VersionDependency) -> bool {
dep.kind == DependencyKind::Required
}
fn dependency_label(dep: &VersionDependency) -> String {
dep.project_id
.clone()
.or_else(|| dep.version_id.clone())
.unwrap_or_else(|| "unknown".to_string())
}
async fn resolve_one(
provider: &dyn PackageProvider,
dep: &VersionDependency,
cluster: &ClusterRow,
ctx: &ContentCtx,
) -> ContentResult<Option<ResolvedDependency>> {
// A pinned version wins outright: the author named that exact file, so it is
// used even when a newer one exists. Both providers ignore the project id
// argument when the version id is known.
let version = match &dep.version_id {
Some(version_id) => {
provider
.get_version(
dep.project_id.as_deref().unwrap_or_default(),
version_id,
ctx,
)
.await?
}
None => {
let Some(project_id) = dep.project_id.as_deref() else {
return Ok(None);
};
let Some(pick) = pick_version(provider, project_id, cluster, ctx).await? else {
return Ok(None);
};
provider.get_version(project_id, &pick.version_id, ctx).await?
}
};
let project = provider.get_project(&version.project_id, ctx).await?;
Ok(Some(ResolvedDependency { project, version }))
}
/// Picks the newest release of `project_id` that fits the cluster, falling back
/// to the newest prerelease when that's all there is.
async fn pick_version(
provider: &dyn PackageProvider,
project_id: &str,
cluster: &ClusterRow,
ctx: &ContentCtx,
) -> ContentResult<Option<VersionSummary>> {
let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla);
let loader_filter = loader.is_modded().then_some(loader);
let mut candidates = provider
.list_versions(
project_id,
Some(&cluster.mc_version),
loader_filter,
0,
VERSION_WINDOW,
ctx,
)
.await?
.items;
// Resource packs, shaders and datapacks declare no loader, so a
// loader-filtered query comes back empty even though they fit the cluster.
if candidates.is_empty() && loader_filter.is_some() {
candidates = provider
.list_versions(project_id, Some(&cluster.mc_version), None, 0, VERSION_WINDOW, ctx)
.await?
.items;
}
Ok(choose_version(candidates, cluster, loader))
}
fn choose_version(
mut candidates: Vec<VersionSummary>,
cluster: &ClusterRow,
loader: GameLoader,
) -> Option<VersionSummary> {
candidates.retain(|version| fits_cluster(version, cluster, loader));
// Neither provider promises an order, so sort rather than trust the page.
candidates.sort_by_key(|version| std::cmp::Reverse(version.published));
candidates
.iter()
.find(|version| matches!(version.release_type, ReleaseType::Release))
.or_else(|| candidates.first())
.cloned()
}
/// Mirrors the compatibility check the store applies at install time, so a
/// version that would be rejected there is never picked here.
fn fits_cluster(version: &VersionSummary, cluster: &ClusterRow, loader: GameLoader) -> bool {
if !version.game_versions.is_empty()
&& !version
.game_versions
.iter()
.any(|v| cluster.mc_version.contains(v))
{
return false;
}
version.loaders.is_empty() || version.loaders.iter().any(|l| loader.compatible_with(*l))
}
async fn installed_project_ids(
provider_id: ProviderId,
cluster_id: i64,
ctx: &ContentCtx,
) -> ContentResult<HashSet<String>> {
Ok(PackageStore::list_linked_artifacts(cluster_id, ctx)
.await?
.into_iter()
.filter(|linked| linked.provider == Some(provider_id))
.filter_map(|linked| linked.project_id)
.collect())
}
/// Dependencies only make sense for content the loader reads out of the cluster;
/// a modpack brings its own file list.
pub fn resolves_dependencies(content_type: ContentType) -> bool {
!matches!(content_type, ContentType::Modpack)
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use super::*;
fn summary(
version_id: &str,
release_type: ReleaseType,
day: u32,
loaders: Vec<GameLoader>,
game_versions: Vec<&str>,
) -> VersionSummary {
VersionSummary {
version_id: version_id.into(),
project_id: "p".into(),
name: version_id.into(),
version_number: version_id.into(),
published: Utc.with_ymd_and_hms(2025, 1, day, 0, 0, 0).unwrap(),
release_type,
game_versions: game_versions.into_iter().map(Into::into).collect(),
loaders,
downloads: 0,
file_size: 0,
}
}
fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow {
ClusterRow {
id: 1,
name: "c".into(),
folder_name: "c".into(),
setting_profile_name: None,
mc_version: mc_version.into(),
mc_loader: loader as i64,
stage: 0,
mc_loader_version: None,
created_at: None,
last_played: None,
overall_played: None,
linked_modpack_hash: None,
}
}
#[test]
fn prefers_the_newest_release_over_a_newer_prerelease() {
let cluster = cluster(GameLoader::Fabric, "1.21.4");
let candidates = vec![
summary("old", ReleaseType::Release, 1, vec![GameLoader::Fabric], vec!["1.21.4"]),
summary("beta", ReleaseType::Beta, 3, vec![GameLoader::Fabric], vec!["1.21.4"]),
summary("new", ReleaseType::Release, 2, vec![GameLoader::Fabric], vec!["1.21.4"]),
];
let pick = choose_version(candidates, &cluster, GameLoader::Fabric);
assert_eq!(pick.unwrap().version_id, "new");
}
#[test]
fn falls_back_to_a_prerelease_when_no_release_fits() {
let cluster = cluster(GameLoader::Fabric, "1.21.4");
let candidates = vec![
summary("alpha", ReleaseType::Alpha, 1, vec![GameLoader::Fabric], vec!["1.21.4"]),
summary("beta", ReleaseType::Beta, 2, vec![GameLoader::Fabric], vec!["1.21.4"]),
];
let pick = choose_version(candidates, &cluster, GameLoader::Fabric);
assert_eq!(pick.unwrap().version_id, "beta");
}
#[test]
fn drops_versions_the_store_would_reject() {
let cluster = cluster(GameLoader::Fabric, "1.21.4");
let candidates = vec![
summary("forge", ReleaseType::Release, 3, vec![GameLoader::Forge], vec!["1.21.4"]),
summary("old-mc", ReleaseType::Release, 2, vec![GameLoader::Fabric], vec!["1.20.1"]),
summary("fits", ReleaseType::Release, 1, vec![GameLoader::Fabric], vec!["1.21.4"]),
];
let pick = choose_version(candidates, &cluster, GameLoader::Fabric);
assert_eq!(pick.unwrap().version_id, "fits");
}
#[test]
fn keeps_loaderless_content_for_a_modded_cluster() {
let cluster = cluster(GameLoader::Fabric, "1.21.4");
let candidates = vec![summary(
"pack",
ReleaseType::Release,
1,
Vec::new(),
vec!["1.21.4"],
)];
let pick = choose_version(candidates, &cluster, GameLoader::Fabric);
assert_eq!(pick.unwrap().version_id, "pack");
}
#[test]
fn no_compatible_version_resolves_to_nothing() {
let cluster = cluster(GameLoader::Fabric, "1.21.4");
let candidates = vec![summary(
"forge",
ReleaseType::Release,
1,
vec![GameLoader::Forge],
vec!["1.21.4"],
)];
assert!(choose_version(candidates, &cluster, GameLoader::Fabric).is_none());
}
}

View file

@ -159,6 +159,9 @@ async fn cached_version_detail(
size: artifact.size_bytes.unwrap_or(0) as u64,
fingerprint: None,
}],
// Not persisted with the release row; callers that need the dependency
// graph go to the provider.
dependencies: Vec::new(),
})
}

View file

@ -1,3 +1,4 @@
pub mod dependencies;
pub mod error;
pub mod metadata_cache;
pub mod modpack;
@ -14,6 +15,9 @@ pub use metadata_cache::{
CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached,
read_cached_package_meta,
};
pub use dependencies::{
DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies,
};
pub use file_identity::{curseforge_fingerprint, FileIdentity};
pub use error::{PackageError, PackageResult};
pub use provider::{PackageProvider, PackageProviderRegistry};

View file

@ -12,8 +12,9 @@ use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID};
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
use crate::packages::file_identity::FileIdentity;
use crate::packages::types::{
GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, ReleaseType,
SearchFilters, VersionDetail, VersionFile, VersionLookup, VersionSummary,
DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary,
ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup,
VersionSummary,
};
use crate::ctx::ContentCtx;
@ -460,6 +461,32 @@ struct CfFile {
file_fingerprint: u32,
download_url: String,
file_length: u64,
#[serde(default)]
dependencies: Vec<CfDependency>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CfDependency {
mod_id: u32,
relation_type: u8,
}
impl From<CfDependency> for VersionDependency {
fn from(d: CfDependency) -> Self {
Self {
project_id: Some(d.mod_id.to_string()),
version_id: None,
// 1 embedded library, 2 optional, 3 required, 4 tool, 5 incompatible,
// 6 include.
kind: match d.relation_type {
3 => DependencyKind::Required,
5 => DependencyKind::Incompatible,
1 | 6 => DependencyKind::Embedded,
_ => DependencyKind::Optional,
},
}
}
}
#[derive(Deserialize, Clone, Copy)]
@ -627,6 +654,7 @@ impl From<CfFile> for VersionDetail {
size: f.file_length,
fingerprint: Some(f.file_fingerprint.to_string()),
}],
dependencies: f.dependencies.into_iter().map(Into::into).collect(),
}
}
}
@ -698,3 +726,52 @@ fn cf_loader_type(loader: GameLoader) -> Option<u8> {
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Guards the camelCase contract; the relation type is a bare number, so a
/// mismap here quietly turns required libraries into optional ones.
#[test]
fn file_carries_its_dependencies() {
let raw = serde_json::json!({
"id": 1,
"modId": 2,
"displayName": "Sodium",
"fileName": "sodium.jar",
"releaseType": 1,
"fileDate": "2025-01-01T00:00:00Z",
"downloadCount": 10,
"gameVersions": ["1.21.4"],
"modLoaders": [],
"hashes": [],
"fileFingerprint": 123,
"downloadUrl": "https://example.invalid/sodium.jar",
"fileLength": 100,
"dependencies": [
{"modId": 10, "relationType": 3},
{"modId": 11, "relationType": 2},
{"modId": 12, "relationType": 5},
{"modId": 13, "relationType": 1}
]
});
let version: VersionDetail = serde_json::from_value::<CfFile>(raw)
.expect("curseforge file")
.into();
assert_eq!(
version.dependencies[0],
VersionDependency {
project_id: Some("10".into()),
version_id: None,
kind: DependencyKind::Required,
}
);
assert_eq!(version.dependencies[1].kind, DependencyKind::Optional);
assert_eq!(version.dependencies[2].kind, DependencyKind::Incompatible);
assert_eq!(version.dependencies[3].kind, DependencyKind::Embedded);
}
}

View file

@ -16,8 +16,9 @@ use oneclient_net::{RequestClient, RequestError};
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
use crate::packages::file_identity::FileIdentity;
use crate::packages::types::{
GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, ReleaseType,
SearchFilters, VersionDetail, VersionFile, VersionLookup, VersionSummary,
DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary,
ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup,
VersionSummary,
};
use crate::ctx::ContentCtx;
@ -550,6 +551,33 @@ struct ModrinthVersion {
files: Vec<ModrinthFile>,
#[serde(default)]
version_type: String,
#[serde(default)]
dependencies: Vec<ModrinthDependency>,
}
#[derive(Clone, Deserialize)]
struct ModrinthDependency {
#[serde(default)]
project_id: Option<String>,
#[serde(default)]
version_id: Option<String>,
#[serde(default)]
dependency_type: String,
}
impl From<ModrinthDependency> for VersionDependency {
fn from(d: ModrinthDependency) -> Self {
Self {
project_id: d.project_id.filter(|id| !id.is_empty()),
version_id: d.version_id.filter(|id| !id.is_empty()),
kind: match d.dependency_type.as_str() {
"required" => DependencyKind::Required,
"incompatible" => DependencyKind::Incompatible,
"embedded" => DependencyKind::Embedded,
_ => DependencyKind::Optional,
},
}
}
}
#[derive(Clone, Deserialize)]
@ -585,6 +613,7 @@ impl From<ModrinthVersion> for VersionDetail {
published: v.date_published,
downloads: v.downloads,
files: v.files.into_iter().map(Into::into).collect(),
dependencies: v.dependencies.into_iter().map(Into::into).collect(),
}
}
}
@ -648,3 +677,58 @@ fn parse_release_type(s: &str) -> ReleaseType {
_ => ReleaseType::Release,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Guards the field names against the shape Modrinth actually serves; a
/// rename here silently empties the dependency list.
#[test]
fn version_carries_its_dependencies() {
let raw = serde_json::json!({
"id": "abc",
"project_id": "sodium",
"name": "Sodium 0.6",
"version_number": "0.6",
"changelog": null,
"game_versions": ["1.21.4"],
"loaders": ["fabric"],
"date_published": "2025-01-01T00:00:00Z",
"downloads": 10,
"version_type": "release",
"files": [],
"dependencies": [
{"project_id": "fabric-api", "version_id": null, "dependency_type": "required"},
{"project_id": null, "version_id": "pinned", "dependency_type": "required"},
{"project_id": "sodium-extra", "version_id": null, "dependency_type": "optional"},
{"project_id": "optifine", "version_id": null, "dependency_type": "incompatible"},
],
});
let version: VersionDetail = serde_json::from_value::<ModrinthVersion>(raw)
.expect("modrinth version")
.into();
assert_eq!(version.dependencies.len(), 4);
assert_eq!(
version.dependencies[0],
VersionDependency {
project_id: Some("fabric-api".into()),
version_id: None,
kind: DependencyKind::Required,
}
);
assert_eq!(
version.dependencies[1],
VersionDependency {
project_id: None,
version_id: Some("pinned".into()),
kind: DependencyKind::Required,
}
);
assert_eq!(version.dependencies[2].kind, DependencyKind::Optional);
assert_eq!(version.dependencies[3].kind, DependencyKind::Incompatible);
}
}

View file

@ -493,6 +493,7 @@ mod tests {
published: Utc::now(),
downloads: 0,
files: Vec::new(),
dependencies: Vec::new(),
}
}

View file

@ -142,6 +142,29 @@ pub struct VersionDetail {
pub published: DateTime<Utc>,
pub downloads: u64,
pub files: Vec<VersionFile>,
/// What the provider says this version needs alongside it. Only
/// [`DependencyKind::Required`] entries are pulled in automatically.
#[serde(default)]
pub dependencies: Vec<VersionDependency>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DependencyKind {
Required,
Optional,
Incompatible,
Embedded,
}
/// A provider-declared relation to another project. At least one of the two ids
/// is set: Modrinth pins either a project, a specific version, or both, while
/// CurseForge only ever names the project.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VersionDependency {
pub project_id: Option<String>,
pub version_id: Option<String>,
pub kind: DependencyKind,
}
#[derive(Debug, Clone, Serialize, Deserialize)]