feat(core): Add predownload key to versions metadata and restrict onboarding downloads

This commit is contained in:
LynithDev 2026-07-16 18:45:32 +02:00
parent 6eb16028d6
commit 98c2da4253
7 changed files with 207 additions and 19 deletions

View file

@ -42,7 +42,7 @@ pub use queries::{
use_package_project, use_package_search, use_package_versions, use_player_profile,
use_player_skin, use_provider_versions, use_refresh_account, use_refresh_all_accounts,
use_remove_account, use_screenshot_action, use_set_default_account, use_terms, use_upload_log,
use_version_metadata, use_versions, version_list, versions_total,
use_version_metadata, use_versions, version_list, versions_metadata, versions_total,
};
use crate::{

View file

@ -72,4 +72,4 @@ pub use settings_profiles::{
pub use skin::use_player_skin;
pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms};
pub use version_metadata::{pick_version_metadata, use_version_metadata};
pub use versions::{loader_versions, use_loader_versions, use_versions};
pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata};

View file

@ -84,6 +84,7 @@ mod tests {
art_url: None,
long_description: None,
tags: Vec::new(),
predownload: false,
}
}

View file

@ -28,6 +28,21 @@ pub fn use_versions() -> UseQuery<VersionsMetadataQuery> {
use_query(Query::new(VersionsMetadataKeys, VersionsMetadataQuery))
}
/// The manifest rows once the query has settled, `None` while it is still
/// loading. A failed fetch reads as an empty list rather than `None`, so
/// callers that block on this don't wait forever when the network is down.
pub fn versions_metadata(query: &UseQuery<VersionsMetadataQuery>) -> Option<Vec<VersionMetadata>> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => Some(list.clone()),
QueryStateData::Settled { res: Err(_), .. } => Some(Vec::new()),
QueryStateData::Loading {
res: Some(Ok(list)),
} => Some(list.clone()),
_ => None,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct LoaderVersionsQuery;

View file

