diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 9a029cad..9bc877bf 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -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::{ diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 15e563c3..f70c14e2 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -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}; diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs index 644d4644..29fb3eab 100644 --- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -84,6 +84,7 @@ mod tests { art_url: None, long_description: None, tags: Vec::new(), + predownload: false, } } diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs index b48c48fb..c218ea33 100644 --- a/packages/oneclient_app/src/hooks/queries/versions.rs +++ b/packages/oneclient_app/src/hooks/queries/versions.rs @@ -28,6 +28,21 @@ pub fn use_versions() -> UseQuery { 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) -> Option> { + 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; diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index af11f2b7..12c52dbd 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -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, + versions: &[VersionMetadata], ) -> Vec { 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, + 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 = archive_overrides(cb.cluster.id, archive, selected) @@ -646,7 +675,7 @@ fn run_install_batch(plans: Vec, 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, predownload: bool, handles: Instal label: plan.mc_version.clone(), })); - if let Err(reason) = install_one(&plan, predownload, ¬ifier, &ui_tx).await { + let fetch_now = predownload && plan.predownload; + if let Err(reason) = install_one(&plan, fetch_now, ¬ifier, &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 { + 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 = [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); + } } diff --git a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs index ff5d2c98..9796a390 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs @@ -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, + 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 { diff --git a/packages/oneclient_core/src/versions/manifest.rs b/packages/oneclient_core/src/versions/manifest.rs index 2e0c8143..f779cb34 100644 --- a/packages/oneclient_core/src/versions/manifest.rs +++ b/packages/oneclient_core/src/versions/manifest.rs @@ -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, #[serde(default)] pub tags: Vec, + /// Default for every entry in this cluster; an entry's own key wins. + #[serde(default)] + pub predownload: Option, #[serde(default)] pub entries: Vec, } @@ -41,6 +44,8 @@ pub struct RemoteEntry { pub long_description: Option, #[serde(default)] pub tags: Option>, + #[serde(default)] + pub predownload: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -71,6 +76,9 @@ pub struct VersionMetadata { pub art_url: Option, pub long_description: Option, pub tags: Vec, + /// 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 { 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 { + Some(format_mc_version( + self.major_version, + self.minor_version?, + self.patch_version, + )) + } } fn art_url(path: &Option) -> Option { @@ -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| { + 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(