feat: Make bundle updates prettier

This commit is contained in:
LynithDev 2026-07-21 13:03:33 +02:00
parent 85d1e09cee
commit 5929e51920
21 changed files with 682 additions and 221 deletions

View file

@ -1,5 +1,8 @@
use std::path::PathBuf; use std::path::PathBuf;
use uuid::Uuid;
use oneclient_core::ApplyBundleUpdatesResult;
use oneclient_core::notification::UserChoice; use oneclient_core::notification::UserChoice;
use oneclient_core::packages::{ContentType, ProviderId}; use oneclient_core::packages::{ContentType, ProviderId};
use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate}; use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate};
@ -125,6 +128,12 @@ pub enum BridgeCommand {
cluster_id: ClusterId, cluster_id: ClusterId,
}, },
SyncBundles, SyncBundles,
/// Emitted by the background bundle sync task when it finishes; carries the
/// per-cluster changes so the loop can raise "view update" notifications.
BundleSyncComplete {
changed: Vec<(ClusterId, ApplyBundleUpdatesResult)>,
session_id: Uuid,
},
ImportLauncher { ImportLauncher {
source: oneclient_core::MigrationSource, source: oneclient_core::MigrationSource,

View file

@ -11,7 +11,7 @@ use tokio::sync::{mpsc, watch};
use crate::components::IconType; use crate::components::IconType;
use crate::notifications::{ use crate::notifications::{
ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, ClusterUpdateItem, ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction,
NotificationActionKind, NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, NotificationActionKind, NotificationSpec, NotificationState, PendingPrompt, PendingPromptView,
}; };
@ -219,6 +219,7 @@ impl CoreBridgeRuntime {
&mut inbox, &mut inbox,
&mut notification_center_open, &mut notification_center_open,
&mut pending_prompt, &mut pending_prompt,
&self.commands_tx,
) )
.await .await
{ {
@ -257,6 +258,7 @@ impl CoreBridgeRuntime {
snapshots.launcher = LauncherInit { snapshots.launcher = LauncherInit {
ready: true, ready: true,
fetching: true, fetching: true,
syncing_bundles: true,
error: None, error: None,
data_dir, data_dir,
}; };
@ -276,6 +278,7 @@ impl CoreBridgeRuntime {
inbox: &mut Vec<InboxEntry>, inbox: &mut Vec<InboxEntry>,
notification_center_open: &mut bool, notification_center_open: &mut bool,
pending_prompt: &mut Option<PendingPrompt>, pending_prompt: &mut Option<PendingPrompt>,
commands_tx: &mpsc::UnboundedSender<BridgeCommand>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
match command { match command {
BridgeCommand::ReloadSettings => { BridgeCommand::ReloadSettings => {
@ -704,23 +707,57 @@ impl CoreBridgeRuntime {
} }
} }
BridgeCommand::SyncBundles => { BridgeCommand::SyncBundles => {
let state = LauncherState::get()?; // Run the cluster-bundle download in the background so the main
state.bundles.sync(&state.services).await?; // app can load while it finishes. The launch buttons stay
oneclient_core::clusters::apply_remote_migrations(&state).await?; // disabled via `syncing_bundles` until BundleSyncComplete lands.
oneclient_core::clusters::ensure_from_bundles(&state).await?; snapshots.launcher.syncing_bundles = true;
let changed = oneclient_core::bundles::sync_all_cluster_bundles( let commands_tx = commands_tx.clone();
state.bundles.as_ref(), tokio::spawn(async move {
&state.services, let state = match LauncherState::get() {
) Ok(state) => state,
.await; Err(err) => {
bump_clusters_generation(snapshots); tracing::error!("bundle sync: launcher not ready: {err:#}");
for (cluster_id, result) in &changed { return;
if let Some(spec) = }
cluster_update_notification(*cluster_id, result, &state.services).await };
{ if let Err(err) = state.bundles.sync(&state.services).await {
notification_engine.push_custom(inbox, spec); tracing::error!("bundle catalog sync failed: {err:#}");
} }
} if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await
{
tracing::error!("cluster migrations failed: {err:#}");
}
if let Err(err) = oneclient_core::clusters::ensure_from_bundles(&state).await {
tracing::error!("bundle cluster provisioning failed: {err:#}");
}
// One shared grouped session for the whole batch, so every
// cluster's downloads surface as a single "update in progress"
// notification. `detach` hands that entry to the bridge, which
// converts it to the finished "view changes" state in place.
let session = oneclient_core::notification::GroupedProgressSession::start(
&state.services.notifier,
"Updating mods",
);
let changed = oneclient_core::bundles::sync_all_cluster_bundles(
state.bundles.as_ref(),
&state.services,
Some(&session),
)
.await;
let session_id = session.detach();
let _ = commands_tx
.send(BridgeCommand::BundleSyncComplete { changed, session_id });
});
}
BridgeCommand::BundleSyncComplete {
changed,
session_id,
} => {
snapshots.launcher.syncing_bundles = false;
bump_clusters_generation(snapshots);
let state = LauncherState::get()?;
let spec = combined_cluster_update_spec(&changed, &state.services).await;
notification_engine.finish_grouped_as_actions(inbox, session_id, spec);
} }
} }
@ -728,25 +765,51 @@ impl CoreBridgeRuntime {
} }
} }
async fn cluster_update_notification( fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdateItem {
match &file.kind {
oneclient_core::BundleFileKind::Managed {
provider,
project_id,
..
} => ClusterUpdateItem {
provider: *provider,
project_id: Some(project_id.clone()),
fallback: file.display_name(),
},
oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem {
provider: oneclient_core::packages::ProviderId::Local,
project_id: None,
fallback: file.display_name(),
},
}
}
async fn cluster_update_summary(
cluster_id: i64, cluster_id: i64,
result: &oneclient_core::ApplyBundleUpdatesResult, result: &oneclient_core::ApplyBundleUpdatesResult,
services: &oneclient_core::LauncherServices, services: &oneclient_core::LauncherServices,
) -> Option<NotificationSpec> { ) -> Option<ClusterUpdateSummary> {
let updated: Vec<String> = result let updated: Vec<ClusterUpdateItem> = result
.updates_applied .updates_applied
.iter() .iter()
.map(|u| u.new_file.display_name()) .map(|u| item_from_bundle_file(&u.new_file))
.collect(); .collect();
let added: Vec<String> = result let added: Vec<ClusterUpdateItem> = result
.additions_applied .additions_applied
.iter() .iter()
.map(|a| a.new_file.display_name()) .map(|a| item_from_bundle_file(&a.new_file))
.collect(); .collect();
let removed: Vec<String> = result let removed: Vec<ClusterUpdateItem> = result
.removals_applied .removals_applied
.iter() .iter()
.map(|r| r.package_id.clone()) .map(|r| ClusterUpdateItem {
provider: r.provider.unwrap_or(oneclient_core::packages::ProviderId::Local),
project_id: r.project_id.clone(),
fallback: r
.display_name
.clone()
.unwrap_or_else(|| r.package_id.clone()),
})
.collect(); .collect();
if updated.is_empty() && added.is_empty() && removed.is_empty() { if updated.is_empty() && added.is_empty() && removed.is_empty() {
@ -758,18 +821,26 @@ async fn cluster_update_notification(
.map(|c| c.name) .map(|c| c.name)
.unwrap_or_else(|_| "Cluster".to_string()); .unwrap_or_else(|_| "Cluster".to_string());
let summary = ClusterUpdateSummary { Some(ClusterUpdateSummary {
cluster_id, cluster_id,
cluster_name: cluster_name.clone(), cluster_name,
updated, updated,
added, added,
removed, removed,
}; })
}
async fn cluster_update_notification(
cluster_id: i64,
result: &oneclient_core::ApplyBundleUpdatesResult,
services: &oneclient_core::LauncherServices,
) -> Option<NotificationSpec> {
let summary = cluster_update_summary(cluster_id, result, services).await?;
let total = summary.total(); let total = summary.total();
let body = format!( let body = format!(
"{total} package{} changed in {cluster_name}", "{total} package{} changed in {}",
if total == 1 { "" } else { "s" } if total == 1 { "" } else { "s" },
summary.cluster_name
); );
Some(NotificationSpec { Some(NotificationSpec {
@ -785,6 +856,46 @@ async fn cluster_update_notification(
}) })
} }
/// Builds a single notification summarising a batch bundle sync, with one
/// "view changes" action per changed cluster. `None` when nothing changed.
async fn combined_cluster_update_spec(
changed: &[(i64, oneclient_core::ApplyBundleUpdatesResult)],
services: &oneclient_core::LauncherServices,
) -> Option<NotificationSpec> {
let mut actions = Vec::new();
let mut total_changes = 0usize;
for (cluster_id, result) in changed {
if let Some(summary) = cluster_update_summary(*cluster_id, result, services).await {
total_changes += summary.total();
actions.push(NotificationAction {
label: summary.cluster_name.clone(),
kind: NotificationActionKind::OpenClusterUpdate(summary),
});
}
}
if actions.is_empty() {
return None;
}
let cluster_count = actions.len();
let body = format!(
"{total_changes} package{} updated across {cluster_count} cluster{}",
if total_changes == 1 { "" } else { "s" },
if cluster_count == 1 { "" } else { "s" }
);
Some(NotificationSpec {
title: "Mods updated".to_string(),
body,
level: NotificationLevel::Info,
icon: Some(IconType::DownloadCloud02),
progress: None,
actions,
})
}
fn publish(tx: &watch::Sender<BridgeSnapshot>, snapshots: &BridgeSnapshot) { fn publish(tx: &watch::Sender<BridgeSnapshot>, snapshots: &BridgeSnapshot) {
let _ = tx.send(snapshots.clone()); let _ = tx.send(snapshots.clone());
} }

View file

@ -48,6 +48,9 @@ pub enum AsyncStatus {
pub struct LauncherInit { pub struct LauncherInit {
pub ready: bool, pub ready: bool,
pub fetching: bool, pub fetching: bool,
/// True while the background cluster-bundle download is running. Launch is
/// disabled until it clears.
pub syncing_bundles: bool,
pub error: Option<String>, pub error: Option<String>,
pub data_dir: String, pub data_dir: String,
} }

View file

@ -7,7 +7,8 @@ use crate::components::{Button, Icon, IconType};
use oneclient_core::parse_mc_version; use oneclient_core::parse_mc_version;
use crate::hooks::{ use crate::hooks::{
use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_version_metadata, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher,
use_version_metadata,
}; };
use crate::routes::Route; use crate::routes::Route;
use crate::theme::colors; use crate::theme::colors;
@ -23,6 +24,8 @@ impl Component for ActiveClusterPanel {
let mut active_id = use_active_cluster_id(); let mut active_id = use_active_cluster_id();
let dispatch = use_dispatch(); let dispatch = use_dispatch();
let game = use_game_snapshot(); let game = use_game_snapshot();
let launcher = use_launcher();
let syncing = launcher.fetching || launcher.syncing_bundles;
let clusters = { let clusters = {
let reader = clusters_query.read(); let reader = clusters_query.read();
@ -119,7 +122,7 @@ impl Component for ActiveClusterPanel {
.child(launch_button( .child(launch_button(
cluster_id, cluster_id,
dispatch, dispatch,
launch_button_state(&game, cluster_id), launch_button_state(&game, cluster_id, syncing),
)) ))
.child(cluster_settings_button(cluster_id)), .child(cluster_settings_button(cluster_id)),
) )

View file

@ -1,16 +1,63 @@
use freya::{prelude::*, router::RouterContext}; use std::collections::HashMap;
use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea}; use freya::{prelude::*, router::RouterContext};
use crate::hooks::{use_dispatch, use_notifications_snapshot}; use oneclient_core::packages::{CachedPackageMeta, ProviderId};
use crate::notifications::ClusterUpdateSummary;
use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea, TabBar, TabItem};
use crate::hooks::{package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch};
use crate::notifications::{ClusterUpdateItem, ClusterUpdateSummary};
use crate::routes::Route; use crate::routes::Route;
use crate::theme::colors; use crate::theme::colors;
use crate::ui::border_all_color; use crate::ui::border_all_color;
const CARD_BG: Color = Color::from_rgb(26, 34, 41); const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const DIALOG_W: f32 = 680.; const DIALOG_W: f32 = 420.;
const DIALOG_H: f32 = 400.; const DIALOG_H: f32 = 400.;
const BANNER_W: f32 = 300.;
type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
#[derive(Clone, Copy, PartialEq, Eq)]
enum UpdateTab {
Updates,
Additions,
Removals,
}
impl UpdateTab {
const ALL: [UpdateTab; 3] = [UpdateTab::Updates, UpdateTab::Additions, UpdateTab::Removals];
fn label(self) -> &'static str {
match self {
UpdateTab::Updates => "Updated",
UpdateTab::Additions => "Added",
UpdateTab::Removals => "Removed",
}
}
fn icon(self) -> IconType {
match self {
UpdateTab::Updates => IconType::RefreshCw01,
UpdateTab::Additions => IconType::Plus,
UpdateTab::Removals => IconType::Trash01,
}
}
fn accent(self) -> Color {
match self {
UpdateTab::Updates => colors::brand(),
UpdateTab::Additions => colors::success(),
UpdateTab::Removals => colors::danger(),
}
}
fn items(self, summary: &ClusterUpdateSummary) -> &[ClusterUpdateItem] {
match self {
UpdateTab::Updates => &summary.updated,
UpdateTab::Additions => &summary.added,
UpdateTab::Removals => &summary.removed,
}
}
}
#[derive(PartialEq)] #[derive(PartialEq)]
pub struct ClusterUpdatePopup; pub struct ClusterUpdatePopup;
@ -19,8 +66,31 @@ impl Component for ClusterUpdatePopup {
fn render(&self) -> impl IntoElement { fn render(&self) -> impl IntoElement {
let snapshot = use_notifications_snapshot(); let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch(); let dispatch = use_dispatch();
let active = use_state(|| UpdateTab::Updates);
let Some(summary) = snapshot.cluster_update.clone() else { let summary = snapshot.cluster_update.clone();
// Resolve pretty display names through the package-meta cache. Hooks
// must run unconditionally, so gather ids (empty when no summary) and
// query every remote provider before the early return below.
let all_items: Vec<&ClusterUpdateItem> = summary
.iter()
.flat_map(|s| s.updated.iter().chain(&s.added).chain(&s.removed))
.collect();
let mut meta = MetaMap::new();
for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() {
let ids: Vec<String> = all_items
.iter()
.filter(|i| i.provider == provider)
.filter_map(|i| i.project_id.clone())
.collect();
let query = use_package_meta_batch(provider, ids);
for (pid, m) in package_meta_batch(&query) {
meta.insert((provider, pid), m);
}
}
let Some(summary) = summary else {
return rect().into_element(); return rect().into_element();
}; };
@ -33,15 +103,29 @@ impl Component for ClusterUpdatePopup {
.width(Size::window_percent(100.)) .width(Size::window_percent(100.))
.height(Size::window_percent(100.)) .height(Size::window_percent(100.))
.center() .center()
.child(dialog(&summary, dispatch)), .child(dialog(&summary, &meta, active, dispatch)),
) )
.into_element() .into_element()
} }
} }
fn dialog(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> impl IntoElement { fn resolve_name(item: &ClusterUpdateItem, meta: &MetaMap) -> String {
item.project_id
.as_ref()
.and_then(|pid| meta.get(&(item.provider, pid.clone())))
.map(|m| m.name.clone())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| item.fallback.clone())
}
fn dialog(
summary: &ClusterUpdateSummary,
meta: &MetaMap,
active: State<UpdateTab>,
dispatch: crate::BridgeDispatch,
) -> impl IntoElement {
rect() rect()
.horizontal() .vertical()
.width(Size::px(DIALOG_W)) .width(Size::px(DIALOG_W))
.height(Size::px(DIALOG_H)) .height(Size::px(DIALOG_H))
.max_width(Size::window_percent(95.)) .max_width(Size::window_percent(95.))
@ -56,75 +140,15 @@ fn dialog(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> im
0., 0.,
Color::from_argb(150, 0, 0, 0), Color::from_argb(150, 0, 0, 0),
))) )))
.child(banner(summary)) .child(content(summary, meta, active, dispatch))
.child(content(summary, dispatch))
} }
fn banner(summary: &ClusterUpdateSummary) -> impl IntoElement { fn content(
rect() summary: &ClusterUpdateSummary,
.vertical() meta: &MetaMap,
.width(Size::px(BANNER_W)) active: State<UpdateTab>,
.height(Size::fill()) dispatch: crate::BridgeDispatch,
.overflow(Overflow::Clip) ) -> impl IntoElement {
.background(colors::brand().with_a(30))
.main_align(Alignment::SpaceBetween)
// watermark
.child(
rect()
.width(Size::fill())
.height(Size::flex(1.0))
.center()
.child(
Icon::new(IconType::DownloadCloud02)
.size(150.)
.color(colors::brand().with_a(34)),
),
)
// seated title block
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.padding(Gaps::new(24., 20., 20., 20.))
.background(CARD_BG.with_a(170))
.child(
rect()
.center()
.padding(Gaps::new_symmetric(4., 10.))
.corner_radius(CornerRadius::new_all(999.))
.background(colors::brand())
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(5.)
.child(
Icon::new(IconType::RefreshCw01)
.size(12.)
.color(Color::WHITE),
)
.child(
label()
.text("Updated")
.font_size(11.)
.font_weight(FontWeight::SEMI_BOLD)
.color(Color::WHITE),
),
),
)
.child(
label()
.text(summary.cluster_name.clone())
.font_size(23.)
.font_weight(FontWeight::BOLD)
.max_lines(2)
.color(colors::fg_primary()),
),
)
}
fn content(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> impl IntoElement {
let dismiss = dispatch.clone(); let dismiss = dispatch.clone();
let cluster_id = summary.cluster_id; let cluster_id = summary.cluster_id;
let open = move |_| { let open = move |_| {
@ -133,40 +157,31 @@ fn content(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> i
}; };
let total = summary.total(); let total = summary.total();
let active_tab = *active.read();
let mut sections = ScrollArea::new() // Tab row: every category always shown with a count pill, even at zero, so
// the modal shape stays stable across clusters.
let tabs = TabBar::new()
.width(Size::fill()) .width(Size::fill())
.height(Size::flex(1.0)) .height(Size::px(30.))
.spacing(10.); .spacing(20.)
.tabs(UpdateTab::ALL.into_iter().map(|tab| {
let count = tab.items(summary).len();
let mut set = active;
TabItem::new(tab.label(), tab == active_tab)
.count_text(count.to_string())
.on_press(move |_| *set.write() = tab)
}));
if !summary.updated.is_empty() { let names: Vec<String> = active_tab
sections = sections.child(change_section( .items(summary)
IconType::RefreshCw01, .iter()
"Updated", .map(|item| resolve_name(item, meta))
colors::brand(), .collect();
&summary.updated,
));
}
if !summary.added.is_empty() {
sections = sections.child(change_section(
IconType::Plus,
"Added",
colors::success(),
&summary.added,
));
}
if !summary.removed.is_empty() {
sections = sections.child(change_section(
IconType::Trash01,
"Removed",
colors::danger(),
&summary.removed,
));
}
rect() rect()
.vertical() .vertical()
.width(Size::flex(1.0)) .width(Size::fill())
.height(Size::fill()) .height(Size::fill())
.content(Content::Flex) .content(Content::Flex)
.padding(Gaps::new_all(22.)) .padding(Gaps::new_all(22.))
@ -194,7 +209,8 @@ fn content(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> i
.color(colors::fg_secondary()), .color(colors::fg_secondary()),
), ),
) )
.child(sections) .child(tabs)
.child(change_list(active_tab, &names))
.child( .child(
rect() rect()
.horizontal() .horizontal()
@ -218,31 +234,43 @@ fn content(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> i
) )
} }
fn change_section( fn change_list(tab: UpdateTab, names: &[String]) -> impl IntoElement {
icon: IconType, let accent = tab.accent();
heading: &str,
accent: Color, let mut scroll = ScrollArea::new()
items: &[String], .width(Size::fill())
) -> impl IntoElement { .height(Size::flex(1.0))
let mut list = rect().vertical().width(Size::fill()).spacing(1.); .spacing(1.);
for item in items {
list = list.child( if names.is_empty() {
return scroll
.child(
rect()
.width(Size::fill())
.height(Size::px(80.))
.center()
.child(
label()
.text(format!("No {} in this update.", tab.label().to_lowercase()))
.font_size(12.5)
.color(colors::fg_secondary()),
),
)
.into_element();
}
for name in names {
scroll = scroll.child(
rect() rect()
.horizontal() .horizontal()
.width(Size::fill()) .width(Size::fill())
.cross_align(Alignment::Center) .cross_align(Alignment::Center)
.spacing(9.) .spacing(9.)
.padding(Gaps::new_symmetric(4., 4.)) .padding(Gaps::new_symmetric(5., 6.))
.child( .child(Icon::new(tab.icon()).size(13.).color(accent))
rect()
.width(Size::px(5.))
.height(Size::px(5.))
.corner_radius(CornerRadius::new_all(3.))
.background(accent),
)
.child( .child(
label() label()
.text(item.clone()) .text(name.clone())
.font_size(12.5) .font_size(12.5)
.max_lines(1) .max_lines(1)
.width(Size::flex(1.0)) .width(Size::flex(1.0))
@ -251,27 +279,5 @@ fn change_section(
); );
} }
rect() scroll.into_element()
.vertical()
.width(Size::fill())
.spacing(5.)
.padding(Gaps::new(9., 11., 11., 11.))
.corner_radius(CornerRadius::new_all(10.))
.background(colors::page_elevated().with_a(140))
.border(border_all_color(1., colors::component_border().with_a(120)))
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(7.)
.child(Icon::new(icon).size(14.).color(accent))
.child(
label()
.text(format!("{heading} · {}", items.len()))
.font_size(12.)
.font_weight(FontWeight::SEMI_BOLD)
.color(accent),
),
)
.child(list)
} }

View file

@ -26,6 +26,7 @@ mod recents_row;
mod screenshot_viewer; mod screenshot_viewer;
mod scrollview; mod scrollview;
mod segmented_control; mod segmented_control;
mod splash_curtain;
mod status_bar; mod status_bar;
mod tab_bar; mod tab_bar;
mod text_input; mod text_input;
@ -64,6 +65,7 @@ pub use screenshot_viewer::ScreenshotViewer;
pub(crate) use scrollview::corrected_scroll; pub(crate) use scrollview::corrected_scroll;
pub use scrollview::{ScrollArea, ScrollAreaCtx}; pub use scrollview::{ScrollArea, ScrollAreaCtx};
pub use segmented_control::{Segment, SegmentedControl}; pub use segmented_control::{Segment, SegmentedControl};
pub use splash_curtain::SplashCurtain;
pub use status_bar::StatusBar; pub use status_bar::StatusBar;
pub use tab_bar::{TabBar, TabItem}; pub use tab_bar::{TabBar, TabItem};
pub use text_input::{TextInput, validate_number}; pub use text_input::{TextInput, validate_number};

View file

@ -0,0 +1,106 @@
use std::time::Duration;
use freya::animation::{AnimNum, Ease, Function, use_animation};
use freya::prelude::*;
use crate::AppAssets;
use crate::hooks::use_splash;
use crate::theme::colors;
/// How long the curtain takes to fade away once home is ready.
const FADE_MS: u64 = 460;
/// Safety net: reveal the app even if the "ready" signal never arrives.
const FALLBACK_MS: u64 = 8000;
#[derive(PartialEq)]
pub struct SplashCurtain;
impl Component for SplashCurtain {
fn render(&self) -> impl IntoElement {
let splash = use_splash();
if !*splash.active.read() {
return rect().into_element();
}
// Fresh mount whenever the curtain is raised, so `CurtainFade`'s
// animation and timers start from the beginning each time.
CurtainFade.into_element()
}
}
#[derive(PartialEq)]
struct CurtainFade;
impl Component for CurtainFade {
fn render(&self) -> impl IntoElement {
let splash = use_splash();
let ready = *splash.home_ready.read();
let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default());
let fade = use_animation(|_| {
AnimNum::new(1.0, 0.0)
.time(FADE_MS)
.ease(Ease::Out)
.function(Function::Cubic)
});
// Force-reveal after a while if home never reports ready.
use_hook(move || {
let mut home_ready = splash.home_ready;
spawn(async move {
tokio::time::sleep(Duration::from_millis(FALLBACK_MS)).await;
if !*home_ready.peek() {
home_ready.set(true);
}
});
});
// When home is ready, play the fade-out then drop the curtain.
use_side_effect_with_deps(&ready, move |&ready| {
if !ready {
return;
}
let mut fade = fade;
fade.start();
let mut active = splash.active;
spawn(async move {
tokio::time::sleep(Duration::from_millis(FADE_MS + 40)).await;
active.set(false);
});
});
let opacity = fade.get().value();
rect()
.width(Size::fill())
.height(Size::fill())
.position(Position::new_global().top(0.).left(0.))
.layer(Layer::Overlay)
.opacity(opacity)
.background(colors::page())
.window_drag()
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.center()
.vertical()
.spacing(14.)
.child(
svg(logo.read().cloned())
.width(Size::px(288.))
.height(Size::px(60.))
.color(colors::fg_primary()),
)
.child(
label()
.text("The only Minecraft launcher you'll need.")
.font_size(15.)
.color(colors::fg_primary().with_a(140)),
),
)
.into_element()
}
}

