From 695406145ff753a5aad67eb90e3b2f36ceea8b5a Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:33:01 +0200 Subject: [PATCH] chore: deleted the download process from onboarding (#865) * chore: deleted the download process from onboarding * fix: gate onboarding Finish on loaded bundles and correct failure copy * chore: drop unused install_cluster_bundles re-export from oneclient_core * chore: drop the unread predownload flag from the versions manifest --------- Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com> --- .../oneclient_app/src/hooks/active_cluster.rs | 2 - .../src/hooks/queries/version_metadata.rs | 1 - .../oneclient_app/src/layout/app_shell.rs | 2 +- packages/oneclient_app/src/layout/mod.rs | 1 - .../src/layout/onboarding_shell.rs | 17 +- packages/oneclient_app/src/routes.rs | 10 +- .../src/view/onboarding/bundles/mod.rs | 7 +- .../view/onboarding/downloading/backdrop.rs | 186 --- .../src/view/onboarding/downloading/mod.rs | 1121 ----------------- .../view/onboarding/downloading/progress.rs | 388 ------ .../src/view/onboarding/downloading/tips.rs | 150 --- .../oneclient_app/src/view/onboarding/mod.rs | 48 +- .../src/view/onboarding/preferences.rs | 2 +- .../src/view/onboarding/summary/mod.rs | 479 +++++++ .../summary.rs => summary/view.rs} | 140 +- .../oneclient_core/src/versions/manifest.rs | 42 - 16 files changed, 580 insertions(+), 2016 deletions(-) delete mode 100644 packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs delete mode 100644 packages/oneclient_app/src/view/onboarding/downloading/mod.rs delete mode 100644 packages/oneclient_app/src/view/onboarding/downloading/progress.rs delete mode 100644 packages/oneclient_app/src/view/onboarding/downloading/tips.rs create mode 100644 packages/oneclient_app/src/view/onboarding/summary/mod.rs rename packages/oneclient_app/src/view/onboarding/{downloading/summary.rs => summary/view.rs} (70%) diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index 312c48fb..f5aa1838 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -122,8 +122,6 @@ pub struct OnboardingSelectionState { pub migrated_categories: State>>, pub language: State, pub reduce_motion: State, - pub predownload: State, - pub setup_started: State, /// `None` = don't import pub import_folder: State>, pub import_dedicated: State, diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs index e2f5646c..755d00a4 100644 --- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -77,7 +77,6 @@ mod tests { art_url: None, long_description: None, tags: Vec::new(), - predownload: false, } } diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index 6ec9ee2a..e03a3e0e 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -577,7 +577,7 @@ half4 main(float2 fragCoord) { } "#; -pub(crate) fn gradient_overlay_radial() -> impl IntoElement { +fn gradient_overlay_radial() -> impl IntoElement { let effect = use_hook(|| { freya::engine::prelude::RuntimeEffect::make_for_shader(VIGNETTE_SPOTLIGHT_SHADER, None) .expect("Failed to compile vignette shader") diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs index 638ab43a..8fba9730 100644 --- a/packages/oneclient_app/src/layout/mod.rs +++ b/packages/oneclient_app/src/layout/mod.rs @@ -8,7 +8,6 @@ mod root_layout; mod settings_shell; pub use animated_outlet::AnimatedAppOutlet; -pub(crate) use app_shell::gradient_overlay_radial; pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch}; pub use cluster_shell::{ClusterShell, cluster_content}; pub use ipc_commands::use_ipc_commands; diff --git a/packages/oneclient_app/src/layout/onboarding_shell.rs b/packages/oneclient_app/src/layout/onboarding_shell.rs index ed701bd2..8c14ef6d 100644 --- a/packages/oneclient_app/src/layout/onboarding_shell.rs +++ b/packages/oneclient_app/src/layout/onboarding_shell.rs @@ -10,9 +10,7 @@ use crate::hooks::{ use_migration, use_onboarding_bundles, use_provide_onboarding_selection, use_settings_snapshot, }; use crate::theme::colors; -use crate::view::onboarding::{ - LoadingBackdrop, default_selection, onboarding_step_index, onboarding_total, -}; +use crate::view::onboarding::{default_selection, onboarding_step_index, onboarding_total}; #[derive(PartialEq)] pub struct OnboardingShell; @@ -24,8 +22,6 @@ impl Component for OnboardingShell { let migrated_categories = use_state(|| None::>); let language = use_state(|| "English".to_string()); let reduce_motion = use_state(|| false); - let predownload = use_state(|| true); - let setup_started = use_state(|| false); let import_folder = use_state(|| None::); let import_dedicated = use_state(|| false); let picks_location = use_state({ @@ -38,8 +34,6 @@ impl Component for OnboardingShell { migrated_categories, language, reduce_motion, - predownload, - setup_started, import_folder, import_dedicated, picks_location, @@ -70,14 +64,6 @@ impl Component for OnboardingShell { selected.set(defaults); } }); - let show_backdrop = - matches!(&route, Route::OnboardingDownloading {}) && *setup_started.read(); - let backdrop = show_backdrop.then(|| { - let clusters = onboarding_bundles_items(&bundles) - .map(|items| items.into_iter().map(|cb| cb.cluster).collect()) - .unwrap_or_default(); - LoadingBackdrop { clusters }.into_element() - }); rect() .vertical() @@ -86,7 +72,6 @@ impl Component for OnboardingShell { .background(colors::page()) .color(colors::fg_primary()) .overflow(Overflow::Clip) - .maybe_child(backdrop) .child( rect() .vertical() diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index e27f4011..08c772ef 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -23,8 +23,8 @@ use crate::view::{ }, }, onboarding::{ - OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage, - OnboardingLocation, OnboardingMigration, OnboardingPreferences, OnboardingTerms, + OnboardingAccount, OnboardingBundles, OnboardingLanguage, OnboardingLocation, + OnboardingMigration, OnboardingPreferences, OnboardingSummary, OnboardingTerms, OnboardingWelcome, }, }; @@ -55,8 +55,8 @@ pub enum Route { OnboardingPreferences {}, #[route("/onboarding/bundles")] OnboardingBundles {}, - #[route("/onboarding/downloading")] - OnboardingDownloading {}, + #[route("/onboarding/summary")] + OnboardingSummary {}, #[end_layout] #[layout(AppShell)] @@ -138,7 +138,7 @@ impl Route { Route::OnboardingAccount { .. } => "Account".to_string(), Route::OnboardingPreferences { .. } => "Accessibility".to_string(), Route::OnboardingBundles { .. } => "Mods".to_string(), - Route::OnboardingDownloading { .. } => "Finishing".to_string(), + Route::OnboardingSummary { .. } => "Summary".to_string(), Route::Home { .. } => "Home".to_string(), Route::Clusters { .. } => "Versions".to_string(), Route::ClusterOverview { .. } => "Cluster".to_string(), diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs index 03276484..208fd882 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs @@ -17,8 +17,7 @@ use crate::theme::colors; use crate::ui::border_all_color; use crate::view::onboarding::{ archive_selected, choice_row_sized, is_default_bundle, is_optional_file, onboarding_nav, - onboarding_slide, pkg_key, predownload_toggle_row, set_archive_selected, step_heading, - version_chip, + onboarding_slide, pkg_key, set_archive_selected, step_heading, version_chip, }; mod row; @@ -241,7 +240,6 @@ impl Component for OnboardingBundles { let selection = use_onboarding_selection(); let selected = selection.selected; let user_touched = selection.user_touched; - let predownload = selection.predownload; let clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some(); @@ -317,8 +315,7 @@ impl Component for OnboardingBundles { .children(sections), ) .maybe_child(nothing_to_ask.then(|| empty_hint(&catalog_msg))), - ) - .child(predownload_toggle_row(predownload)), + ), )) .child(onboarding_nav( Some(Route::OnboardingAccount {}), diff --git a/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs b/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs deleted file mode 100644 index 81ac8358..00000000 --- a/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs +++ /dev/null @@ -1,186 +0,0 @@ -use super::*; - -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use oneclient_core::clusters::Cluster; - -use crate::components::{ART_PREVIEW_EDGE, DynamicArt}; -use crate::hooks::use_settings_snapshot; -use crate::layout::gradient_overlay_radial; -use crate::theme::colors; - -pub(super) const BACKDROP_INTERVAL_SECS: u64 = 12; - -const ROTATION_PERIOD: Duration = Duration::from_secs(BACKDROP_INTERVAL_SECS); - -fn since_epoch() -> Duration { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or(Duration::ZERO) -} - -pub(super) fn rotation_tick() -> usize { - (since_epoch().as_secs() / BACKDROP_INTERVAL_SECS) as usize -} - -pub(super) fn next_rotation_delay() -> Duration { - let into_period = since_epoch().as_nanos() % ROTATION_PERIOD.as_nanos(); - ROTATION_PERIOD - Duration::from_nanos(into_period as u64) -} - -const PARALLAX_SCALE: f32 = 1.10; -const PARALLAX_STRENGTH: f32 = 0.008; -const PARALLAX_STEP: f32 = 0.12; -const PARALLAX_SETTLE: f32 = 0.002; -const PARALLAX_FRAME: Duration = Duration::from_millis(33); - -#[derive(PartialEq)] -pub struct LoadingBackdrop { - pub clusters: Vec, -} - -impl Component for LoadingBackdrop { - fn render(&self) -> impl IntoElement { - let clusters = &self.clusters; - let count = clusters.len().max(1); - // Offset against the shared tick so the first art shown is still the - // first cluster - let start = use_hook(rotation_tick); - let mut tick = use_state(rotation_tick); - - let stop = use_hook(|| Arc::new(AtomicBool::new(false))); - - use_drop({ - let stop = stop.clone(); - move || stop.store(true, Ordering::Relaxed) - }); - - use_hook({ - let stop = stop.clone(); - move || { - if count <= 1 { - return; - } - - spawn(async move { - loop { - tokio::time::sleep(next_rotation_delay()).await; - - if stop.load(Ordering::Relaxed) { - break; - } - - tick.set(rotation_tick()); - } - }); - } - }); - - let current = (*tick.read()).wrapping_sub(start) % count; - - let parallax_enabled = use_settings_snapshot().settings.dynamic_background_enabled; - - let mut size = use_state(|| (0f32, 0f32)); - let mut target = use_state(|| (0f32, 0f32)); - let mut pan = use_state(|| (0f32, 0f32)); - - let mut anim_task = use_state(|| None::); - use_side_effect_with_deps(¶llax_enabled, move |enabled| { - if *enabled { - let handle = spawn(async move { - loop { - let (tx, ty) = *target.peek(); - let (cx, cy) = *pan.peek(); - - if (tx - cx).abs() > PARALLAX_SETTLE || (ty - cy).abs() > PARALLAX_SETTLE { - pan.set(( - cx + (tx - cx) * PARALLAX_STEP, - cy + (ty - cy) * PARALLAX_STEP, - )); - } - - tokio::time::sleep(PARALLAX_FRAME).await; - } - }); - anim_task.set(Some(handle.owned())); - } else { - anim_task.set(None); - target.set((0.0, 0.0)); - pan.set((0.0, 0.0)); - } - }); - - let (cx, cy) = if parallax_enabled { - *pan.read() - } else { - (0.0, 0.0) - }; - let (sw, sh) = *size.read(); - let pan_x = cx * sw * PARALLAX_STRENGTH; - let pan_y = cy * sh * PARALLAX_STRENGTH; - - let art = match clusters.get(current) { - Some(cluster) => DynamicArt::for_cluster(cluster), - None => DynamicArt::fallback(), - } - .preview_edge(ART_PREVIEW_EDGE); - - let fade = use_animation_with_dependencies(¤t, |conf, _| { - conf.on_creation(OnCreation::Run); - conf.on_change(OnChange::Rerun); - AnimNum::new(0.0, 1.0) - .time(900) - .ease(Ease::Out) - .function(Function::Cubic) - }); - - rect() - .position(Position::new_absolute().top(0.).left(0.)) - .width(Size::fill()) - .height(Size::fill()) - .overflow(Overflow::Clip) - .interactive(false) - .background(colors::page()) - .on_sized(move |e: Event| { - let (nw, nh) = (e.area.width(), e.area.height()); - let (pw, ph) = *size.peek(); - if (pw - nw).abs() > 0.5 || (ph - nh).abs() > 0.5 { - size.set((nw, nh)); - } - }) - .maybe(parallax_enabled, |el| { - el.on_capture_global_pointer_move(move |e: Event| { - let (sw, sh) = *size.peek(); - if sw > 0.0 && sh > 0.0 { - let loc = e.global_location(); - target.set(( - (loc.x as f32 / sw) * 2.0 - 1.0, - (loc.y as f32 / sh) * 2.0 - 1.0, - )); - } - }) - }) - .child( - rect() - .position(Position::new_absolute().top(0.).left(0.)) - .width(Size::fill()) - .height(Size::fill()) - .layer(Layer::Relative(0)) - .scale(PARALLAX_SCALE) - .offset_x(pan_x) - .offset_y(pan_y) - .opacity(fade.get().value()) - .child(art), - ) - .child( - rect() - .position(Position::new_absolute().top(0.).left(0.)) - .width(Size::fill()) - .height(Size::fill()) - .layer(Layer::Relative(1)) - .child(gradient_overlay_radial()), - ) - } -} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs deleted file mode 100644 index 9cb51f3c..00000000 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ /dev/null @@ -1,1121 +0,0 @@ -use std::collections::{BTreeMap, HashMap}; -use std::time::{Duration, Instant}; - -use freya::animation::*; -use freya::prelude::*; -use freya::router::RouterContext; -use oneclient_core::{ - BundleArchive, BundleFile, Cluster, ImportTarget, MigrationSource, SentryExclusion, - VersionMetadata, -}; -use oneclient_db::models::OverrideType; -use oneclient_events::{ - EventBus, GroupedProgressEvent, GroupedProgressSession, ProgressEvent, TaskCategory, -}; -use tokio::sync::mpsc; -use uuid::Uuid; - -use crate::hooks::{ - Actions, ClusterBundles, invalidate_cluster_queries, migration_detection, - 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; -use crate::transfer::{TransferMeter, TransferStats}; -use crate::view::onboarding::{matching_new_cluster_id, pkg_key}; - -mod backdrop; -mod progress; -mod summary; -mod tips; -pub use backdrop::LoadingBackdrop; -use progress::{ProgressView, failure_panel, progress_panel}; -use summary::summary_view; -use tips::{tip_panel, use_onboarding_tip}; - -const FADE_DURATION_MS: u64 = 700; -const MAX_TASK_ROWS: usize = 6; - -#[derive(Clone, PartialEq)] -struct ClusterPlan { - cluster_id: i64, - mc_version: String, - /// `(bundle_name, package_id, override)` for every file whose fate differs from the manifest default - overrides: Vec<(String, String, OverrideType)>, - /// Manifest `predownload` flag versions without it fetch their content on first launch - predownload: bool, -} - -#[derive(Clone, PartialEq)] -struct InstallFailure { - plan: ClusterPlan, - reason: String, -} - -#[derive(Clone, PartialEq)] -struct TaskLine { - label: String, - phase: &'static str, - current: u64, - total: u64, - category: TaskCategory, -} - -/// Display order for the task rows mirroring the notification centre -const TASK_LANES: [TaskCategory; 7] = [ - TaskCategory::Java, - TaskCategory::Client, - TaskCategory::Libraries, - TaskCategory::Natives, - TaskCategory::Assets, - TaskCategory::Metadata, - TaskCategory::Packages, -]; - -#[derive(Clone, PartialEq, Default)] -struct GroupedAgg { - children: HashMap, - done_units: u64, - carried: u64, -} - -impl GroupedAgg { - fn fraction(&self) -> f32 { - let completed: u64 = self.done_units - + self - .children - .values() - .map(|t| t.current.min(t.total)) - .sum::(); - let total: u64 = self.done_units + self.children.values().map(|t| t.total).sum::(); - if total == 0 { - 0.0 - } else { - (completed as f32 / total as f32).clamp(0.0, 1.0) - } - } - - /// Round-robin assets run 32-at-a-time and sort first so a flat sorted list showed nothing but assets for the whole download - fn task_list(&self) -> Vec { - let mut lanes: Vec> = TASK_LANES - .iter() - .map(|category| { - let mut lane: Vec = self - .children - .values() - .filter(|task| task.category == *category) - .cloned() - .collect(); - lane.sort_by(|a, b| a.label.cmp(&b.label)); - lane - }) - .collect(); - - let deepest = lanes.iter().map(Vec::len).max().unwrap_or(0); - let mut tasks = Vec::new(); - for index in 0..deepest { - for lane in &mut lanes { - if let Some(task) = lane.get(index) { - tasks.push(task.clone()); - } - } - } - tasks - } - - fn downloaded_bytes(&self) -> u64 { - self.carried - + self.done_units - + self - .children - .values() - .map(|t| t.current.min(t.total)) - .sum::() - } - - fn summary(&self) -> Option { - if self.children.is_empty() { - return None; - } - - let mut kinds: BTreeMap<&'static str, usize> = BTreeMap::new(); - let mut phases: BTreeMap<&'static str, usize> = BTreeMap::new(); - for task in self.children.values() { - *kinds.entry(categorize(&task.label)).or_default() += 1; - *phases.entry(task.phase).or_default() += 1; - } - - let verb = phases - .iter() - .max_by_key(|(_, n)| **n) - .map(|(p, _)| *p) - .unwrap_or("Downloading"); - - let mut ordered: Vec<(&str, usize)> = kinds.into_iter().collect(); - ordered.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); - let parts: Vec = ordered - .into_iter() - .take(3) - .map(|(kind, count)| { - if count > 1 { - format!("{kind} ×{count}") - } else { - kind.to_string() - } - }) - .collect(); - - Some(format!("{verb} {}", parts.join(", "))) - } -} - -fn categorize(label: &str) -> &'static str { - if label.starts_with("Assets index") { - "asset index" - } else if label.starts_with("Asset") { - "assets" - } else if label.starts_with("Library") { - "libraries" - } else if label.starts_with("Natives") { - "natives" - } else if label.starts_with("Client") { - "game client" - } else if label.starts_with("Version metadata") || label.starts_with("Loader metadata") { - "metadata" - } else if label.starts_with("Mod") { - "mods" - } else { - "content" - } -} - -#[derive(Clone, PartialEq, Default)] -struct DownloadStage { - index: usize, - total: usize, - label: String, -} - -enum InstallUiEvent { - Progress(usize, usize), - Stage(DownloadStage), - Activity(String), - TotalEstimate(u64), - Finished(Vec), -} - -#[derive(Clone, Copy)] -struct InstallHandles { - progress: State<(usize, usize)>, - agg: State, - stage: State, - activity: State, - started_at: State>, - meter: State>, - total_estimate: State, - failures: State>, - running: State, - complete: State, -} - -#[derive(PartialEq)] -pub struct OnboardingDownloading; - -impl Component for OnboardingDownloading { - fn render(&self) -> impl IntoElement { - 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(); - - let selected_state = selection.selected; - let language_state = selection.language; - let reduce_motion_state = selection.reduce_motion; - let predownload_state = selection.predownload; - let mut setup_started = selection.setup_started; - - let mut confirmed = use_state(|| false); - let mut started = use_state(|| false); - let progress = use_state(|| (0usize, 0usize)); - let agg = use_state(GroupedAgg::default); - let stage = use_state(DownloadStage::default); - let activity = use_state(String::new); - let started_at = use_state(|| None::); - let meter = use_state(|| None::); - let total_estimate = use_state(|| 0u64); - let failures = use_state(Vec::::new); - let running = use_state(|| false); - let complete = use_state(|| false); - let mut leaving = use_state(|| false); - - let handles = InstallHandles { - progress, - agg, - stage, - activity, - started_at, - meter, - total_estimate, - failures, - running, - complete, - }; - - use_side_effect(move || { - if !*confirmed.read() || *started.peek() { - return; - } - 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, &versions); - let predownload = *predownload_state.peek(); - started.set(true); - run_install_batch(plans, predownload, handles); - }); - - let dispatch_finish = dispatch.clone(); - use_side_effect(move || { - if !*complete.read() || !failures.read().is_empty() || *leaving.peek() { - return; - } - - leaving.set(true); - finish_onboarding(dispatch_finish.clone(), &bundles_query); - }); - - let (done, total) = *progress.read(); - let stage_now = stage.read().clone(); - let agg_now = agg.read().clone(); - let activity_now = activity.read().clone(); - let transfer = *meter.read(); - let total_estimate_now = *total_estimate.read(); - let elapsed_secs = (*started_at.read()).map(|s| s.elapsed().as_secs()); - let predownload = *predownload_state.read(); - let is_running = *running.read(); - let is_complete = *complete.read(); - - let global = if is_complete { - 100.0 - } else if total == 0 { - 0.0 - } else if total_estimate_now > 0 { - (agg_now.downloaded_bytes() as f32 / total_estimate_now as f32 * 100.0).clamp(0.0, 99.0) - } else { - ((done as f32 + agg_now.fraction()) / total as f32 * 100.0).clamp(0.0, 100.0) - }; - - let has_failures = !failures.read().is_empty(); - let heading = if has_failures && is_complete { - "Download issues found" - } else if predownload { - "Downloading..." - } else { - "Finishing up..." - }; - - let is_leaving = *leaving.read(); - let fade = use_animation_with_dependencies(&is_leaving, |conf, _| { - conf.on_creation(OnCreation::Run); - conf.on_change(OnChange::Rerun); - AnimNum::new(0., 1.) - .time(FADE_DURATION_MS) - .ease(Ease::Out) - .function(Function::Cubic) - }); - let opacity = if is_leaving { - 1.0 - fade.get().value() - } else { - 1.0 - }; - - let tip = use_onboarding_tip(); - - if !*confirmed.read() { - let items = onboarding_bundles_items(&bundles_query).unwrap_or_default(); - let selected = selected_state.read().clone(); - let language = language_state.read().clone(); - let reduce_motion = *reduce_motion_state.read(); - let account_name = try_default_account(&account_query) - .map(|account| account.username.clone()) - .unwrap_or_else(|| "Not signed in".to_string()); - - let import_dispatch = dispatch.clone(); - let import_folder = selection.import_folder; - let import_dedicated = selection.import_dedicated; - let import_detection = migration_detection(&migration_query); - let import_items = items.clone(); - - let migration_summary = import_detection.as_ref().map(|detection| { - let source_name = detection.source.display_name().to_string(); - let is_vanilla = detection.source == MigrationSource::Vanilla; - match import_folder.read().clone() { - Some(folder) => { - let dedicated = !is_vanilla - && *import_dedicated.read() - && detection - .instances - .iter() - .find(|c| c.folder_name == folder) - .and_then(|inst| matching_new_cluster_id(inst, &items)) - .is_some(); - let target = if dedicated { - "This version only" - } else { - "Shared game directory" - }; - let files = if is_vanilla { - "Your Minecraft folder".to_string() - } else { - folder - }; - (source_name, files, target.to_string()) - } - None => (source_name, "No files imported".to_string(), String::new()), - } - }); - - return summary_view( - &items, - &selected, - &versions_metadata(&versions_query).unwrap_or_default(), - &language, - reduce_motion, - settings.dynamic_background_enabled, - account_name, - migration_summary, - predownload_state, - move |_| { - if let (Some(detection), Some(folder)) = - (import_detection.as_ref(), import_folder.peek().clone()) - { - let target = if detection.source != MigrationSource::Vanilla - && *import_dedicated.peek() - { - detection - .instances - .iter() - .find(|c| c.folder_name == folder) - .and_then(|inst| matching_new_cluster_id(inst, &import_items)) - .map(|new_cluster_id| ImportTarget::Dedicated { new_cluster_id }) - .unwrap_or(ImportTarget::Shared) - } else { - ImportTarget::Shared - }; - import_dispatch.import_launcher(detection.source, folder, target); - } - - setup_started.set(true); - confirmed.set(true); - }, - ) - .into_element(); - } - - let dispatch_continue = dispatch.clone(); - let continue_query = bundles_query; - let mut continue_leaving = leaving; - - rect() - .vertical() - .width(Size::fill()) - .height(Size::fill()) - .content(Content::Flex) - .padding(Gaps::new(16., 48., 48., 48.)) - .opacity(opacity) - .child( - label() - .text(heading) - .font_size(36.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), - ) - .maybe_child((has_failures && is_complete).then(|| { - failure_panel( - &failures.read(), - done, - total, - is_running, - move |_| { - let retry_plans: Vec = - failures.peek().iter().map(|f| f.plan.clone()).collect(); - if retry_plans.is_empty() || *running.peek() { - return; - } - let predownload = *predownload_state.peek(); - run_install_batch(retry_plans, predownload, handles); - }, - move |_| { - if *continue_leaving.peek() { - return; - } - continue_leaving.set(true); - finish_onboarding(dispatch_continue.clone(), &continue_query); - }, - ) - .into_element() - })) - .child(rect().width(Size::fill()).height(Size::flex(1.0))) - .child( - rect() - .horizontal() - .width(Size::fill()) - .content(Content::Flex) - .cross_align(Alignment::End) - .child(progress_panel(ProgressView { - global, - stage: &stage_now, - agg: &agg_now, - activity: &activity_now, - transfer, - total_estimate: total_estimate_now, - elapsed_secs, - done, - total, - predownload, - running: is_running, - })) - .child(rect().width(Size::flex(1.0))) - .child(tip_panel(tip)), - ) - .into_element() - } -} - -fn build_plans( - items: &[ClusterBundles], - selected: &std::collections::HashSet, - versions: &[VersionMetadata], -) -> Vec { - items - .iter() - .map(|cb| { - let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected); - let mut overrides = Vec::new(); - for archive in &cb.archives { - overrides.extend(archive_overrides(cb.cluster.id, archive, selected, &kept)); - } - ClusterPlan { - cluster_id: cb.cluster.id, - mc_version: cb.cluster.mc_version.clone(), - overrides, - predownload: cluster_predownloads(versions, &cb.cluster), - } - }) - .collect() -} - -/// 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)) - }) -} - -/// True once anything visible in the bundle was taken including one opted-in extra that would otherwise lose its dependencies -fn bundle_taken( - cluster_id: i64, - archive: &BundleArchive, - selected: &std::collections::HashSet, -) -> bool { - archive.manifest.files.iter().any(|file| { - !file.hidden - && selected.contains(&pkg_key( - cluster_id, - &archive.manifest.name, - &file.kind.package_id(), - )) - }) -} - -/// Bundles overlap deciding each archive alone recorded a removal for a package an accepted bundle was about to install -fn kept_package_ids( - cluster_id: i64, - archives: &[BundleArchive], - selected: &std::collections::HashSet, -) -> std::collections::HashSet { - let mut kept = std::collections::HashSet::new(); - - for archive in archives { - let takes_hidden = bundle_taken(cluster_id, archive, selected); - for file in &archive.manifest.files { - let package_id = file.kind.package_id(); - let wanted = if file.hidden { - takes_hidden - } else { - selected.contains(&pkg_key(cluster_id, &archive.manifest.name, &package_id)) - }; - if wanted { - kept.insert(package_id); - } - } - } - - kept -} - -fn archive_overrides( - cluster_id: i64, - archive: &BundleArchive, - selected: &std::collections::HashSet, - kept_elsewhere: &std::collections::HashSet, -) -> Vec<(String, String, OverrideType)> { - let bundle_name = &archive.manifest.name; - let wants = |file: &BundleFile| { - selected.contains(&pkg_key(cluster_id, bundle_name, &file.kind.package_id())) - }; - - // Hidden files follow the bundle kept if anything from it was taken dropped if not - let takes_hidden = bundle_taken(cluster_id, archive, selected); - - let mut overrides = Vec::new(); - for file in &archive.manifest.files { - let wanted = if file.hidden { - takes_hidden - } else { - wants(file) - }; - - let override_type = match (wanted, file.enabled) { - // Matches the manifest default nothing to record - (true, true) | (false, false) => continue, - // Opting into a mod the bundle ships turned off - (true, false) => OverrideType::Enabled, - // Only really declined if no bundle the user took ships the same package - (false, true) => { - if kept_elsewhere.contains(&file.kind.package_id()) { - continue; - } - OverrideType::Removed - } - }; - - overrides.push((bundle_name.clone(), file.kind.package_id(), override_type)); - } - overrides -} - -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; - } - // Counted once per package two bundles shipping the same library is one download - let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected); - let mut counted: std::collections::HashSet = std::collections::HashSet::new(); - for archive in &cb.archives { - for file in &archive.manifest.files { - let package_id = file.kind.package_id(); - if kept.contains(&package_id) && counted.insert(package_id) { - total += file.size; - } - } - } - } - total -} - -fn run_install_batch(plans: Vec, predownload: bool, handles: InstallHandles) { - let InstallHandles { - mut progress, - mut agg, - mut stage, - mut activity, - mut started_at, - mut meter, - mut total_estimate, - mut failures, - mut running, - mut complete, - } = handles; - - running.set(true); - complete.set(false); - failures.set(Vec::new()); - agg.set(GroupedAgg::default()); - meter.set(None); - total_estimate.set(0); - activity.set("Getting started...".to_string()); - started_at.set(Some(Instant::now())); - let total = plans.len(); - progress.set((0, total)); - - let (events, mut notif_rx) = EventBus::channel(); - - let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::(); - - spawn(async move { - let mut transfer = TransferMeter::default(); - - loop { - tokio::time::sleep(Duration::from_millis(1000)).await; - - let downloaded = agg.peek().downloaded_bytes(); - meter.set(transfer.sample(downloaded, *total_estimate.peek())); - - if !*running.peek() { - break; - } - } - }); - - spawn(async move { - let mut local = GroupedAgg::default(); - loop { - let Some(first) = notif_rx.recv().await else { - break; - }; - if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = first { - apply_grouped(&mut local, event); - } - while let Ok(notification) = notif_rx.try_recv() { - if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = - notification - { - apply_grouped(&mut local, event); - } - } - agg.set(local.clone()); - tokio::time::sleep(Duration::from_millis(50)).await; - } - }); - - spawn(async move { - while let Some(event) = ui_rx.recv().await { - match event { - InstallUiEvent::Progress(done, total) => progress.set((done, total)), - InstallUiEvent::Stage(next) => stage.set(next), - InstallUiEvent::Activity(text) => activity.set(text), - InstallUiEvent::TotalEstimate(bytes) => total_estimate.set(bytes), - InstallUiEvent::Finished(failed) => { - agg.set(GroupedAgg::default()); - activity.set("Wrapping up...".to_string()); - invalidate_cluster_queries().await; - failures.set(failed); - running.set(false); - complete.set(true); - break; - } - } - } - }); - - tokio::spawn(async move { - let mut failed = Vec::new(); - - if predownload { - let _ = ui_tx.send(InstallUiEvent::Activity( - "Calculating download size...".to_string(), - )); - if let Ok(state) = crate::launcher::state() { - let mut sum = 0u64; - for plan in plans.iter().filter(|p| p.predownload) { - match oneclient_content::bundles::enabled_bundle_bytes( - plan.cluster_id, - state.bundles.as_ref(), - &state.services.content(), - ) - .await - { - Ok(bytes) => sum += bytes, - Err(err) => tracing::warn!( - cluster_id = plan.cluster_id, - "download size estimate failed: {err}" - ), - } - let _ = ui_tx.send(InstallUiEvent::TotalEstimate(sum)); - } - } - } - - for (index, plan) in plans.into_iter().enumerate() { - let _ = ui_tx.send(InstallUiEvent::Stage(DownloadStage { - index, - total, - label: plan.mc_version.clone(), - })); - - let fetch_now = predownload && plan.predownload; - if let Err(err) = install_one(&plan, fetch_now, &events, &ui_tx).await { - // Expected failures (out of disk offline no Java build) are not crashes warn! is a breadcrumb error! reports to Sentry - if err.is_sentry_excluded() { - tracing::warn!( - cluster_id = plan.cluster_id, - mc_version = %plan.mc_version, - "onboarding install failed (expected): {err}" - ); - } else { - tracing::error!( - cluster_id = plan.cluster_id, - mc_version = %plan.mc_version, - "onboarding install failed: {err}" - ); - } - failed.push(InstallFailure { - plan, - reason: err.to_string(), - }); - } - let _ = ui_tx.send(InstallUiEvent::Progress(index + 1, total)); - } - - drop(events); - let _ = ui_tx.send(InstallUiEvent::Finished(failed)); - }); -} - -async fn install_one( - plan: &ClusterPlan, - predownload: bool, - events: &EventBus, - ui_tx: &mpsc::UnboundedSender, -) -> oneclient_core::LauncherResult<()> { - let state = crate::launcher::state()?; - - if !plan.overrides.is_empty() { - let _ = ui_tx.send(InstallUiEvent::Activity( - "Saving your package choices...".to_string(), - )); - } - oneclient_core::set_bundle_package_overrides( - plan.cluster_id, - &plan.overrides, - &state.services.content(), - ) - .await?; - - if !predownload { - return Ok(()); - } - - let session = GroupedProgressSession::start(events, format!("Downloading {}", plan.mc_version)); - - let _ = ui_tx.send(InstallUiEvent::Activity(format!( - "Installing mods & content for {}...", - plan.mc_version - ))); - - let bundles_result = oneclient_core::install_cluster_bundles( - plan.cluster_id, - state.bundles.as_ref(), - Some(&session), - &state.services.content(), - ) - .await; - - session.finish(); - - bundles_result?; - Ok(()) -} - -fn apply_grouped(agg: &mut GroupedAgg, event: GroupedProgressEvent) { - match event { - GroupedProgressEvent::Start { .. } => { - agg.children.clear(); - agg.done_units = 0; - } - GroupedProgressEvent::Expect { .. } => {} - GroupedProgressEvent::AddChild { - child_id, - label, - total, - category, - .. - } => { - agg.children.insert( - child_id, - TaskLine { - label, - phase: "Downloading", - current: 0, - total: total.max(1), - category, - }, - ); - } - GroupedProgressEvent::UpdateChild { - child_id, - current, - total, - .. - } => { - if let Some(task) = agg.children.get_mut(&child_id) { - task.current = current; - task.total = total.max(1); - } - } - GroupedProgressEvent::SetChildPhase { - child_id, phase, .. - } => { - if let Some(task) = agg.children.get_mut(&child_id) { - task.phase = phase.label(); - } - } - GroupedProgressEvent::FinishChild { child_id, .. } => { - if let Some(task) = agg.children.remove(&child_id) { - agg.done_units += task.total; - } - } - GroupedProgressEvent::End { .. } => { - agg.carried += agg.done_units - + agg - .children - .values() - .map(|t| t.current.min(t.total)) - .sum::(); - agg.children.clear(); - agg.done_units = 0; - } - } -} - -fn finish_onboarding( - dispatch: Actions, - bundles_query: &freya::query::UseQuery, -) { - let versions: Vec = onboarding_bundles_items(bundles_query) - .map(|items| { - let mut versions: Vec = items - .iter() - .map(|cb| cb.cluster.mc_version.clone()) - .collect(); - - versions.sort(); - versions.dedup(); - versions - }) - .unwrap_or_default(); - - spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(FADE_DURATION_MS)).await; - - for version in versions { - dispatch.record_seen_version(version); - } - - dispatch.mark_onboarding_seen(); - let _ = RouterContext::get().replace(Route::Home {}); - }); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::view::onboarding::test_support::{archive, cluster, file}; - use std::collections::HashSet; - - fn skyblock() -> BundleArchive { - archive( - "SkyBlock", - false, - vec![ - file("skyblock-main", true, false), - file("skycubed", false, false), - file("sb-dep", true, true), - ], - ) - } - - fn items(archives: Vec) -> Vec { - vec![ClusterBundles { - cluster: cluster(1), - archives, - }] - } - - fn keys(archive: &BundleArchive, pids: &[&str]) -> HashSet { - pids.iter() - .map(|p| pkg_key(1, &archive.manifest.name, p)) - .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(), &versions()); - - let ov = &plans[0].overrides; - // The hidden dependency must be dropped too or it installs anyway - assert!(ov.contains(&( - sb.manifest.name.clone(), - "sb-dep".to_string(), - OverrideType::Removed - ))); - assert!(ov.contains(&( - sb.manifest.name.clone(), - "skyblock-main".to_string(), - OverrideType::Removed - ))); - assert!(!ov.iter().any(|(_, pid, _)| pid == "skycubed")); - } - - #[test] - 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, &versions()); - - assert!(plans[0].overrides.is_empty()); - } - - #[test] - 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, &versions()); - - assert_eq!( - plans[0].overrides, - vec![( - sb.manifest.name.clone(), - "skycubed".to_string(), - OverrideType::Enabled - )] - ); - } - - #[test] - 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, &versions()); - let ov = &plans[0].overrides; - - assert!(ov.contains(&( - sb.manifest.name.clone(), - "skycubed".to_string(), - OverrideType::Enabled - ))); - assert!(!ov.iter().any(|(_, pid, _)| pid == "sb-dep")); - assert!(ov.contains(&( - sb.manifest.name.clone(), - "skyblock-main".to_string(), - OverrideType::Removed - ))); - } - - #[test] - fn rejections_are_removed_never_disabled() { - let plans = build_plans(&items(vec![skyblock()]), &HashSet::new(), &versions()); - - assert!( - !plans[0] - .overrides - .iter() - .any(|(_, _, ty)| *ty == OverrideType::Disabled) - ); - } - - #[test] - fn per_cluster_choices_are_independent() { - let sb = skyblock(); - let both = vec![ - ClusterBundles { - cluster: cluster(1), - archives: vec![sb.clone()], - }, - ClusterBundles { - cluster: cluster(2), - archives: vec![sb.clone()], - }, - ]; - let selected: HashSet = [pkg_key(1, &sb.manifest.name, "skyblock-main")].into(); - let plans = build_plans(&both, &selected, &versions()); - - assert!(plans[0].overrides.is_empty()); - assert!( - plans[1] - .overrides - .iter() - .any(|(_, pid, ty)| pid == "skyblock-main" && *ty == OverrideType::Removed) - ); - } - - #[test] - fn estimate_skips_declined_bundles_and_counts_opted_in_extras() { - let sb = skyblock(); - let all = items(vec![sb.clone()]); - let declined = rough_download_estimate(&all, &HashSet::new(), &versions()); - assert_eq!(declined, 0, "declined bundle should cost nothing"); - - let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"]), &versions()); - assert_eq!(accepted, 2); - - let with_extra = rough_download_estimate( - &all, - &keys(&sb, &["skyblock-main", "skycubed"]), - &versions(), - ); - assert_eq!(with_extra, 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/progress.rs b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs deleted file mode 100644 index 21d6f3b1..00000000 --- a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs +++ /dev/null @@ -1,388 +0,0 @@ -use super::*; - -use crate::components::{Button, progress_track}; -use crate::theme::colors; -use crate::ui::border_all_color; -use crate::utils::{format_durations, format_size}; - -pub(super) fn failure_panel( - failures: &[InstallFailure], - done: usize, - total: usize, - running: bool, - on_retry: impl FnMut(Event) + 'static, - on_continue: impl FnMut(Event) + 'static, -) -> impl IntoElement { - let lines: Vec = failures - .iter() - .take(8) - .map(|failure| { - label() - .text(format!( - "{} (cluster {}): {}", - failure.plan.mc_version, failure.plan.cluster_id, failure.reason - )) - .font_size(12.) - .color(colors::fg_primary()) - .into_element() - }) - .collect(); - let extra = failures.len().saturating_sub(8); - - rect() - .vertical() - .width(Size::px(640.)) - .margin(Gaps::new(24., 0., 0., 0.)) - .padding(Gaps::new_all(20.)) - .spacing(10.) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::danger().with_a(26)) - .border(border_all_color(1., colors::danger().with_a(128))) - .child( - label() - .text("Some versions could not be prepared.") - .font_size(16.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child( - label() - .text(format!("Processed: {done} / {total}")) - .font_size(12.) - .color(colors::fg_secondary()), - ) - .child( - rect() - .vertical() - .width(Size::fill()) - .spacing(4.) - .padding(Gaps::new_all(12.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(Color::BLACK.with_a(51)) - .children(lines) - .maybe_child((extra > 0).then(|| { - label() - .text(format!("+{extra} more failures")) - .font_size(12.) - .color(colors::fg_secondary()) - .into_element() - })), - ) - .child( - rect() - .horizontal() - .spacing(10.) - .child( - Button::new() - .primary() - .enabled(!running) - .on_press(on_retry) - .text("Retry Failed Versions"), - ) - .child( - Button::new() - .secondary() - .on_press(on_continue) - .text("Continue Anyway"), - ), - ) - .into_element() -} - -pub(super) struct ProgressView<'a> { - pub(super) global: f32, - pub(super) stage: &'a DownloadStage, - pub(super) agg: &'a GroupedAgg, - pub(super) activity: &'a str, - /// `None` until the meter has two samples to work from - pub(super) transfer: Option, - pub(super) total_estimate: u64, - pub(super) elapsed_secs: Option, - pub(super) done: usize, - pub(super) total: usize, - pub(super) predownload: bool, - pub(super) running: bool, -} - -pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement { - let ProgressView { - global, - stage, - agg, - activity, - transfer, - total_estimate, - elapsed_secs, - done, - total, - predownload, - running, - } = view; - - let percentage = global.round() as i32; - - let position = if !running && done >= total && total > 0 { - "Done".to_string() - } else if predownload && stage.total > 0 { - format!( - "Version {} of {}: {}", - (stage.index + 1).min(stage.total), - stage.total, - stage.label - ) - } else if total > 0 { - format!("Step {done} of {total}") - } else { - "Preparing...".to_string() - }; - - let tasks = agg.task_list(); - let live_count = tasks.len(); - let shown = live_count.min(MAX_TASK_ROWS); - let overflow = live_count.saturating_sub(shown); - let task_rows: Vec = tasks.into_iter().take(shown).map(task_row).collect(); - - let activity_text = agg.summary().unwrap_or_else(|| { - if activity.is_empty() { - position.clone() - } else { - activity.to_string() - } - }); - - let stats = build_stats(StatsView { - global, - transfer, - total_estimate, - elapsed_secs, - running, - done, - total, - }); - - rect() - .vertical() - .width(Size::px(460.)) - .spacing(10.) - .cross_align(Alignment::Start) - .main_align(Alignment::End) - .maybe_child((!task_rows.is_empty()).then(|| { - rect() - .vertical() - .width(Size::fill()) - .spacing(8.) - .children(task_rows) - .maybe_child((overflow > 0).then(|| { - label() - .text(format!("+{overflow} more")) - .font_size(11.) - .color(colors::fg_secondary()) - .into_element() - })) - .into_element() - })) - .child( - ActivityIndicator { - running, - text: activity_text, - } - .into_element(), - ) - .maybe_child(stats) - .child( - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .content(Content::Flex) - .child( - label() - .text(format!("{percentage}%")) - .width(Size::flex(1.0)) - .font_size(24.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child( - label() - .text(position) - .font_size(11.) - .max_lines(1) - .color(colors::fg_secondary()), - ), - ) - .child(progress_track( - global, - 4., - Color::WHITE, - Color::WHITE.with_a(51), - )) - .into_element() -} - -struct StatsView { - global: f32, - transfer: Option, - total_estimate: u64, - elapsed_secs: Option, - running: bool, - done: usize, - total: usize, -} - -fn build_stats(view: StatsView) -> Option { - let StatsView { - global, - transfer, - total_estimate, - elapsed_secs, - running, - done, - total, - } = view; - - let elapsed = elapsed_secs?; - let finished = !running && done >= total && total > 0; - - let mut parts: Vec = vec![format!("Elapsed {}", format_durations(elapsed as i64))]; - - if finished { - parts.push("Complete".to_string()); - } else { - // Both readings come off the same smoothed rate so they always agree - match transfer.and_then(|t| t.eta_secs) { - Some(remaining) => parts.push(format!("~{} left", format_durations(remaining as i64))), - None => parts.push("~- left".to_string()), - } - - if let Some(speed) = transfer.map(|t| t.speed_bps).filter(|s| *s >= 1.0) { - parts.push(format!("{}/s", format_size(speed as u64))); - } - } - - if total_estimate > 0 { - let downloaded = ((global as f64 / 100.0) * total_estimate as f64) as u64; - parts.push(format!( - "{} / ~{}", - format_size(downloaded), - format_size(total_estimate) - )); - } - - Some( - label() - .text(parts.join(" · ")) - .font_size(11.) - .max_lines(1) - .color(colors::fg_secondary()) - .into_element(), - ) -} - -fn task_row(task: TaskLine) -> Element { - let is_bytes = task.total > 1; - let pct = if task.total > 0 { - (task.current as f32 / task.total as f32 * 100.0).clamp(0.0, 100.0) - } else { - 0.0 - }; - let detail = if is_bytes { - format!( - "{} / {}", - format_size(task.current), - format_size(task.total) - ) - } else { - task.phase.to_string() - }; - - let track_pct = if is_bytes { pct } else { 100.0 }; - let fill = if is_bytes { - colors::brand() - } else { - colors::brand().with_a(120) - }; - - rect() - .vertical() - .width(Size::fill()) - .spacing(4.) - .child( - rect() - .horizontal() - .width(Size::fill()) - .content(Content::Flex) - .cross_align(Alignment::Center) - .spacing(10.) - .child( - label() - .text(format!("{} {}", task.phase, task.label)) - .width(Size::flex(1.0)) - .font_size(12.) - .max_lines(1) - .color(colors::fg_primary()), - ) - .child( - label() - .text(detail) - .font_size(11.) - .max_lines(1) - .color(colors::fg_secondary()), - ), - ) - .child(progress_track(track_pct, 3., fill, Color::WHITE.with_a(28))) - .into_element() -} - -#[derive(PartialEq)] -struct ActivityIndicator { - running: bool, - text: String, -} - -impl Component for ActivityIndicator { - fn render(&self) -> impl IntoElement { - let pulse = use_animation(|conf| { - conf.on_creation(OnCreation::Run); - conf.on_finish(OnFinish::reverse()); - AnimNum::new(0.3, 1.0) - .time(650) - .ease(Ease::InOut) - .function(Function::Cubic) - }); - - let dot_opacity = if self.running { - pulse.read().value() - } else { - 0.9 - }; - let color = if self.running { - colors::brand() - } else { - colors::fg_secondary() - }; - - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(8.) - .content(Content::Flex) - .child( - rect() - .width(Size::px(8.)) - .height(Size::px(8.)) - .corner_radius(CornerRadius::new_all(4.)) - .background(color) - .opacity(dot_opacity), - ) - .child( - label() - .text(self.text.clone()) - .width(Size::flex(1.0)) - .font_size(12.) - .max_lines(1) - .color(colors::fg_primary()), - ) - } -} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs deleted file mode 100644 index 5e2e9112..00000000 --- a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs +++ /dev/null @@ -1,150 +0,0 @@ -use super::*; - -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -use super::backdrop::{next_rotation_delay, rotation_tick}; -use crate::theme::colors; - -const ONBOARDING_TIPS_BACKUP: &[&str] = &[ - "418 I'm a teapot", - "Floors 8 through 10 of the Catacombs were originally planned for release, but portions of it were later merged into Floor 7 and its Master Mode counterpart.", - "At one point, Feather Client was willing to pay $300 USD to add Skytils, a popular SkyBlock mod.", - "Grass is a plant with narrow leaves growing from the base. [...]\nGrasses are monocotyledon, herbaceous plants.", - "In Canada, Santa's postal code is H0H 0H0.", - "Guvf vf abg na vzcbegnag fragrapr.", - "Vs lbh qrpvcure guvf, lbh fubhyq yvfgra gb Mrgihr'f zhfvp.", - "Try to do something creative! It could be music, art, coding...\nYou won't know how much you like it until you try it, so give it a shot :)", - "At one point in Minecraft, there was a rare chance of Snow Golems naturally spawning.", - "W[h]yvest", - "RUMOUR. Open your ears; 9r\"5j5&?OWTY Z0d", - "Make sure to drink water! You never know if you might be dehydrated.", - "People asking you to \"test their mod\" are likely trying to hack you.\nThe only ones you can trust are ones widely trusted by the community.", - "Have a great day!", - "You can sleep through a thunderstorm, even during the day", -]; - -const FUNFACTS_URL: &str = "https://data-v2.polyfrost.org/oneclient/funfacts.txt"; - -fn normalize_line_breaks(value: &str) -> String { - value.replace("/n", "\n").replace("\\n", "\n") -} - -fn parse_funfacts(text: &str) -> Vec { - text.lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(normalize_line_breaks) - .collect() -} - -/// A random tip that rotates in step with the backdrop art -pub(super) fn use_onboarding_tip() -> String { - let mut tips = use_state(|| { - ONBOARDING_TIPS_BACKUP - .iter() - .map(|tip| normalize_line_breaks(tip)) - .collect::>() - }); - - use_hook(move || { - spawn(async move { - let Ok(state) = crate::launcher::state() else { - return; - }; - match state - .services - .requester - .http() - .get(FUNFACTS_URL) - .send() - .await - { - Ok(res) if res.status().is_success() => match res.text().await { - Ok(text) => { - let parsed = parse_funfacts(&text); - if !parsed.is_empty() { - tips.set(parsed); - } - } - Err(err) => { - tracing::warn!("onboarding tips: reading body failed: {err}"); - } - }, - Ok(res) => { - tracing::warn!("onboarding tips: unexpected status {}", res.status()); - } - Err(err) => { - tracing::warn!("onboarding tips: request failed, using backup facts: {err}"); - } - } - }); - }); - - // Random offset against the shared tick so the run does not always open on - // the same fact - let seed = use_hook(|| { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.subsec_nanos() as usize) - .unwrap_or(0) - }); - let mut tick = use_state(rotation_tick); - - let stop = use_hook(|| Arc::new(AtomicBool::new(false))); - - use_drop({ - let stop = stop.clone(); - move || stop.store(true, Ordering::Relaxed) - }); - - use_hook({ - let stop = stop.clone(); - move || { - spawn(async move { - loop { - tokio::time::sleep(next_rotation_delay()).await; - - if stop.load(Ordering::Relaxed) { - break; - } - - tick.set(rotation_tick()); - } - }); - } - }); - - // Wrapped at read time the list is swapped out once the remote facts land - let list = tips.read(); - if list.is_empty() { - String::new() - } else { - list[seed.wrapping_add(*tick.read()) % list.len()].clone() - } -} - -pub(super) fn tip_panel(tip: String) -> impl IntoElement { - rect() - .vertical() - .width(Size::px(320.)) - .spacing(4.) - .cross_align(Alignment::End) - .child( - label() - .text("TIP") - .width(Size::fill()) - .font_size(11.) - .font_weight(FontWeight::SEMI_BOLD) - .text_align(TextAlign::Right) - .color(colors::fg_secondary()), - ) - .child( - label() - .text(tip) - .width(Size::fill()) - .font_size(13.) - .text_align(TextAlign::Right) - .color(colors::fg_secondary()), - ) -} diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs index 7473df77..4eefd147 100644 --- a/packages/oneclient_app/src/view/onboarding/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/mod.rs @@ -1,11 +1,11 @@ mod account; mod bundles; -mod downloading; mod language; mod location; mod migration; mod preferences; mod selection; +mod summary; mod terms; #[cfg(test)] pub(crate) mod test_support; @@ -18,12 +18,12 @@ pub(crate) use selection::{ pub use account::OnboardingAccount; pub use bundles::OnboardingBundles; -pub use downloading::{LoadingBackdrop, OnboardingDownloading}; pub use language::OnboardingLanguage; pub use location::OnboardingLocation; pub use migration::OnboardingMigration; pub(crate) use migration::matching_new_cluster_id; pub use preferences::OnboardingPreferences; +pub use summary::OnboardingSummary; pub use terms::OnboardingTerms; pub use welcome::OnboardingWelcome; @@ -34,7 +34,7 @@ use freya::prelude::*; use freya::router::{RouterContext, use_route}; use crate::Route; -use crate::components::{Button, Icon, IconType, toggle}; +use crate::components::{Button, Icon, IconType}; use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection}; use crate::theme::colors; use crate::ui::{border_all_color, entrance_motion_layer}; @@ -56,7 +56,7 @@ pub fn onboarding_step_index(route: &Route, has_migration: bool, picks_location: Route::OnboardingAccount {} => 3 + shift, Route::OnboardingBundles {} => 4 + shift, Route::OnboardingPreferences {} => 5 + shift, - Route::OnboardingDownloading {} => 6 + shift, + Route::OnboardingSummary {} => 6 + shift, _ => 0, } } @@ -207,44 +207,6 @@ pub(crate) fn onboarding_nav_action( .into_element() } -pub(crate) fn predownload_toggle_row(predownload: State) -> impl IntoElement { - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(16.) - .content(Content::Flex) - .padding(Gaps::new_symmetric(12., 16.)) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .child( - rect() - .vertical() - .width(Size::flex(1.0)) - .spacing(3.) - .child( - label() - .text("Download mods now") - .font_size(14.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_primary()), - ) - .child( - label() - .text( - "Downloads your chosen mods now. Minecraft itself is always \ - downloaded the first time you play a version. Turn this off to \ - leave the mods until then too.", - ) - .font_size(11.) - .color(colors::fg_secondary()), - ), - ) - .child(toggle(predownload)) - .into_element() -} - pub(crate) fn choice_row( title: &str, subtitle: &str, @@ -389,7 +351,7 @@ mod tests { Route::OnboardingAccount {}, Route::OnboardingBundles {}, Route::OnboardingPreferences {}, - Route::OnboardingDownloading {}, + Route::OnboardingSummary {}, ]); routes diff --git a/packages/oneclient_app/src/view/onboarding/preferences.rs b/packages/oneclient_app/src/view/onboarding/preferences.rs index e5c461cd..bf5349e0 100644 --- a/packages/oneclient_app/src/view/onboarding/preferences.rs +++ b/packages/oneclient_app/src/view/onboarding/preferences.rs @@ -77,7 +77,7 @@ impl Component for OnboardingPreferences { content, onboarding_nav( Some(Route::OnboardingBundles {}), - Route::OnboardingDownloading {}, + Route::OnboardingSummary {}, true, ), ) diff --git a/packages/oneclient_app/src/view/onboarding/summary/mod.rs b/packages/oneclient_app/src/view/onboarding/summary/mod.rs new file mode 100644 index 00000000..0a5d0efe --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/summary/mod.rs @@ -0,0 +1,479 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::{BundleArchive, BundleFile, ImportTarget, MigrationSource, SentryExclusion}; +use oneclient_db::models::OverrideType; + +use crate::hooks::{ + Actions, ClusterBundles, invalidate_cluster_queries, migration_detection, + onboarding_bundles_items, try_default_account, use_current_account, use_dispatch, + use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot, +}; +use crate::routes::Route; +use crate::view::onboarding::{matching_new_cluster_id, pkg_key}; + +mod view; +use view::{SummaryView, summary_view}; + +#[derive(Clone, PartialEq)] +struct ClusterPlan { + cluster_id: i64, + /// `(bundle_name, package_id, override)` for every file whose fate differs from the manifest default + overrides: Vec<(String, String, OverrideType)>, +} + +#[derive(PartialEq)] +pub struct OnboardingSummary; + +impl Component for OnboardingSummary { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let bundles_query = use_onboarding_bundles(); + let migration_query = use_migration(); + let selection = use_onboarding_selection(); + let settings = use_settings_snapshot().settings; + let account_query = use_current_account(); + + let selected_state = selection.selected; + let mut attempted = use_state(|| false); + let mut finishing = use_state(|| false); + let mut failure = use_state(|| None::); + + let loaded = onboarding_bundles_items(&bundles_query); + let ready = loaded.is_some(); + let items = loaded.unwrap_or_default(); + let selected = selected_state.read().clone(); + let language = selection.language.read().clone(); + let reduce_motion = *selection.reduce_motion.read(); + let account_name = try_default_account(&account_query) + .map(|account| account.username.clone()) + .unwrap_or_else(|| "Not signed in".to_string()); + + let import_dispatch = dispatch.clone(); + let import_folder = selection.import_folder; + let import_dedicated = selection.import_dedicated; + let import_detection = migration_detection(&migration_query); + + let migration_summary = import_detection.as_ref().map(|detection| { + let source_name = detection.source.display_name().to_string(); + let is_vanilla = detection.source == MigrationSource::Vanilla; + match import_folder.read().clone() { + Some(folder) => { + let dedicated = !is_vanilla + && *import_dedicated.read() + && detection + .instances + .iter() + .find(|c| c.folder_name == folder) + .and_then(|inst| matching_new_cluster_id(inst, &items)) + .is_some(); + let target = if dedicated { + "This version only" + } else { + "Shared game directory" + }; + let files = if is_vanilla { + "Your Minecraft folder".to_string() + } else { + folder + }; + (source_name, files, target.to_string()) + } + None => (source_name, "No files imported".to_string(), String::new()), + } + }); + + let finish_items = items.clone(); + let finish_versions = seen_versions(&items); + let finish_dispatch = dispatch.clone(); + let continue_versions = finish_versions.clone(); + let continue_dispatch = dispatch; + + summary_view( + SummaryView { + items: &items, + selected: &selected, + language: &language, + reduce_motion, + parallax: settings.dynamic_background_enabled, + account_name, + migration: migration_summary, + ready, + finishing: *finishing.read(), + failure: failure.read().clone(), + }, + move |_| { + if *finishing.peek() { + return; + } + + if !*attempted.peek() { + if let (Some(detection), Some(folder)) = + (import_detection.as_ref(), import_folder.peek().clone()) + { + let target = if detection.source != MigrationSource::Vanilla + && *import_dedicated.peek() + { + detection + .instances + .iter() + .find(|c| c.folder_name == folder) + .and_then(|inst| matching_new_cluster_id(inst, &finish_items)) + .map(|new_cluster_id| ImportTarget::Dedicated { new_cluster_id }) + .unwrap_or(ImportTarget::Shared) + } else { + ImportTarget::Shared + }; + import_dispatch.import_launcher(detection.source, folder, target); + } + attempted.set(true); + } + + failure.set(None); + finishing.set(true); + finish_setup( + build_plans(&finish_items, &selected_state.peek()), + finish_versions.clone(), + finish_dispatch.clone(), + finishing, + failure, + ); + }, + move |_| { + if *finishing.peek() { + return; + } + finishing.set(true); + // Leaves the manifest defaults in place the picks are already lost + finish_setup( + Vec::new(), + continue_versions.clone(), + continue_dispatch.clone(), + finishing, + failure, + ); + }, + ) + .into_element() + } +} + +fn build_plans( + items: &[ClusterBundles], + selected: &std::collections::HashSet, +) -> Vec { + items + .iter() + .map(|cb| { + let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected); + let mut overrides = Vec::new(); + for archive in &cb.archives { + overrides.extend(archive_overrides(cb.cluster.id, archive, selected, &kept)); + } + ClusterPlan { + cluster_id: cb.cluster.id, + overrides, + } + }) + .collect() +} + +fn seen_versions(items: &[ClusterBundles]) -> Vec { + let mut versions: Vec = items + .iter() + .map(|cb| cb.cluster.mc_version.clone()) + .collect(); + + versions.sort(); + versions.dedup(); + versions +} + +/// True once anything visible in the bundle was taken including one opted-in extra that would otherwise lose its dependencies +fn bundle_taken( + cluster_id: i64, + archive: &BundleArchive, + selected: &std::collections::HashSet, +) -> bool { + archive.manifest.files.iter().any(|file| { + !file.hidden + && selected.contains(&pkg_key( + cluster_id, + &archive.manifest.name, + &file.kind.package_id(), + )) + }) +} + +/// Bundles overlap deciding each archive alone recorded a removal for a package an accepted bundle was about to install +fn kept_package_ids( + cluster_id: i64, + archives: &[BundleArchive], + selected: &std::collections::HashSet, +) -> std::collections::HashSet { + let mut kept = std::collections::HashSet::new(); + + for archive in archives { + let takes_hidden = bundle_taken(cluster_id, archive, selected); + for file in &archive.manifest.files { + let package_id = file.kind.package_id(); + let wanted = if file.hidden { + takes_hidden + } else { + selected.contains(&pkg_key(cluster_id, &archive.manifest.name, &package_id)) + }; + if wanted { + kept.insert(package_id); + } + } + } + + kept +} + +fn archive_overrides( + cluster_id: i64, + archive: &BundleArchive, + selected: &std::collections::HashSet, + kept_elsewhere: &std::collections::HashSet, +) -> Vec<(String, String, OverrideType)> { + let bundle_name = &archive.manifest.name; + let wants = |file: &BundleFile| { + selected.contains(&pkg_key(cluster_id, bundle_name, &file.kind.package_id())) + }; + + // Hidden files follow the bundle kept if anything from it was taken dropped if not + let takes_hidden = bundle_taken(cluster_id, archive, selected); + + let mut overrides = Vec::new(); + for file in &archive.manifest.files { + let wanted = if file.hidden { + takes_hidden + } else { + wants(file) + }; + + let override_type = match (wanted, file.enabled) { + // Matches the manifest default nothing to record + (true, true) | (false, false) => continue, + // Opting into a mod the bundle ships turned off + (true, false) => OverrideType::Enabled, + // Only really declined if no bundle the user took ships the same package + (false, true) => { + if kept_elsewhere.contains(&file.kind.package_id()) { + continue; + } + OverrideType::Removed + } + }; + + overrides.push((bundle_name.clone(), file.kind.package_id(), override_type)); + } + overrides +} + +/// The bundle picks are written nowhere else so a failure has to stop the run rather than drop them silently +fn finish_setup( + plans: Vec, + versions: Vec, + dispatch: Actions, + mut finishing: State, + mut failure: State>, +) { + spawn(async move { + for plan in plans.iter().filter(|p| !p.overrides.is_empty()) { + if let Err(err) = apply_overrides(plan).await { + // Expected failures (a busy or unwritable database) are not crashes warn! is a breadcrumb error! reports to Sentry + if err.is_sentry_excluded() { + tracing::warn!( + cluster_id = plan.cluster_id, + "saving package choices failed (expected): {err}" + ); + } else { + tracing::error!( + cluster_id = plan.cluster_id, + "saving package choices failed: {err}" + ); + } + failure.set(Some(err.to_string())); + finishing.set(false); + return; + } + } + + invalidate_cluster_queries().await; + + for version in versions { + dispatch.record_seen_version(version); + } + + dispatch.mark_onboarding_seen(); + let _ = RouterContext::get().replace(Route::Home {}); + }); +} + +async fn apply_overrides(plan: &ClusterPlan) -> oneclient_core::LauncherResult<()> { + let state = crate::launcher::state()?; + oneclient_core::set_bundle_package_overrides( + plan.cluster_id, + &plan.overrides, + &state.services.content(), + ) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::{archive, cluster, file}; + use std::collections::HashSet; + + fn skyblock() -> BundleArchive { + archive( + "SkyBlock", + false, + vec![ + file("skyblock-main", true, false), + file("skycubed", false, false), + file("sb-dep", true, true), + ], + ) + } + + fn items(archives: Vec) -> Vec { + vec![ClusterBundles { + cluster: cluster(1), + archives, + }] + } + + fn keys(archive: &BundleArchive, pids: &[&str]) -> HashSet { + pids.iter() + .map(|p| pkg_key(1, &archive.manifest.name, p)) + .collect() + } + + #[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 ov = &plans[0].overrides; + // The hidden dependency must be dropped too or it installs anyway + assert!(ov.contains(&( + sb.manifest.name.clone(), + "sb-dep".to_string(), + OverrideType::Removed + ))); + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skyblock-main".to_string(), + OverrideType::Removed + ))); + assert!(!ov.iter().any(|(_, pid, _)| pid == "skycubed")); + } + + #[test] + 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); + + assert!(plans[0].overrides.is_empty()); + } + + #[test] + 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); + + assert_eq!( + plans[0].overrides, + vec![( + sb.manifest.name.clone(), + "skycubed".to_string(), + OverrideType::Enabled + )] + ); + } + + #[test] + 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 ov = &plans[0].overrides; + + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skycubed".to_string(), + OverrideType::Enabled + ))); + assert!(!ov.iter().any(|(_, pid, _)| pid == "sb-dep")); + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skyblock-main".to_string(), + OverrideType::Removed + ))); + } + + #[test] + fn rejections_are_removed_never_disabled() { + let plans = build_plans(&items(vec![skyblock()]), &HashSet::new()); + + assert!( + !plans[0] + .overrides + .iter() + .any(|(_, _, ty)| *ty == OverrideType::Disabled) + ); + } + + #[test] + fn per_cluster_choices_are_independent() { + let sb = skyblock(); + let both = vec![ + ClusterBundles { + cluster: cluster(1), + archives: vec![sb.clone()], + }, + ClusterBundles { + cluster: cluster(2), + archives: vec![sb.clone()], + }, + ]; + let selected: HashSet = [pkg_key(1, &sb.manifest.name, "skyblock-main")].into(); + let plans = build_plans(&both, &selected); + + assert!(plans[0].overrides.is_empty()); + assert!( + plans[1] + .overrides + .iter() + .any(|(_, pid, ty)| pid == "skyblock-main" && *ty == OverrideType::Removed) + ); + } + + #[test] + fn every_version_is_recorded_once() { + let mut second = cluster(2); + second.mc_version = "1.20.1".to_string(); + let items = vec![ + ClusterBundles { + cluster: cluster(1), + archives: Vec::new(), + }, + ClusterBundles { + cluster: second, + archives: Vec::new(), + }, + ClusterBundles { + cluster: cluster(3), + archives: Vec::new(), + }, + ]; + + assert_eq!(seen_versions(&items), vec!["1.20.1", "1.21.11"]); + } +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs b/packages/oneclient_app/src/view/onboarding/summary/view.rs similarity index 70% rename from packages/oneclient_app/src/view/onboarding/downloading/summary.rs rename to packages/oneclient_app/src/view/onboarding/summary/view.rs index d0b44c1a..f6381c7f 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs +++ b/packages/oneclient_app/src/view/onboarding/summary/view.rs @@ -1,5 +1,4 @@ -use super::*; - +use freya::prelude::*; use freya::router::RouterContext; use crate::components::{ART_PREVIEW_EDGE, Button, DynamicArt, Icon, IconType, ScrollArea}; @@ -7,22 +6,40 @@ use crate::hooks::ClusterBundles; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; -use crate::utils::format_size; -use crate::view::onboarding::{onboarding_illustration, predownload_toggle_row, step_heading}; +use crate::view::onboarding::{onboarding_illustration, onboarding_slide, step_heading}; + +pub(super) struct SummaryView<'a> { + pub items: &'a [ClusterBundles], + pub selected: &'a std::collections::HashSet, + pub language: &'a str, + pub reduce_motion: bool, + pub parallax: bool, + pub account_name: String, + pub migration: Option<(String, String, String)>, + pub ready: bool, + /// A run is in flight both buttons stay down until it settles + pub finishing: bool, + pub failure: Option, +} -#[allow(clippy::too_many_arguments)] pub(super) fn summary_view( - items: &[ClusterBundles], - selected: &std::collections::HashSet, - versions: &[VersionMetadata], - language: &str, - reduce_motion: bool, - parallax: bool, - account_name: String, - migration: Option<(String, String, String)>, - predownload: State, - on_setup: impl FnMut(Event) + 'static, + view: SummaryView<'_>, + on_finish: impl FnMut(Event) + 'static, + on_continue: impl FnMut(Event) + 'static, ) -> impl IntoElement { + let SummaryView { + items, + selected, + language, + reduce_motion, + parallax, + account_name, + migration, + ready, + finishing, + failure, + } = view; + let version_rows: Vec = items .iter() .map(|cb| { @@ -35,32 +52,6 @@ pub(super) fn summary_view( }) .collect(); - 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!( - "{scope} downloaded now, during setup. About {} total.{rest}", - format_size(est) - ) - } else { - "Content is downloaded the first time you launch each version.".to_string() - }; - let migration_section: Element = match migration { Some((source, folder, target)) => { let mut rows = vec![ @@ -75,6 +66,8 @@ pub(super) fn summary_view( None => rect().into_element(), }; + let failed = failure.is_some(); + let content = rect() .vertical() .width(Size::fill()) @@ -109,26 +102,26 @@ pub(super) fn summary_view( )) .child(summary_section( "Packages per version", - Some(packages_note.as_str()), + Some("Content is downloaded the first time you launch each version."), if version_rows.is_empty() { vec![summary_line("No versions", "")] } else { version_rows }, - )) - .child(predownload_toggle_row(predownload)), - ); + )), + ) + .maybe_child(failure.map(|reason| failure_notice(&reason))); rect() .vertical() .width(Size::fill()) .height(Size::fill()) .content(Content::Flex) - .child( + .child(onboarding_slide( rect() .horizontal() .width(Size::fill()) - .height(Size::flex(1.0)) + .height(Size::fill()) .content(Content::Flex) .child( rect() @@ -147,7 +140,7 @@ pub(super) fn summary_view( .padding(Gaps::new(48., 80., 24., 24.)) .child(content), ), - ) + )) .child( rect() .horizontal() @@ -156,27 +149,66 @@ pub(super) fn summary_view( .cross_align(Alignment::Center) .spacing(12.) .padding(Gaps::new(0., 40., 32., 40.)) - .child( + .child(if failed { + Button::new() + .secondary() + .width(Size::px(180.)) + .enabled(ready && !finishing) + .on_press(on_continue) + .text("Continue anyway") + .into_element() + } else { Button::new() .secondary() .width(Size::px(128.)) + .enabled(!finishing) .on_press(move |_| { let _ = RouterContext::get().replace(Route::OnboardingPreferences {}); }) - .text("Back"), - ) + .text("Back") + .into_element() + }) .child( Button::new() .primary() .width(Size::px(160.)) - .on_press(on_setup) - .text("Setup") - .child(Icon::new(IconType::Download01).size(16.)), + .enabled(ready && !finishing) + .on_press(on_finish) + .text(if failed { "Try again" } else { "Finish" }) + .child(Icon::new(IconType::Check).size(16.)), ), ) .child(prefetch_art(items)) } +fn failure_notice(reason: &str) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new_all(14.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::danger().with_a(26)) + .border(border_all_color(1., colors::danger().with_a(128))) + .child( + label() + .text("Your package choices could not be saved.") + .font_size(14.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{}. Continuing anyway keeps the bundle defaults for any version whose choices were not saved.", + reason.trim_end_matches('.') + )) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .into_element() +} + fn prefetch_art(items: &[ClusterBundles]) -> impl IntoElement { rect() .horizontal() diff --git a/packages/oneclient_core/src/versions/manifest.rs b/packages/oneclient_core/src/versions/manifest.rs index 33ed2006..fc014155 100644 --- a/packages/oneclient_core/src/versions/manifest.rs +++ b/packages/oneclient_core/src/versions/manifest.rs @@ -24,9 +24,6 @@ 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, } @@ -46,8 +43,6 @@ pub struct RemoteEntry { pub long_description: Option, #[serde(default)] pub tags: Option>, - #[serde(default)] - pub predownload: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -107,8 +102,6 @@ pub struct VersionMetadata { pub art_url: Option, pub long_description: Option, pub tags: Vec, - /// Fetched up front during onboarding rather than on first launch - pub predownload: bool, } impl VersionMetadata { @@ -151,7 +144,6 @@ impl VersionsManifest { art_url: art_url(&cluster.art, meta_url_base), long_description: cluster.long_description.clone(), tags: cluster.tags.clone(), - predownload: cluster.predownload.unwrap_or(false), }); for entry in &cluster.entries { @@ -168,7 +160,6 @@ 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), }); } } @@ -215,39 +206,6 @@ 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("https://example.test"); - let flag = |major: u32, minor: Option| { - metadata - .iter() - .find(|m| m.major_version == major && m.minor_version == minor) - .expect("row present") - .predownload - }; - - assert!(flag(26, None)); - assert!(flag(26, Some(1))); - assert!(!flag(26, Some(2))); - assert!(flag(21, Some(11))); - assert!(!flag(21, None)); - assert!(!flag(21, Some(1))); - } - #[test] fn mc_version_skips_the_cluster_row() { let manifest: VersionsManifest = serde_json::from_str(