feat(ui): Made the download notification more descriptive

This commit is contained in:
LynithDev 2026-07-19 11:19:33 +02:00
parent 48a2d67ae6
commit c338d09c60
12 changed files with 417 additions and 81 deletions

View file

@ -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,

View file

@ -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<bool>) -> 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) => {

View file

@ -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<NotificationAction>,
pub tasks: Vec<TaskView>,
/// Live transfer stats for grouped downloads (bytes/sec, seconds remaining).
pub transfer: Option<TransferStats>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TransferStats {
pub speed_bps: f64,
pub eta_secs: Option<u64>,
}
/// 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<ClusterUpdateSummary>,
}
/// 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<Uuid, TaskView>,
seen: u64,
done: u64,
done_units: u64,
current: Option<String>,
children: HashMap<Uuid, ChildRec>,
done_units: HashMap<TaskCategory, u64>,
done_count: HashMap<TaskCategory, u64>,
seen_count: HashMap<TaskCategory, u64>,
/// Expected counts/bytes announced up-front so totals don't climb mid-download.
reserved_count: HashMap<TaskCategory, u64>,
reserved_units: HashMap<TaskCategory, u64>,
/// (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<TaskView> {
let mut tasks: Vec<TaskView> = 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::<u64>();
let total: u64 = group.done_units + group.children.values().map(|t| t.total).sum::<u64>();
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::<u64>().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;
}
}

View file

@ -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,

View file

@ -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
});

View file

@ -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
});

View file

@ -87,11 +87,13 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver<Notification>) {
},
);
}
GroupedProgressEvent::Expect { .. } => {}
GroupedProgressEvent::AddChild {
session_id,
child_id,
label,
total,
..
} => {
let Some(session) = grouped_sessions.get_mut(&session_id) else {
continue;

View file

@ -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<String>,
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<String>,
category: TaskCategory,
expected_size: u64,
url: &str,
expected_sha1: &str,
) -> LauncherResult<Vec<u8>> {
@ -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(

View file

@ -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(
&notifier,
&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(
&notifier,
&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(
&notifier,
&progress,
format!("Library {}", lib_short(&lib.name)),
TaskCategory::Libraries,
0,
&url,
&path,
None,
@ -416,12 +434,18 @@ pub async fn download_libraries(
&notifier,
&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,

View file

@ -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 {

View file

@ -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<std::sync::atomic::AtomicBool>,
}
@ -49,7 +50,26 @@ impl GroupedProgressSession {
self.inner.session_id
}
pub fn child(&self, label: impl Into<String>, 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<String>,
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<String>,
total: u64,
category: TaskCategory,
f: F,
) -> Result<T, E>
where
F: FnOnce(GroupedProgressChild) -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
{
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,

View file

@ -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;