@ -7,7 +7,7 @@ use freya::router::RouterContext;
use oneclient_core::notification::{
GroupedProgressEvent, GroupedProgressSession, Notification, NotificationService,
};
use oneclient_core::{BundleArchive, BundleFile, ImportTarget};
use oneclient_core::{BundleArchive, BundleFile, Cluster, ImportTarget, VersionMetadata};
use oneclient_db::models::OverrideType;
use tokio::sync::mpsc;
use uuid::Uuid;
@ -16,6 +16,7 @@ use crate::hooks::{
BridgeDispatch, ClusterBundles, invalidate_cluster_queries, migration_detections,
onboarding_bundles_items, try_default_account, use_current_account, use_dispatch,
use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot,
use_versions, versions_metadata,
};
use crate::routes::Route;
use crate::theme::colors;
@ -40,6 +41,9 @@ struct ClusterPlan {
/// `(bundle_name, package_id, override)` for every file whose fate differs
/// from the bundle manifest's default.
overrides: Vec<(String, String, OverrideType)>,
/// The manifest's `predownload` flag for this version. Versions without it
/// only ever record their package choices here and fetch on first launch.
predownload: bool,
}
#[derive(Clone, PartialEq)]
@ -193,6 +197,7 @@ impl Component for OnboardingDownloading {
let dispatch = use_dispatch();
let bundles_query = use_onboarding_bundles();
let migration_query = use_migration();
let versions_query = use_versions();
let selection = use_onboarding_selection();
let settings = use_settings_snapshot().settings;
let account_query = use_current_account();
@ -237,8 +242,11 @@ impl Component for OnboardingDownloading {
let Some(items) = onboarding_bundles_items(&bundles_query) else {
return;
};
let Some(versions) = versions_metadata(&versions_query) else {
return;
};
let selected = selected_state.peek().clone();
let plans = build_plans(&items, &selected);
let plans = build_plans(&items, &selected, &versions);
let predownload = *predownload_state.peek();
started.set(true);
run_install_batch(plans, predownload, handles);
@ -341,6 +349,7 @@ impl Component for OnboardingDownloading {
return summary_view(
&items,
&selected,
&versions_metadata(&versions_query).unwrap_or_default(),
&language,
reduce_motion,
settings.dynamic_background_enabled,
@ -440,6 +449,7 @@ impl Component for OnboardingDownloading {
fn build_plans(
items: &[ClusterBundles],
selected: &std::collections::HashSet<String>,
versions: &[VersionMetadata],
) -> Vec<ClusterPlan> {
items
.iter()
@ -452,11 +462,26 @@ fn build_plans(
cluster_id: cb.cluster.id,
mc_version: cb.cluster.mc_version.clone(),
overrides,
predownload: cluster_predownloads(versions, &cb.cluster),
}
})
.collect()
}
/// Whether the manifest asks for this cluster's version to be fetched up front.
/// Clusters with no manifest row (e.g. seeded from the bundle catalog alone)
/// are not predownloaded.
fn cluster_predownloads(versions: &[VersionMetadata], cluster: &Cluster) -> bool {
let loader = cluster.mc_loader.to_string();
versions.iter().any(|m| {
m.predownload
&& m.mc_version().as_deref() == Some(cluster.mc_version.as_str())
&& m.loader
.as_deref()
.is_none_or(|l| l.eq_ignore_ascii_case(&loader))
})
}
fn archive_overrides(
cluster_id: i64,
archive: &BundleArchive,
@ -505,9 +530,13 @@ const JRE_SIZE_GUESS: u64 = 45_000_000;
fn rough_download_estimate(
items: &[ClusterBundles],
selected: &std::collections::HashSet<String>,
versions: &[VersionMetadata],
) -> u64 {
let mut total: u64 = 0;
for cb in items {
if !cluster_predownloads(versions, &cb.cluster) {
continue;
}
for archive in &cb.archives {
let dropped: std::collections::HashSet<String> =
archive_overrides(cb.cluster.id, archive, selected)
@ -646,7 +675,7 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
));
if let Ok(state) = oneclient_core::LauncherState::get() {
let mut sum = 0u64;
for plan in &plans {
for plan in plans.iter().filter(|p| p.predownload) {
match oneclient_core::estimate_cluster_download(
&state,
plan.cluster_id,
@ -672,7 +701,8 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
label: plan.mc_version.clone(),
}));
if let Err(reason) = install_one(&plan, predownload, &notifier, &ui_tx).await {
let fetch_now = predownload && plan.predownload;
if let Err(reason) = install_one(&plan, fetch_now, &notifier, &ui_tx).await {
tracing::error!(
cluster_id = plan.cluster_id,
mc_version = %plan.mc_version,
@ -868,10 +898,29 @@ mod tests {
.collect()
}
/// Manifest row matching `test_support::cluster` (1.21.11 fabric).
fn version_row(predownload: bool) -> VersionMetadata {
VersionMetadata {
major_version: 21,
minor_version: Some(11),
patch_version: None,
loader: Some("fabric".to_string()),
name: "Mounts of Mayhem".to_string(),
art_url: None,
long_description: None,
tags: Vec::new(),
predownload,
}
}
fn versions() -> Vec<VersionMetadata> {
vec![version_row(true)]
}
#[test]
fn declining_a_bundle_removes_its_visible_and_hidden_files() {
let sb = skyblock();
let plans = build_plans(&items(vec![sb.clone()]), &HashSet::new());
let plans = build_plans(&items(vec![sb.clone()]), &HashSet::new(), &versions());
let ov = &plans[0].overrides;
// The hidden dependency must be dropped too, or it installs anyway.
@ -892,7 +941,7 @@ mod tests {
fn accepting_a_bundle_records_nothing_for_its_defaults() {
let sb = skyblock();
let selected = keys(&sb, &["skyblock-main"]);
let plans = build_plans(&items(vec![sb.clone()]), &selected);
let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions());
assert!(plans[0].overrides.is_empty());
}
@ -901,7 +950,7 @@ mod tests {
fn opting_into_an_extra_writes_enabled() {
let sb = skyblock();
let selected = keys(&sb, &["skyblock-main", "skycubed"]);
let plans = build_plans(&items(vec![sb.clone()]), &selected);
let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions());
assert_eq!(
plans[0].overrides,
@ -917,7 +966,7 @@ mod tests {
fn an_extra_alone_still_keeps_hidden_dependencies() {
let sb = skyblock();
let selected = keys(&sb, &["skycubed"]);
let plans = build_plans(&items(vec![sb.clone()]), &selected);
let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions());
let ov = &plans[0].overrides;
assert!(ov.contains(&(
@ -935,7 +984,7 @@ mod tests {
#[test]
fn rejections_are_removed_never_disabled() {
let plans = build_plans(&items(vec![skyblock()]), &HashSet::new());
let plans = build_plans(&items(vec![skyblock()]), &HashSet::new(), &versions());
assert!(
!plans[0]
@ -959,7 +1008,7 @@ mod tests {
},
];
let selected: HashSet<String> = [pkg_key(1, &sb.manifest.name, "skyblock-main")].into();
let plans = build_plans(&both, &selected);
let plans = build_plans(&both, &selected, &versions());
assert!(plans[0].overrides.is_empty());
assert!(
@ -976,13 +1025,48 @@ mod tests {
let all = items(vec![sb.clone()]);
let baseline = GAME_SIZE_GUESS + JRE_SIZE_GUESS;
let declined = rough_download_estimate(&all, &HashSet::new());
let declined = rough_download_estimate(&all, &HashSet::new(), &versions());
assert_eq!(declined, baseline, "declined bundle should cost nothing");
let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"]));
let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"]), &versions());
assert_eq!(accepted, baseline + 2);
let with_extra = rough_download_estimate(&all, &keys(&sb, &["skyblock-main", "skycubed"]));
let with_extra = rough_download_estimate(
&all,
&keys(&sb, &["skyblock-main", "skycubed"]),
&versions(),
);
assert_eq!(with_extra, baseline + 3);
}
#[test]
fn plans_take_predownload_from_the_manifest() {
let all = items(vec![skyblock()]);
assert!(build_plans(&all, &HashSet::new(), &versions())[0].predownload);
assert!(!build_plans(&all, &HashSet::new(), &[version_row(false)])[0].predownload);
// A cluster with no manifest row at all is never predownloaded.
assert!(!build_plans(&all, &HashSet::new(), &[])[0].predownload);
}
#[test]
fn a_predownload_row_for_another_version_does_not_match() {
let mut other = version_row(true);
other.minor_version = Some(10);
assert!(!build_plans(&items(vec![skyblock()]), &HashSet::new(), &[other])[0].predownload);
}
#[test]
fn a_predownload_row_for_another_loader_does_not_match() {
let mut other = version_row(true);
other.loader = Some("forge".to_string());
assert!(!build_plans(&items(vec![skyblock()]), &HashSet::new(), &[other])[0].predownload);
}
#[test]
fn the_estimate_ignores_versions_that_are_not_predownloaded() {
let all = items(vec![skyblock()]);
let selected = keys(&skyblock(), &["skyblock-main"]);
assert_eq!(rough_download_estimate(&all, &selected, &[]), 0);
}
}

View file

@ -14,6 +14,7 @@ use crate::view::onboarding::{onboarding_illustration, predownload_toggle_row, s
pub(super) fn summary_view(
items: &[ClusterBundles],
selected: &std::collections::HashSet<String>,
versions: &[VersionMetadata],
language: &str,
reduce_motion: bool,
parallax: bool,
@ -34,10 +35,26 @@ pub(super) fn summary_view(
})
.collect();
let packages_note = if *predownload.read() {
let est = rough_download_estimate(items, selected);
let ready_now = items
.iter()
.filter(|cb| cluster_predownloads(versions, &cb.cluster))
.count();
let packages_note = if *predownload.read() && ready_now > 0 {
let est = rough_download_estimate(items, selected, versions);
let (scope, rest) = if ready_now == items.len() {
("Your selected content is".to_string(), "")
} else {
(
format!(
"Content for {ready_now} version{} is",
if ready_now == 1 { "" } else { "s" }
),
" Everything else is downloaded the first time you launch it.",
)
};
format!(
"Your selected content is downloaded now, during setup. About {} total.",
"{scope} downloaded now, during setup. About {} total.{rest}",
format_size(est)
)
} else {

View file

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use crate::api_config::meta_url_base;
use crate::version::VersionKey;
use crate::version::{VersionKey, format_mc_version};
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct VersionsManifest {
@ -22,6 +22,9 @@ pub struct RemoteCluster {
pub long_description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
/// Default for every entry in this cluster; an entry's own key wins.
#[serde(default)]
pub predownload: Option<bool>,
#[serde(default)]
pub entries: Vec<RemoteEntry>,
}
@ -41,6 +44,8 @@ pub struct RemoteEntry {
pub long_description: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub predownload: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -71,6 +76,9 @@ pub struct VersionMetadata {
pub art_url: Option<String>,
pub long_description: Option<String>,
pub tags: Vec<String>,
/// Whether this version is fetched up front during onboarding, rather than
/// on first launch. Cluster rows carry the cluster's own default.
pub predownload: bool,
}
impl VersionMetadata {
@ -78,6 +86,17 @@ impl VersionMetadata {
pub fn key(&self) -> Option<VersionKey> {
Some((self.minor_version?, self.patch_version))
}
/// The `mc_version` string this row maps to, or `None` for the synthetic
/// cluster-level row (which has no minor version of its own).
#[must_use]
pub fn mc_version(&self) -> Option<String> {
Some(format_mc_version(
self.major_version,
self.minor_version?,
self.patch_version,
))
}
}
fn art_url(path: &Option<String>) -> Option<String> {
@ -104,6 +123,7 @@ impl VersionsManifest {
art_url: art_url(&cluster.art),
long_description: cluster.long_description.clone(),
tags: cluster.tags.clone(),
predownload: cluster.predownload.unwrap_or(false),
});
for entry in &cluster.entries {
@ -119,6 +139,7 @@ impl VersionsManifest {
.clone()
.or_else(|| cluster.long_description.clone()),
tags: entry.tags.clone().unwrap_or_else(|| cluster.tags.clone()),
predownload: entry.predownload.or(cluster.predownload).unwrap_or(false),
});
}
}
@ -165,6 +186,56 @@ mod tests {
assert_eq!(entry.key(), Some((1, Some(2))));
}
#[test]
fn predownload_inherits_from_cluster_and_entry_overrides() {
let manifest: VersionsManifest = serde_json::from_str(
r#"{"clusters":[
{"major_version":26,"predownload":true,"entries":[
{"minor_version":1},
{"minor_version":2,"predownload":false}
]},
{"major_version":21,"entries":[
{"minor_version":1},
{"minor_version":11,"predownload":true}
]}
]}"#,
)
.expect("should parse");
let metadata = manifest.metadata();
let flag = |major: u32, minor: Option<u32>| {
metadata
.iter()
.find(|m| m.major_version == major && m.minor_version == minor)
.expect("row present")
.predownload
};
// Cluster default flows down to entries that stay silent.
assert!(flag(26, None));
assert!(flag(26, Some(1)));
// An entry's own key wins over the cluster default, in both directions.
assert!(!flag(26, Some(2)));
assert!(flag(21, Some(11)));
// Absent everywhere means off.
assert!(!flag(21, None));
assert!(!flag(21, Some(1)));
}
#[test]
fn mc_version_skips_the_cluster_row() {
let manifest: VersionsManifest = serde_json::from_str(
r#"{"clusters":[{"major_version":26,"entries":[
{"minor_version":1,"patch_version":2}
]}]}"#,
)
.expect("should parse");
let metadata = manifest.metadata();
assert_eq!(metadata[0].mc_version(), None);
assert_eq!(metadata[1].mc_version().as_deref(), Some("26.1.2"));
}
#[test]
fn migrations_parse() {
let manifest: VersionsManifest = serde_json::from_str(