View file

@ -15,6 +15,25 @@ pub fn use_active_cluster_id() -> State<Option<ClusterId>> {
consume_root_context::<ActiveClusterState>().0 consume_root_context::<ActiveClusterState>().0
} }
/// Drives the startup splash "curtain": a full-screen cover that stays up after
/// the router leaves the Startup route until the home view's clusters/art have
/// settled, then fades out — so the app never reveals a half-populated home.
#[derive(Clone, Copy)]
pub struct SplashState {
/// The curtain is covering the screen (raised when heading into Home).
pub active: State<bool>,
/// Home content (clusters + background) has finished loading.
pub home_ready: State<bool>,
}
pub fn use_provide_splash(state: SplashState) {
use_provide_root_context(move || state);
}
pub fn use_splash() -> SplashState {
consume_root_context::<SplashState>()
}
#[derive(Clone)] #[derive(Clone)]
pub struct BrowserCompatState(pub State<bool>); pub struct BrowserCompatState(pub State<bool>);

View file

@ -7,10 +7,10 @@ pub use view_state::{PersistedView, use_view_state};
pub use active_cluster::{ pub use active_cluster::{
ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState, ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState,
OnboardingSelectionState, use_active_cluster_id, use_browser_compat, use_browser_state_store, OnboardingSelectionState, SplashState, use_active_cluster_id, use_browser_compat,
use_link_confirm, use_onboarding_selection, use_provide_active_cluster, use_browser_state_store, use_link_confirm, use_onboarding_selection, use_provide_active_cluster,
use_provide_browser_compat, use_provide_browser_state, use_provide_link_confirm, use_provide_browser_compat, use_provide_browser_state, use_provide_link_confirm,
use_provide_onboarding_selection, use_provide_onboarding_selection, use_provide_splash, use_splash,
}; };
pub use dispatch::BridgeDispatch; pub use dispatch::BridgeDispatch;

