From c338d09c608bff082d696c72c72939fa3c34d3b9 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:19:33 +0200 Subject: [PATCH] feat(ui): Made the download notification more descriptive --- packages/oneclient_app/src/bridge/runtime.rs | 6 +- .../src/components/notifications.rs | 91 ++++++- packages/oneclient_app/src/notifications.rs | 222 ++++++++++++++---- .../src/view/onboarding/downloading/mod.rs | 1 + .../oneclient_core/src/bundles/install.rs | 13 +- .../oneclient_core/src/bundles/updates.rs | 23 +- packages/oneclient_core/src/dev.rs | 2 + packages/oneclient_core/src/game/download.rs | 24 +- packages/oneclient_core/src/game/metadata.rs | 32 ++- .../oneclient_core/src/notification/data.rs | 41 ++++ .../src/notification/grouped.rs | 41 +++- .../oneclient_core/src/notification/mod.rs | 2 +- 12 files changed, 417 insertions(+), 81 deletions(-) diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index ded6564e..2e6cbf36 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -854,7 +854,11 @@ async fn install_package( format!("Installing {}", project.name), ); let size = version.primary_file().map(|f| f.size).unwrap_or(0); - let child = session.child(project.name.clone(), size); + let child = session.child( + project.name.clone(), + size, + oneclient_core::notification::TaskCategory::Packages, + ); let result = PackageStore::install_to_cluster( provider, diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index ae17e46c..5f9d5411 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -9,8 +9,9 @@ use crate::{ Route, components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea}, hooks::{use_dispatch, use_notifications_snapshot}, - notifications::{InboxEntry, NotificationActionKind}, + notifications::{InboxEntry, NotificationActionKind, TransferStats}, theme::colors, + utils::{format_duration_hms, format_size}, }; #[derive(PartialEq)] @@ -212,15 +213,7 @@ fn tasks_section(entry: &InboxEntry, mut expanded: State) -> impl IntoElem let now = !*expanded.peek(); expanded.set(now); }) - .child( - Icon::new(if is_expanded { - IconType::ChevronDown - } else { - IconType::ChevronRight - }) - .size(14.) - .color(colors::fg_secondary()), - ) + .child(ChevronToggle { expanded: is_expanded }) .child( label() .text(if is_expanded { @@ -269,6 +262,14 @@ fn task_row(task: &crate::notifications::TaskView) -> impl IntoElement { .width(Size::flex(1.0)) .color(colors::fg_primary()), ) + .maybe(task.total_count > 1, |el| { + el.child( + label() + .text(format!("{}/{}", task.done_count, task.total_count)) + .font_size(10.) + .color(colors::fg_secondary()), + ) + }) .child( label() .text(format!("{percent}%")) @@ -278,6 +279,35 @@ fn task_row(task: &crate::notifications::TaskView) -> impl IntoElement { .into_element() } +/// A right-facing chevron that rotates 90° down when `expanded`, animated. +#[derive(PartialEq)] +struct ChevronToggle { + expanded: bool, +} + +impl Component for ChevronToggle { + fn render(&self) -> impl IntoElement { + let expanded = self.expanded; + let anim = use_animation(|_| AnimNum::new(0., 90.).time(160).ease(Ease::Out)); + + use_side_effect_with_deps(&expanded, move |&expanded| { + let mut anim = anim; + if expanded { + anim.start(); + } else { + anim.reverse(); + } + }); + + let deg = anim.get().value(); + rect().rotate(deg).child( + Icon::new(IconType::ChevronRight) + .size(14.) + .color(colors::fg_secondary()), + ) + } +} + fn phase_badge(phase: &str) -> impl IntoElement { let color = match phase { "Verifying" => colors::code_warn(), @@ -353,10 +383,9 @@ fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoEle fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement { if let Some((current, total)) = entry.progress.filter(|_| entry.is_loading) { let frac = (current as f32 / total.max(1) as f32).clamp(0.0, 1.0); - return rect() + let bar = rect() .width(Size::fill()) .height(Size::px(6.)) - .margin(Gaps::new(8., 0., 0., 0.)) .corner_radius(CornerRadius::new_all(50.)) .background(colors::brand().with_a(128)) .child( @@ -365,7 +394,15 @@ fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoEl .height(Size::px(6.)) .corner_radius(CornerRadius::new_all(50.)) .background(colors::brand()), - ) + ); + + return rect() + .vertical() + .width(Size::fill()) + .spacing(5.) + .margin(Gaps::new(8., 0., 0., 0.)) + .child(bar) + .maybe_child(entry.transfer.map(transfer_footer)) .into_element(); } @@ -412,6 +449,34 @@ fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoEl rect().into_element() } +fn transfer_footer(stats: TransferStats) -> Element { + let speed = format!("{}/s", format_size(stats.speed_bps as u64)); + let eta = stats + .eta_secs + .map(|secs| format!("{} left", format_duration_hms(secs as i64))); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .child( + label() + .text(speed) + .font_size(10.) + .color(colors::fg_secondary()), + ) + .maybe_child(eta.map(|eta| { + label() + .text(eta) + .font_size(10.) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element() +} + fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { match kind { NotificationActionKind::OpenClusterUpdate(summary) => { diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 4b829575..2915d5b0 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use oneclient_core::notification::{ - GroupedProgressEvent, Notification, NotificationLevel, PromptKind, UserChoice, + GroupedProgressEvent, Notification, NotificationLevel, PromptKind, TaskCategory, UserChoice, }; use oneclient_db::models::ClusterId; use tokio::sync::oneshot; @@ -51,14 +51,26 @@ pub struct InboxEntry { pub created_at: Instant, pub actions: Vec, pub tasks: Vec, + /// Live transfer stats for grouped downloads (bytes/sec, seconds remaining). + pub transfer: Option, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TransferStats { + pub speed_bps: f64, + pub eta_secs: Option, +} + +/// One row in the expandable task list — an aggregate over all children of a +/// single [`TaskCategory`] (e.g. all libraries collapse into one "Libraries" row). #[derive(Clone, Debug, PartialEq)] pub struct TaskView { pub label: String, pub phase: &'static str, pub current: u64, pub total: u64, + pub done_count: u64, + pub total_count: u64, } #[derive(Clone, Debug, PartialEq)] @@ -137,28 +149,97 @@ pub struct NotificationState { cluster_update: Option, } +/// Fixed display order for the category rows. +const CATEGORY_ORDER: [TaskCategory; 6] = [ + TaskCategory::Client, + TaskCategory::Libraries, + TaskCategory::Natives, + TaskCategory::Assets, + TaskCategory::Metadata, + TaskCategory::Packages, +]; + +struct ChildRec { + category: TaskCategory, + phase: &'static str, + current: u64, + total: u64, +} + #[derive(Default)] struct GroupedTasks { title: String, - children: HashMap, - seen: u64, - done: u64, - done_units: u64, - current: Option, + children: HashMap, + done_units: HashMap, + done_count: HashMap, + seen_count: HashMap, + /// Expected counts/bytes announced up-front so totals don't climb mid-download. + reserved_count: HashMap, + reserved_units: HashMap, + /// (timestamp, completed bytes) of the last speed sample. + last_sample: Option<(Instant, u64)>, + speed_bps: f64, } impl GroupedTasks { + /// Aggregate live + finished children into one row per category, in display order. fn task_list(&self) -> Vec { - let mut tasks: Vec = self.children.values().cloned().collect(); - tasks.sort_by(|a, b| a.label.cmp(&b.label)); - tasks + CATEGORY_ORDER + .iter() + .filter_map(|&cat| { + let seen_count = self.seen_count.get(&cat).copied().unwrap_or(0); + let reserved_count = self.reserved_count.get(&cat).copied().unwrap_or(0); + let total_count = seen_count.max(reserved_count); + if total_count == 0 { + return None; + } + let done_units = self.done_units.get(&cat).copied().unwrap_or(0); + let done_count = self.done_count.get(&cat).copied().unwrap_or(0); + let reserved_units = self.reserved_units.get(&cat).copied().unwrap_or(0); + + let live: Vec<&ChildRec> = + self.children.values().filter(|c| c.category == cat).collect(); + let live_current: u64 = live.iter().map(|c| c.current.min(c.total)).sum(); + let live_total: u64 = live.iter().map(|c| c.total).sum(); + + // Surface the most advanced phase among live children; default Downloading. + let phase = live + .iter() + .map(|c| c.phase) + .find(|p| *p != "Downloading") + .unwrap_or("Downloading"); + + Some(TaskView { + label: cat.label().to_string(), + phase, + current: done_units + live_current, + // Reserved total keeps the denominator stable while children are + // still being added; fall back to what we've actually seen. + total: reserved_units.max(done_units + live_total), + done_count, + total_count, + }) + }) + .collect() } - fn describe(task: &TaskView) -> String { - if task.phase == "Verifying" { - format!("Verifying {} checksum", task.label) + /// Which coarse group is currently downloading, for the notification body. + fn active_body(&self) -> Option<&'static str> { + let mut minecraft = false; + let mut packages = false; + for child in self.children.values() { + if child.category.is_minecraft() { + minecraft = true; + } else { + packages = true; + } + } + if minecraft { + Some("Downloading Minecraft") + } else if packages { + Some("Downloading Packages") } else { - format!("{} {}", task.phase, task.label) + None } } } @@ -359,6 +440,7 @@ impl NotificationState { created_at: Instant::now(), actions: Vec::new(), tasks: Vec::new(), + transfer: None, }, ); id @@ -392,6 +474,7 @@ impl NotificationState { created_at: Instant::now(), actions, tasks: Vec::new(), + transfer: None, }, ); @@ -506,22 +589,39 @@ impl NotificationState { }, ); } - GroupedProgressEvent::AddChild { + GroupedProgressEvent::Expect { session_id, - child_id, - label, + category, + count, total, } => { if let Some(group) = self.grouped_tasks.get_mut(&session_id) { - let task = TaskView { - label, - phase: "Downloading", - current: 0, - total: total.max(1), - }; - group.current = Some(GroupedTasks::describe(&task)); - group.children.insert(child_id, task); - group.seen += 1; + *group.reserved_count.entry(category).or_default() += count; + *group.reserved_units.entry(category).or_default() += total; + } + self.refresh_grouped_entry(inbox, session_id); + if let Some(&entry_id) = self.grouped_entries.get(&session_id) { + self.ensure_progress_toast(entry_id); + } + } + GroupedProgressEvent::AddChild { + session_id, + child_id, + label: _, + total, + category, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) { + group.children.insert( + child_id, + ChildRec { + category, + phase: "Downloading", + current: 0, + total: total.max(1), + }, + ); + *group.seen_count.entry(category).or_default() += 1; } self.refresh_grouped_entry(inbox, session_id); if let Some(&entry_id) = self.grouped_entries.get(&session_id) { @@ -539,8 +639,6 @@ impl NotificationState { { task.current = current; task.total = total.max(1); - let desc = GroupedTasks::describe(task); - group.current = Some(desc); } self.refresh_grouped_entry(inbox, session_id); } @@ -553,8 +651,6 @@ impl NotificationState { && let Some(task) = group.children.get_mut(&child_id) { task.phase = phase.label(); - let desc = GroupedTasks::describe(task); - group.current = Some(desc); } self.refresh_grouped_entry(inbox, session_id); } @@ -565,8 +661,8 @@ impl NotificationState { if let Some(group) = self.grouped_tasks.get_mut(&session_id) && let Some(task) = group.children.remove(&child_id) { - group.done += 1; - group.done_units += task.total; + *group.done_units.entry(task.category).or_default() += task.total; + *group.done_count.entry(task.category).or_default() += 1; } self.refresh_grouped_entry(inbox, session_id); } @@ -584,40 +680,70 @@ impl NotificationState { entry.progress = Some((1, 1)); entry.is_loading = false; entry.tasks = Vec::new(); + entry.transfer = None; } } } } } - fn refresh_grouped_entry(&self, inbox: &mut [InboxEntry], session_id: Uuid) { + fn refresh_grouped_entry(&mut self, inbox: &mut [InboxEntry], session_id: Uuid) { let Some(&entry_id) = self.grouped_entries.get(&session_id) else { return; }; - let Some(group) = self.grouped_tasks.get(&session_id) else { - return; - }; - let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) else { + let Some(group) = self.grouped_tasks.get_mut(&session_id) else { return; }; let tasks = group.task_list(); - entry.title = group.title.clone(); - entry.body = group - .current - .clone() + let body = group + .active_body() + .map(str::to_string) .unwrap_or_else(|| "Preparing...".to_string()); - let completed: u64 = group.done_units - + group - .children - .values() - .map(|t| t.current.min(t.total)) - .sum::(); - let total: u64 = group.done_units + group.children.values().map(|t| t.total).sum::(); - entry.progress = Some((completed, total.max(1))); + let title = group.title.clone(); + + let completed: u64 = tasks.iter().map(|t| t.current.min(t.total)).sum(); + let total: u64 = tasks.iter().map(|t| t.total).sum::().max(1); + + // Smooth the transfer rate with an EMA so the readout doesn't jitter. + let now = Instant::now(); + let transfer = match group.last_sample { + Some((t0, b0)) => { + let dt = now.duration_since(t0).as_secs_f64(); + if dt >= 0.25 { + let inst = completed.saturating_sub(b0) as f64 / dt; + group.speed_bps = if group.speed_bps <= 0.0 { + inst + } else { + group.speed_bps * 0.7 + inst * 0.3 + }; + group.last_sample = Some((now, completed)); + } + let speed = group.speed_bps; + (speed >= 1.0).then(|| { + let remaining = total.saturating_sub(completed); + TransferStats { + speed_bps: speed, + eta_secs: Some((remaining as f64 / speed) as u64), + } + }) + } + None => { + group.last_sample = Some((now, completed)); + None + } + }; + + let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) else { + return; + }; + entry.title = title; + entry.body = body; + entry.progress = Some((completed, total)); entry.is_loading = true; entry.read = false; entry.tasks = tasks; + entry.transfer = transfer; } } diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index 5db872fa..f5e096bf 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -802,6 +802,7 @@ fn apply_grouped(agg: &mut GroupedAgg, event: GroupedProgressEvent) { agg.children.clear(); agg.done_units = 0; } + GroupedProgressEvent::Expect { .. } => {} GroupedProgressEvent::AddChild { child_id, label, diff --git a/packages/oneclient_core/src/bundles/install.rs b/packages/oneclient_core/src/bundles/install.rs index c328d7a2..e17eb962 100644 --- a/packages/oneclient_core/src/bundles/install.rs +++ b/packages/oneclient_core/src/bundles/install.rs @@ -10,7 +10,7 @@ use crate::bundles::error::BundleError; use crate::bundles::manager::BundlesManager; use crate::bundles::overrides; use crate::bundles::types::{BundleArchive, BundleFile, BundleFileKind}; -use crate::notification::{GroupedProgressChild, GroupedProgressSession, TaskPhase}; +use crate::notification::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; use crate::packages::domain::{ContentType, GameLoader}; use crate::packages::error::PackageError; use crate::packages::store::{PackageStore, unlink_cluster_file}; @@ -233,10 +233,19 @@ pub async fn install_enabled_bundle_files( "installing enabled bundle files" ); + if let Some(p) = progress { + let reserved_bytes: u64 = to_install.iter().map(|f| f.size.max(1)).sum(); + p.expect(TaskCategory::Packages, to_install.len() as u64, reserved_bytes); + } + let bundle_name = &bundle_name; let results = futures_util::stream::iter(to_install.into_iter().map(|file| async move { let child = progress.map(|p| { - let c = p.child(format!("Mod {}", file.display_name()), file.size.max(1)); + let c = p.child( + format!("Mod {}", file.display_name()), + file.size.max(1), + crate::notification::TaskCategory::Packages, + ); c.set_phase(TaskPhase::Downloading); c }); diff --git a/packages/oneclient_core/src/bundles/updates.rs b/packages/oneclient_core/src/bundles/updates.rs index ba11903b..f33cea64 100644 --- a/packages/oneclient_core/src/bundles/updates.rs +++ b/packages/oneclient_core/src/bundles/updates.rs @@ -367,9 +367,24 @@ pub async fn apply_bundle_updates( ) }); + if let Some(s) = session.as_ref() { + let count = (check.updates_available.len() + check.additions_available.len()) as u64; + let bytes: u64 = check + .updates_available + .iter() + .map(|u| u.new_file.size.max(1)) + .chain(check.additions_available.iter().map(|a| a.new_file.size.max(1))) + .sum(); + s.expect(crate::notification::TaskCategory::Packages, count, bytes); + } + for update in check.updates_available { let child = session.as_ref().map(|s| { - let c = s.child(update.new_file.display_name(), update.new_file.size.max(1)); + let c = s.child( + update.new_file.display_name(), + update.new_file.size.max(1), + crate::notification::TaskCategory::Packages, + ); c.set_phase(crate::notification::TaskPhase::Downloading); c }); @@ -382,7 +397,11 @@ pub async fn apply_bundle_updates( for addition in check.additions_available { let file_id = addition.new_file.kind.package_id(); let child = session.as_ref().map(|s| { - let c = s.child(addition.new_file.display_name(), addition.new_file.size.max(1)); + let c = s.child( + addition.new_file.display_name(), + addition.new_file.size.max(1), + crate::notification::TaskCategory::Packages, + ); c.set_phase(crate::notification::TaskPhase::Downloading); c }); diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 93e2a613..7a00c356 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -87,11 +87,13 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver) { }, ); } + GroupedProgressEvent::Expect { .. } => {} GroupedProgressEvent::AddChild { session_id, child_id, label, total, + .. } => { let Some(session) = grouped_sessions.get_mut(&session_id) else { continue; diff --git a/packages/oneclient_core/src/game/download.rs b/packages/oneclient_core/src/game/download.rs index f5994f83..cc7852c7 100644 --- a/packages/oneclient_core/src/game/download.rs +++ b/packages/oneclient_core/src/game/download.rs @@ -3,15 +3,18 @@ use reqwest::Method; use crate::crypto::{normalize_hash, sha1_bytes, sha1_file}; use crate::game::GameError; use crate::http::{RequestClient, ResponseExt, ResponseNotifyOptions, ResponseOptions}; -use crate::notification::{GroupedProgressSession, NotificationService, TaskPhase}; +use crate::notification::{GroupedProgressSession, NotificationService, TaskCategory, TaskPhase}; use crate::{LauncherError, LauncherResult}; +#[allow(clippy::too_many_arguments)] #[tracing::instrument(skip(requester, notifier, progress, label, expected_sha1), fields(%url), level = "debug")] pub async fn download_to_path( requester: &RequestClient, notifier: &NotificationService, progress: &GroupedProgressSession, label: impl Into, + category: TaskCategory, + expected_size: u64, url: &str, dest: &std::path::Path, expected_sha1: Option<&str>, @@ -25,8 +28,13 @@ pub async fn download_to_path( let request = reqwest::Request::new(Method::GET, parsed_url); let response = requester.send(request).await?; - let total = response.content_length().unwrap_or(0).max(1); - let child = progress.child(label, total); + // Prefer the manifest size; fall back to Content-Length only when unknown. + let total = if expected_size > 0 { + expected_size + } else { + response.content_length().unwrap_or(0).max(1) + }; + let child = progress.child(label, total, category); let stream = response .stream( @@ -63,6 +71,8 @@ pub async fn fetch_bytes_verified( notifier: &NotificationService, progress: &GroupedProgressSession, label: impl Into, + category: TaskCategory, + expected_size: u64, url: &str, expected_sha1: &str, ) -> LauncherResult> { @@ -71,8 +81,12 @@ pub async fn fetch_bytes_verified( let request = reqwest::Request::new(Method::GET, parsed_url.clone()); let response = requester.send(request).await?; - let total = response.content_length().unwrap_or(0).max(1); - let child = progress.child(label, total); + let total = if expected_size > 0 { + expected_size + } else { + response.content_length().unwrap_or(0).max(1) + }; + let child = progress.child(label, total, category); let stream = response .stream( diff --git a/packages/oneclient_core/src/game/metadata.rs b/packages/oneclient_core/src/game/metadata.rs index 45e75807..67c3cd18 100644 --- a/packages/oneclient_core/src/game/metadata.rs +++ b/packages/oneclient_core/src/game/metadata.rs @@ -12,7 +12,7 @@ use crate::metadata::MetadataError; use crate::metadata::MetadataStore; use crate::os_ext::OsExt; use crate::packages::domain::GameLoader; -use crate::notification::GroupedProgressSession; +use crate::notification::{GroupedProgressSession, TaskCategory}; use crate::paths; use crate::state::LauncherServices; use crate::{LauncherError, LauncherResult}; @@ -80,7 +80,11 @@ pub async fn download_version_info( let mut info: VersionInfo = match progress { Some(progress) => { progress - .run_child(format!("Version metadata ({version_id})"), 1, |child| { + .run_child( + format!("Version metadata ({version_id})"), + 1, + TaskCategory::Metadata, + |child| { let requester = requester.clone(); async move { child.set_progress(0, Some(1)); @@ -106,7 +110,11 @@ pub async fn download_version_info( let partial: interfrost::api::modded::PartialVersionInfo = match progress { Some(progress) => { progress - .run_child(format!("Loader metadata ({version_id})"), 1, |child| { + .run_child( + format!("Loader metadata ({version_id})"), + 1, + TaskCategory::Metadata, + |child| { let requester = requester.clone(); async move { child.set_progress(0, Some(1)); @@ -170,6 +178,8 @@ pub async fn download_assets_index( &services.notifier, progress, format!("Assets index ({})", version.asset_index.id), + TaskCategory::Assets, + u64::from(version.asset_index.size), &version.asset_index.url, &path, Some(version.asset_index.sha1.as_str()), @@ -223,6 +233,8 @@ pub async fn download_assets( ¬ifier, &progress, format!("Asset {name}"), + TaskCategory::Assets, + u64::from(asset.size), &url, &path, Some(hash), @@ -275,6 +287,8 @@ pub async fn download_client( &services.notifier, progress, format!("Client {}", version.id), + TaskCategory::Client, + u64::from(client.size), &client.url, &path, Some(&client.sha1), @@ -375,6 +389,8 @@ pub async fn download_libraries( ¬ifier, &progress, format!("Library {}", lib_short(&lib.name)), + TaskCategory::Libraries, + u64::from(artifact.size), &artifact.url, &path, Some(&artifact.sha1), @@ -395,6 +411,8 @@ pub async fn download_libraries( ¬ifier, &progress, format!("Library {}", lib_short(&lib.name)), + TaskCategory::Libraries, + 0, &url, &path, None, @@ -416,12 +434,18 @@ pub async fn download_libraries( ¬ifier, &progress, format!("Natives {}", lib_short(&lib.name)), + TaskCategory::Natives, + u64::from(native.size), &native.url, &native.sha1, ) .await?; - let extract = progress.child(format!("Natives {}", lib_short(&lib.name)), 1); + let extract = progress.child( + format!("Natives {}", lib_short(&lib.name)), + 1, + TaskCategory::Natives, + ); extract.set_phase(crate::notification::TaskPhase::Extracting); polyio::unzip_bytes_filtered( data, diff --git a/packages/oneclient_core/src/notification/data.rs b/packages/oneclient_core/src/notification/data.rs index e8741584..dc8c5efb 100644 --- a/packages/oneclient_core/src/notification/data.rs +++ b/packages/oneclient_core/src/notification/data.rs @@ -9,11 +9,21 @@ pub enum GroupedProgressEvent { session_id: Uuid, title: String, }, + /// Pre-announce how many children (and total bytes) a category will have, + /// so the aggregate total is known up-front instead of climbing as each + /// child is added during a `buffer_unordered` fan-out. + Expect { + session_id: Uuid, + category: TaskCategory, + count: u64, + total: u64, + }, AddChild { session_id: Uuid, child_id: Uuid, label: String, total: u64, + category: TaskCategory, }, UpdateChild { session_id: Uuid, @@ -55,6 +65,37 @@ impl TaskPhase { } } +/// Coarse grouping of a grouped-progress child. Drives the notification body text +/// ("Downloading Minecraft" vs "Downloading Packages") and the per-category task rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum TaskCategory { + Client, + Metadata, + Libraries, + Natives, + Assets, + #[default] + Packages, +} + +impl TaskCategory { + pub fn label(self) -> &'static str { + match self { + Self::Client => "Client", + Self::Metadata => "Metadata", + Self::Libraries => "Libraries", + Self::Natives => "Natives", + Self::Assets => "Assets", + Self::Packages => "Packages", + } + } + + /// True for everything that is part of the Minecraft install (not user packages). + pub fn is_minecraft(self) -> bool { + !matches!(self, Self::Packages) + } +} + #[derive(Debug)] pub enum Notification { Message { diff --git a/packages/oneclient_core/src/notification/grouped.rs b/packages/oneclient_core/src/notification/grouped.rs index bdb95b26..d9d2e9b9 100644 --- a/packages/oneclient_core/src/notification/grouped.rs +++ b/packages/oneclient_core/src/notification/grouped.rs @@ -1,8 +1,9 @@ use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use uuid::Uuid; -use super::data::GroupedProgressEvent; +use super::data::{GroupedProgressEvent, TaskCategory}; use super::NotificationService; #[derive(Clone)] @@ -11,12 +12,12 @@ struct SessionInner { notifier: NotificationService, } -#[derive(Clone)] struct ChildInner { session_id: Uuid, child_id: Uuid, notifier: NotificationService, label: String, + total: AtomicU64, finished: Arc, } @@ -49,7 +50,26 @@ impl GroupedProgressSession { self.inner.session_id } - pub fn child(&self, label: impl Into, total: u64) -> GroupedProgressChild { + /// Reserve the expected work for a category before its children are added. + /// `count` = number of files, `total` = sum of their sizes in bytes. + pub fn expect(&self, category: TaskCategory, count: u64, total: u64) { + if count == 0 { + return; + } + self.inner.notifier.send_grouped(GroupedProgressEvent::Expect { + session_id: self.inner.session_id, + category, + count, + total, + }); + } + + pub fn child( + &self, + label: impl Into, + total: u64, + category: TaskCategory, + ) -> GroupedProgressChild { let child_id = Uuid::new_v4(); let label = label.into(); let total = total.max(1); @@ -59,6 +79,7 @@ impl GroupedProgressSession { child_id, label: label.clone(), total, + category, }); GroupedProgressChild { @@ -67,6 +88,7 @@ impl GroupedProgressSession { child_id, notifier: self.inner.notifier.clone(), label, + total: AtomicU64::new(total), finished: Arc::new(std::sync::atomic::AtomicBool::new(false)), }), } @@ -76,13 +98,14 @@ impl GroupedProgressSession { &self, label: impl Into, total: u64, + category: TaskCategory, f: F, ) -> Result where F: FnOnce(GroupedProgressChild) -> Fut, Fut: std::future::Future>, { - let child = self.child(label, total); + let child = self.child(label, total, category); let result = f(child.clone()).await; if result.is_ok() { child.finish(); @@ -120,7 +143,15 @@ impl GroupedProgressChild { return; } - let total = total.unwrap_or(1).max(1); + // Prefer the largest known total. Streaming reports Content-Length which is + // often missing (1) for compressed/chunked responses; when the child was + // created with a real expected size (from a manifest) we must not let that + // stale header clobber it — otherwise the download bar reads 100%/frozen. + let stored = self.inner.total.load(Ordering::Relaxed); + let total = total.unwrap_or(0).max(stored).max(1); + if total > stored { + self.inner.total.store(total, Ordering::Relaxed); + } self.inner.notifier.send_grouped(GroupedProgressEvent::UpdateChild { session_id: self.inner.session_id, child_id: self.inner.child_id, diff --git a/packages/oneclient_core/src/notification/mod.rs b/packages/oneclient_core/src/notification/mod.rs index 80e44880..12fef898 100644 --- a/packages/oneclient_core/src/notification/mod.rs +++ b/packages/oneclient_core/src/notification/mod.rs @@ -1,6 +1,6 @@ pub use data::{ GroupedProgressEvent, LaunchStage, MicrosoftLoginStatus, Notification, NotificationLevel, - PromptKind, TaskPhase, UserChoice, + PromptKind, TaskCategory, TaskPhase, UserChoice, }; pub use grouped::{GroupedProgressChild, GroupedProgressSession}; pub use service::NotificationService;