View file

@ -17,8 +17,8 @@ use oneclient_db::models::ClusterId;
use crate::hooks::{ use crate::hooks::{
ActiveClusterState, BrowserCompatState, BrowserStateStore, use_active_cluster_id, use_clusters, ActiveClusterState, BrowserCompatState, BrowserStateStore, use_active_cluster_id, use_clusters,
use_game_snapshot, use_provide_active_cluster, use_provide_browser_compat, use_game_snapshot, use_launcher, use_provide_active_cluster, use_provide_browser_compat,
use_provide_browser_state, use_provide_browser_state, use_splash,
}; };
use crate::theme::colors; use crate::theme::colors;
use oneclient_core::notification::LaunchStage; use oneclient_core::notification::LaunchStage;
@ -245,6 +245,24 @@ impl Component for AppHomeBackground {
let parallax_enabled = use_settings_snapshot().settings.dynamic_background_enabled; let parallax_enabled = use_settings_snapshot().settings.dynamic_background_enabled;
let clusters_query = use_clusters(); let clusters_query = use_clusters();
let active_id = use_active_cluster_id(); let active_id = use_active_cluster_id();
let launcher = use_launcher();
let splash = use_splash();
// Once the cluster list has settled and the startup fetch is done, the
// home view is populated — let the splash curtain fade out.
let clusters_settled = matches!(
&*clusters_query.read().state(),
QueryStateData::Settled { .. }
);
let home_ready = clusters_settled && !launcher.fetching;
use_side_effect_with_deps(&home_ready, move |&ready| {
if ready {
let mut flag = splash.home_ready;
if !*flag.peek() {
flag.set(true);
}
}
});
let (art, art_dep) = { let (art, art_dep) = {
let reader = clusters_query.read(); let reader = clusters_query.read();

View file

@ -6,7 +6,9 @@ use oneclient_core::clusters::Cluster;
use oneclient_core::parse_mc_version; use oneclient_core::parse_mc_version;
use crate::components::{Button, Icon, IconType, TabBar, TabItem}; use crate::components::{Button, Icon, IconType, TabBar, TabItem};
use crate::hooks::{use_clusters, use_dispatch, use_game_snapshot, use_version_metadata}; use crate::hooks::{
use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata,
};
use crate::routes::Route; use crate::routes::Route;
use crate::theme::colors; use crate::theme::colors;
use crate::ui::entrance_motion_layer; use crate::ui::entrance_motion_layer;
@ -113,10 +115,12 @@ impl Component for ClusterShell {
let dispatch = use_dispatch(); let dispatch = use_dispatch();
let game = use_game_snapshot(); let game = use_game_snapshot();
let launcher = use_launcher();
let syncing = launcher.fetching || launcher.syncing_bundles;
let cluster = load_cluster(cluster_id); let cluster = load_cluster(cluster_id);
let show_game_log = game.is_active(cluster_id); let show_game_log = game.is_active(cluster_id);
let launch_state = launch_button_state(&game, cluster_id); let launch_state = launch_button_state(&game, cluster_id, syncing);
// Queried out here rather than where it is used: the shell can mount // Queried out here rather than where it is used: the shell can mount
// before the cluster list settles, and a hook that only runs once the // before the cluster list settles, and a hook that only runs once the

View file

@ -2,9 +2,10 @@ use freya::prelude::*;
use freya::router::*; use freya::router::*;
use crate::components::{ use crate::components::{
AccountSwitcher, ClusterUpdatePopup, JavaPromptOverlay, NotificationCenter, StatusBar, Toasts, AccountSwitcher, ClusterUpdatePopup, JavaPromptOverlay, NotificationCenter, SplashCurtain,
UpdatePromptOverlay, StatusBar, Toasts, UpdatePromptOverlay,
}; };
use crate::hooks::{SplashState, use_provide_splash};
use crate::routes::Route; use crate::routes::Route;
use crate::theme; use crate::theme;
use crate::theme::colors; use crate::theme::colors;
@ -14,6 +15,10 @@ pub struct RootLayout;
impl Component for RootLayout { impl Component for RootLayout {
fn render(&self) -> impl IntoElement { fn render(&self) -> impl IntoElement {
let active = use_state(|| false);
let home_ready = use_state(|| false);
use_provide_splash(SplashState { active, home_ready });
rect() rect()
.width(Size::fill()) .width(Size::fill())
.height(Size::fill()) .height(Size::fill())
@ -35,5 +40,6 @@ impl Component for RootLayout {
.child(JavaPromptOverlay) .child(JavaPromptOverlay)
.child(ClusterUpdatePopup) .child(ClusterUpdatePopup)
.child(StatusBar) .child(StatusBar)
.child(SplashCurtain)
} }
} }

View file

@ -4,6 +4,7 @@ use std::time::{Duration, Instant};
use oneclient_core::notification::{ use oneclient_core::notification::{
GroupedProgressEvent, Notification, NotificationLevel, PromptKind, TaskCategory, UserChoice, GroupedProgressEvent, Notification, NotificationLevel, PromptKind, TaskCategory, UserChoice,
}; };
use oneclient_core::packages::ProviderId;
use oneclient_db::models::ClusterId; use oneclient_db::models::ClusterId;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use uuid::Uuid; use uuid::Uuid;
@ -23,13 +24,33 @@ pub enum NotificationActionKind {
OpenClusterUpdate(ClusterUpdateSummary), OpenClusterUpdate(ClusterUpdateSummary),
} }
/// One changed package in a cluster update, carrying enough identity to
/// resolve a pretty display name through the package-meta cache in the UI.
#[derive(Clone, Debug, PartialEq)]
pub struct ClusterUpdateItem {
pub provider: ProviderId,
pub project_id: Option<String>,
/// Name shown when the meta cache has no entry (file name / package id).
pub fallback: String,
}
impl ClusterUpdateItem {
pub fn from_name(name: impl Into<String>) -> Self {
Self {
provider: ProviderId::Local,
project_id: None,
fallback: name.into(),
}
}
}
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct ClusterUpdateSummary { pub struct ClusterUpdateSummary {
pub cluster_id: ClusterId, pub cluster_id: ClusterId,
pub cluster_name: String, pub cluster_name: String,
pub updated: Vec<String>, pub updated: Vec<ClusterUpdateItem>,
pub added: Vec<String>, pub added: Vec<ClusterUpdateItem>,
pub removed: Vec<String>, pub removed: Vec<ClusterUpdateItem>,
} }
impl ClusterUpdateSummary { impl ClusterUpdateSummary {
@ -678,6 +699,73 @@ impl NotificationState {
} }
} }
/// Converts a still-open grouped-progress entry into its finished state,
/// reusing the same inbox id. `spec: None` forgets the entry entirely (used
/// when a sync completed with nothing to show). This lets a download progress
/// notification morph into a "view changes" notification without ever
/// becoming a second entry.
pub fn finish_grouped_as_actions(
&mut self,
inbox: &mut Vec<InboxEntry>,
session_id: Uuid,
spec: Option<NotificationSpec>,
) {
self.grouped_tasks.remove(&session_id);
let entry_id = self.grouped_entries.remove(&session_id);
let Some(spec) = spec else {
// Nothing changed: drop the progress entry if one was created.
if let Some(entry_id) = entry_id {
self.forget_entry(inbox, entry_id);
}
return;
};
let NotificationSpec {
title,
body,
level,
icon,
progress: _,
actions,
} = spec;
match entry_id.and_then(|id| inbox.iter_mut().find(|e| e.id == id)) {
Some(entry) => {
entry.title = title;
entry.body = body;
entry.level = level;
entry.icon = icon;
entry.progress = None;
entry.is_loading = false;
entry.read = false;
entry.created_at = Instant::now();
entry.actions = actions;
entry.tasks = Vec::new();
entry.transfer = None;
// The progress toast (if any) is already armed for this entry;
// leaving it in `active_toasts` lets the loop give it a dismiss
// timer now that it is no longer loading.
self.ensure_progress_toast(entry.id);
}
None => {
// Progress entry never materialised (e.g. no downloads happened);
// fall back to a fresh notification so the result still shows.
self.push_custom(
inbox,
NotificationSpec {
title,
body,
level,
icon,
progress: None,
actions,
},
);
}
}
}
fn refresh_grouped_entry(&mut 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 { let Some(&entry_id) = self.grouped_entries.get(&session_id) else {
return; return;

View file

@ -7,7 +7,8 @@ use oneclient_core::packages::domain::GameLoader;
use crate::components::{Button, ClusterLandscapeArt, Dropdown, Icon, IconType, MajorVersionCard}; use crate::components::{Button, ClusterLandscapeArt, Dropdown, Icon, IconType, MajorVersionCard};
use crate::hooks::{ use crate::hooks::{
use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_version_metadata, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher,
use_version_metadata,
}; };
use crate::routes::Route; use crate::routes::Route;
use crate::theme::colors; use crate::theme::colors;
@ -196,6 +197,8 @@ fn detail_sidebar(
let active_id = use_active_cluster_id(); let active_id = use_active_cluster_id();
let dispatch = use_dispatch(); let dispatch = use_dispatch();
let game = use_game_snapshot(); let game = use_game_snapshot();
let launcher = use_launcher();
let syncing = launcher.fetching || launcher.syncing_bundles;
let keys = version_keys(clusters_for_major); let keys = version_keys(clusters_for_major);
let loaders = loaders_for_major(clusters_for_major); let loaders = loaders_for_major(clusters_for_major);
let cluster_id = cluster.id; let cluster_id = cluster.id;
@ -273,7 +276,7 @@ fn detail_sidebar(
.child(play_button( .child(play_button(
cluster_id, cluster_id,
dispatch, dispatch,
launch_button_state(&game, cluster_id), launch_button_state(&game, cluster_id, syncing),
)) ))
.child(view_button(cluster_id, active_id)), .child(view_button(cluster_id, active_id)),
), ),

View file

@ -7,7 +7,9 @@ use oneclient_core::auth::preview_samples;
use crate::components::{Button, Icon, IconType, TextInput, login_dialog, toggle}; use crate::components::{Button, Icon, IconType, TextInput, login_dialog, toggle};
use crate::hooks::use_dispatch; use crate::hooks::use_dispatch;
use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind}; use crate::notifications::{
ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind,
};
use crate::routes::Route; use crate::routes::Route;
use crate::theme::colors; use crate::theme::colors;
use crate::ui::border_all_color; use crate::ui::border_all_color;
@ -245,9 +247,18 @@ impl Component for ClusterUpdateSimulator {
let summary = ClusterUpdateSummary { let summary = ClusterUpdateSummary {
cluster_id: cluster_id.read().trim().parse().unwrap_or(1), cluster_id: cluster_id.read().trim().parse().unwrap_or(1),
cluster_name: name.clone(), cluster_name: name.clone(),
updated: split_csv(&updated.read()), updated: split_csv(&updated.read())
added: split_csv(&added.read()), .into_iter()
removed: split_csv(&removed.read()), .map(ClusterUpdateItem::from_name)
.collect(),
added: split_csv(&added.read())
.into_iter()
.map(ClusterUpdateItem::from_name)
.collect(),
removed: split_csv(&removed.read())
.into_iter()
.map(ClusterUpdateItem::from_name)
.collect(),
}; };
let total = summary.total(); let total = summary.total();
simulate simulate

View file

@ -13,14 +13,23 @@ pub mod settings;
use crate::bridge::GameSnapshot; use crate::bridge::GameSnapshot;
use oneclient_core::notification::LaunchStage; use oneclient_core::notification::LaunchStage;
pub fn launch_button_state(game: &GameSnapshot, cluster_id: i64) -> (&'static str, bool) { pub fn launch_button_state(
match game.stage(cluster_id) { game: &GameSnapshot,
cluster_id: i64,
syncing: bool,
) -> (&'static str, bool) {
let state = match game.stage(cluster_id) {
Some(LaunchStage::Checking) => ("Checking", false), Some(LaunchStage::Checking) => ("Checking", false),
Some(LaunchStage::Downloading) => ("Downloading", false), Some(LaunchStage::Downloading) => ("Downloading", false),
Some(LaunchStage::Launching) => ("Launching", false), Some(LaunchStage::Launching) => ("Launching", false),
Some(LaunchStage::Running) => ("Running", false), Some(LaunchStage::Running) => ("Running", false),
_ => ("Launch", true), _ => ("Launch", true),
};
// Block launching while the startup bundle download is still running.
if syncing && state.1 {
return (state.0, false);
} }
state
} }
pub use accounts::Accounts; pub use accounts::Accounts;

View file

@ -4,8 +4,8 @@ use freya::{prelude::*, router::*};
use crate::AppAssets; use crate::AppAssets;
use crate::Route; use crate::Route;
use crate::hooks::{ use crate::hooks::{
terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, use_settings_snapshot,
use_settings_snapshot, use_terms, use_splash, use_terms,
}; };
use crate::theme::colors; use crate::theme::colors;
@ -18,6 +18,7 @@ impl Component for Startup {
let settings = use_settings_snapshot(); let settings = use_settings_snapshot();
let notifications = use_notifications_snapshot(); let notifications = use_notifications_snapshot();
let terms = use_terms(); let terms = use_terms();
let splash = use_splash();
let intro = use_animation(|conf| { let intro = use_animation(|conf| {
conf.on_creation(OnCreation::Run); conf.on_creation(OnCreation::Run);
@ -38,7 +39,7 @@ impl Component for Startup {
let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default()); let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default());
if launcher.ready && !launcher.fetching && !terms_is_loading(&terms) { if launcher.ready && !terms_is_loading(&terms) {
let document = terms_document(&terms); let document = terms_document(&terms);
let required_terms = document.as_ref().map(|doc| doc.version).unwrap_or(1); let required_terms = document.as_ref().map(|doc| doc.version).unwrap_or(1);
let required_privacy = document let required_privacy = document
@ -56,8 +57,27 @@ impl Component for Startup {
} else { } else {
Route::Home {} Route::Home {}
}; };
let _ = RouterContext::get().replace(destination);
return rect().into_element(); // Onboarding needs the bundle catalog before it can render its steps,
// so gate it on the fetch. Returning users go straight to the app and
// let the bundle download finish in the background.
let heading_to_onboarding = matches!(
destination,
Route::OnboardingWelcome { .. } | Route::OnboardingTerms { .. }
);
if !heading_to_onboarding || !launcher.fetching {
// Heading into Home: raise the splash curtain so the hard route
// swap is hidden until Home's clusters/art have settled, then it
// fades out. Onboarding animates itself, so skip the curtain.
if !heading_to_onboarding {
let mut active = splash.active;
let mut home_ready = splash.home_ready;
home_ready.set(false);
active.set(true);
}
let _ = RouterContext::get().replace(destination);
return rect().into_element();
}
} }
let active = notifications.inbox.iter().find(|entry| entry.is_loading); let active = notifications.inbox.iter().find(|entry| entry.is_loading);

View file

@ -4,6 +4,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use crate::bundles::manager::BundlesManager; use crate::bundles::manager::BundlesManager;
use crate::bundles::ApplyBundleUpdatesResult; use crate::bundles::ApplyBundleUpdatesResult;
use crate::bundles::updates::apply_bundle_updates_for_all_clusters; use crate::bundles::updates::apply_bundle_updates_for_all_clusters;
use crate::notification::GroupedProgressSession;
use crate::state::LauncherServices; use crate::state::LauncherServices;
static BUNDLE_SYNCING: AtomicBool = AtomicBool::new(false); static BUNDLE_SYNCING: AtomicBool = AtomicBool::new(false);
@ -12,14 +13,18 @@ pub fn is_bundle_syncing() -> bool {
BUNDLE_SYNCING.load(Ordering::Relaxed) BUNDLE_SYNCING.load(Ordering::Relaxed)
} }
/// Syncs every cluster's bundle content. When `session` is provided, all cluster
/// downloads report into that one grouped session so they appear as a single
/// notification the caller can later convert to its finished state.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub async fn sync_all_cluster_bundles( pub async fn sync_all_cluster_bundles(
bundles: &BundlesManager, bundles: &BundlesManager,
services: &LauncherServices, services: &LauncherServices,
session: Option<&GroupedProgressSession>,
) -> Vec<(i64, ApplyBundleUpdatesResult)> { ) -> Vec<(i64, ApplyBundleUpdatesResult)> {
tracing::info!("syncing all cluster bundles"); tracing::info!("syncing all cluster bundles");
BUNDLE_SYNCING.store(true, Ordering::Relaxed); BUNDLE_SYNCING.store(true, Ordering::Relaxed);
let changed = match apply_bundle_updates_for_all_clusters(bundles, services).await { let changed = match apply_bundle_updates_for_all_clusters(bundles, services, session).await {
Ok(changed) => changed, Ok(changed) => changed,
Err(err) => { Err(err) => {
tracing::error!("failed to apply bundle updates for clusters: {err:#}"); tracing::error!("failed to apply bundle updates for clusters: {err:#}");

View file

@ -109,6 +109,14 @@ pub struct BundlePackageRemoval {
pub hash: String, pub hash: String,
pub package_id: String, pub package_id: String,
pub bundle_name: String, pub bundle_name: String,
/// Provider of the installed artifact being removed, resolved from the
/// linked-artifact row at check time. `None` for external/local files.
pub provider: Option<ProviderId>,
/// Remote project id of the installed artifact, for meta-cache lookups.
pub project_id: Option<String>,
/// Best-effort human name (display name / file name) captured at check
/// time, used as fallback when meta cache has no entry.
pub display_name: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View file

@ -263,11 +263,21 @@ async fn check_bundle_updates_inner(
continue; continue;
} }
let linked = all_linked_by_hash.get(&bundle_pkg.hash);
removals_available.push(BundlePackageRemoval { removals_available.push(BundlePackageRemoval {
cluster_id, cluster_id,
hash: bundle_pkg.hash.clone(), hash: bundle_pkg.hash.clone(),
package_id: pkg_id.clone(), package_id: pkg_id.clone(),
bundle_name: bundle_name.clone(), bundle_name: bundle_name.clone(),
provider: linked.and_then(|i| i.provider),
project_id: linked
.and_then(|i| i.project_id.clone())
.or_else(|| Some(pkg_id.clone())),
display_name: linked.map(|i| {
i.display_name
.clone()
.unwrap_or_else(|| i.file_name.clone())
}),
}); });
} }
} }
@ -334,6 +344,27 @@ pub async fn apply_bundle_updates(
cluster_id: i64, cluster_id: i64,
bundles: &BundlesManager, bundles: &BundlesManager,
services: &LauncherServices, services: &LauncherServices,
) -> LauncherResult<ApplyBundleUpdatesResult> {
// Manual, single-cluster path: its own short-lived progress notification.
let session = crate::notification::GroupedProgressSession::start(
&services.notifier,
"Updating bundle content",
);
let result = apply_bundle_updates_with(cluster_id, bundles, services, Some(&session)).await;
session.finish();
result
}
/// Applies pending bundle updates for one cluster. When `session` is provided,
/// download progress is reported into that (possibly shared) grouped session, so
/// a whole batch of clusters can surface as a single notification. The caller
/// owns the session's lifetime.
#[tracing::instrument(skip(bundles, services, session))]
pub async fn apply_bundle_updates_with(
cluster_id: i64,
bundles: &BundlesManager,
services: &LauncherServices,
session: Option<&crate::notification::GroupedProgressSession>,
) -> LauncherResult<ApplyBundleUpdatesResult> { ) -> LauncherResult<ApplyBundleUpdatesResult> {
let lock = cluster_lock(cluster_id); let lock = cluster_lock(cluster_id);
let _guard = lock.lock().await; let _guard = lock.lock().await;
@ -359,15 +390,9 @@ pub async fn apply_bundle_updates(
} }
} }
let session = (!check.updates_available.is_empty() || !check.additions_available.is_empty()) let has_downloads =
.then(|| { !check.updates_available.is_empty() || !check.additions_available.is_empty();
crate::notification::GroupedProgressSession::start( if let Some(s) = session.filter(|_| has_downloads) {
&services.notifier,
"Updating bundle content",
)
});
if let Some(s) = session.as_ref() {
let count = (check.updates_available.len() + check.additions_available.len()) as u64; let count = (check.updates_available.len() + check.additions_available.len()) as u64;
let bytes: u64 = check let bytes: u64 = check
.updates_available .updates_available
@ -379,7 +404,7 @@ pub async fn apply_bundle_updates(
} }
for update in check.updates_available { for update in check.updates_available {
let child = session.as_ref().map(|s| { let child = session.map(|s| {
let c = s.child( let c = s.child(
update.new_file.display_name(), update.new_file.display_name(),
update.new_file.size.max(1), update.new_file.size.max(1),
@ -396,7 +421,7 @@ pub async fn apply_bundle_updates(
for addition in check.additions_available { for addition in check.additions_available {
let file_id = addition.new_file.kind.package_id(); let file_id = addition.new_file.kind.package_id();
let child = session.as_ref().map(|s| { let child = session.map(|s| {
let c = s.child( let c = s.child(
addition.new_file.display_name(), addition.new_file.display_name(),
addition.new_file.size.max(1), addition.new_file.size.max(1),
@ -411,10 +436,6 @@ pub async fn apply_bundle_updates(
} }
} }
if let Some(session) = session {
session.finish();
}
{ {
let cluster = PackageStore::get_cluster(cluster_id, services).await?; let cluster = PackageStore::get_cluster(cluster_id, services).await?;
let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric);
@ -674,10 +695,11 @@ pub async fn get_bundles_with_update_status(
pub async fn apply_bundle_updates_for_all_clusters( pub async fn apply_bundle_updates_for_all_clusters(
bundles: &BundlesManager, bundles: &BundlesManager,
services: &LauncherServices, services: &LauncherServices,
session: Option<&crate::notification::GroupedProgressSession>,
) -> LauncherResult<Vec<(i64, ApplyBundleUpdatesResult)>> { ) -> LauncherResult<Vec<(i64, ApplyBundleUpdatesResult)>> {
let mut changed = Vec::new(); let mut changed = Vec::new();
for cluster in cluster_dao::list_all(&services.db).await? { for cluster in cluster_dao::list_all(&services.db).await? {
match apply_bundle_updates(cluster.id, bundles, services).await { match apply_bundle_updates_with(cluster.id, bundles, services, session).await {
Ok(result) => { Ok(result) => {
if !result.updates_applied.is_empty() if !result.updates_applied.is_empty()
|| !result.additions_applied.is_empty() || !result.additions_applied.is_empty()

View file

@ -139,6 +139,14 @@ impl GroupedProgressSession {
pub fn finish(self) { pub fn finish(self) {
self.inner.end(); self.inner.end();
} }
/// Consumes the session without emitting an `End` event, returning its id.
/// Use when another actor (e.g. the UI bridge) will take over the session's
/// notification entry and convert it to a finished state itself.
pub fn detach(self) -> Uuid {
self.inner.ended.store(true, Ordering::Relaxed);
self.inner.session_id
}
} }
impl GroupedProgressChild { impl GroupedProgressChild {