diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs index c5be31cd..d76b7664 100644 --- a/packages/oneclient_app/src/bridge/commands.rs +++ b/packages/oneclient_app/src/bridge/commands.rs @@ -1,5 +1,8 @@ use std::path::PathBuf; +use uuid::Uuid; + +use oneclient_core::ApplyBundleUpdatesResult; use oneclient_core::notification::UserChoice; use oneclient_core::packages::{ContentType, ProviderId}; use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate}; @@ -125,6 +128,12 @@ pub enum BridgeCommand { cluster_id: ClusterId, }, 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 { source: oneclient_core::MigrationSource, diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 2e6cbf36..5b5b610b 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -11,7 +11,7 @@ use tokio::sync::{mpsc, watch}; use crate::components::IconType; use crate::notifications::{ - ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, + ClusterUpdateItem, ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, NotificationActionKind, NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, }; @@ -219,6 +219,7 @@ impl CoreBridgeRuntime { &mut inbox, &mut notification_center_open, &mut pending_prompt, + &self.commands_tx, ) .await { @@ -257,6 +258,7 @@ impl CoreBridgeRuntime { snapshots.launcher = LauncherInit { ready: true, fetching: true, + syncing_bundles: true, error: None, data_dir, }; @@ -276,6 +278,7 @@ impl CoreBridgeRuntime { inbox: &mut Vec, notification_center_open: &mut bool, pending_prompt: &mut Option, + commands_tx: &mpsc::UnboundedSender, ) -> anyhow::Result<()> { match command { BridgeCommand::ReloadSettings => { @@ -704,23 +707,57 @@ impl CoreBridgeRuntime { } } BridgeCommand::SyncBundles => { - let state = LauncherState::get()?; - state.bundles.sync(&state.services).await?; - oneclient_core::clusters::apply_remote_migrations(&state).await?; - oneclient_core::clusters::ensure_from_bundles(&state).await?; - let changed = oneclient_core::bundles::sync_all_cluster_bundles( - state.bundles.as_ref(), - &state.services, - ) - .await; - bump_clusters_generation(snapshots); - for (cluster_id, result) in &changed { - if let Some(spec) = - cluster_update_notification(*cluster_id, result, &state.services).await - { - notification_engine.push_custom(inbox, spec); + // Run the cluster-bundle download in the background so the main + // app can load while it finishes. The launch buttons stay + // disabled via `syncing_bundles` until BundleSyncComplete lands. + snapshots.launcher.syncing_bundles = true; + let commands_tx = commands_tx.clone(); + tokio::spawn(async move { + let state = match LauncherState::get() { + Ok(state) => state, + Err(err) => { + tracing::error!("bundle sync: launcher not ready: {err:#}"); + return; + } + }; + if let Err(err) = state.bundles.sync(&state.services).await { + 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, result: &oneclient_core::ApplyBundleUpdatesResult, services: &oneclient_core::LauncherServices, -) -> Option { - let updated: Vec = result +) -> Option { + let updated: Vec = result .updates_applied .iter() - .map(|u| u.new_file.display_name()) + .map(|u| item_from_bundle_file(&u.new_file)) .collect(); - let added: Vec = result + let added: Vec = result .additions_applied .iter() - .map(|a| a.new_file.display_name()) + .map(|a| item_from_bundle_file(&a.new_file)) .collect(); - let removed: Vec = result + let removed: Vec = result .removals_applied .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(); if updated.is_empty() && added.is_empty() && removed.is_empty() { @@ -758,18 +821,26 @@ async fn cluster_update_notification( .map(|c| c.name) .unwrap_or_else(|_| "Cluster".to_string()); - let summary = ClusterUpdateSummary { + Some(ClusterUpdateSummary { cluster_id, - cluster_name: cluster_name.clone(), + cluster_name, updated, added, removed, - }; + }) +} +async fn cluster_update_notification( + cluster_id: i64, + result: &oneclient_core::ApplyBundleUpdatesResult, + services: &oneclient_core::LauncherServices, +) -> Option { + let summary = cluster_update_summary(cluster_id, result, services).await?; let total = summary.total(); let body = format!( - "{total} package{} changed in {cluster_name}", - if total == 1 { "" } else { "s" } + "{total} package{} changed in {}", + if total == 1 { "" } else { "s" }, + summary.cluster_name ); 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 { + 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, snapshots: &BridgeSnapshot) { let _ = tx.send(snapshots.clone()); } diff --git a/packages/oneclient_app/src/bridge/snapshot.rs b/packages/oneclient_app/src/bridge/snapshot.rs index b8784083..179e89d1 100644 --- a/packages/oneclient_app/src/bridge/snapshot.rs +++ b/packages/oneclient_app/src/bridge/snapshot.rs @@ -48,6 +48,9 @@ pub enum AsyncStatus { pub struct LauncherInit { pub ready: 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, pub data_dir: String, } diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs index 61e01121..9508cb80 100644 --- a/packages/oneclient_app/src/components/active_cluster_panel.rs +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -7,7 +7,8 @@ use crate::components::{Button, Icon, IconType}; use oneclient_core::parse_mc_version; 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::theme::colors; @@ -23,6 +24,8 @@ impl Component for ActiveClusterPanel { let mut active_id = use_active_cluster_id(); let dispatch = use_dispatch(); let game = use_game_snapshot(); + let launcher = use_launcher(); + let syncing = launcher.fetching || launcher.syncing_bundles; let clusters = { let reader = clusters_query.read(); @@ -119,7 +122,7 @@ impl Component for ActiveClusterPanel { .child(launch_button( cluster_id, dispatch, - launch_button_state(&game, cluster_id), + launch_button_state(&game, cluster_id, syncing), )) .child(cluster_settings_button(cluster_id)), ) diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs index 3c5f35a0..abd75745 100644 --- a/packages/oneclient_app/src/components/cluster_update_popup.rs +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -1,16 +1,63 @@ -use freya::{prelude::*, router::RouterContext}; +use std::collections::HashMap; -use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea}; -use crate::hooks::{use_dispatch, use_notifications_snapshot}; -use crate::notifications::ClusterUpdateSummary; +use freya::{prelude::*, router::RouterContext}; +use oneclient_core::packages::{CachedPackageMeta, ProviderId}; + +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::theme::colors; use crate::ui::border_all_color; 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 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)] pub struct ClusterUpdatePopup; @@ -19,8 +66,31 @@ impl Component for ClusterUpdatePopup { fn render(&self) -> impl IntoElement { let snapshot = use_notifications_snapshot(); 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 = 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(); }; @@ -33,15 +103,29 @@ impl Component for ClusterUpdatePopup { .width(Size::window_percent(100.)) .height(Size::window_percent(100.)) .center() - .child(dialog(&summary, dispatch)), + .child(dialog(&summary, &meta, active, dispatch)), ) .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, + dispatch: crate::BridgeDispatch, +) -> impl IntoElement { rect() - .horizontal() + .vertical() .width(Size::px(DIALOG_W)) .height(Size::px(DIALOG_H)) .max_width(Size::window_percent(95.)) @@ -56,75 +140,15 @@ fn dialog(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> im 0., Color::from_argb(150, 0, 0, 0), ))) - .child(banner(summary)) - .child(content(summary, dispatch)) + .child(content(summary, meta, active, dispatch)) } -fn banner(summary: &ClusterUpdateSummary) -> impl IntoElement { - rect() - .vertical() - .width(Size::px(BANNER_W)) - .height(Size::fill()) - .overflow(Overflow::Clip) - .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 { +fn content( + summary: &ClusterUpdateSummary, + meta: &MetaMap, + active: State, + dispatch: crate::BridgeDispatch, +) -> impl IntoElement { let dismiss = dispatch.clone(); let cluster_id = summary.cluster_id; let open = move |_| { @@ -133,40 +157,31 @@ fn content(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> i }; 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()) - .height(Size::flex(1.0)) - .spacing(10.); + .height(Size::px(30.)) + .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() { - sections = sections.child(change_section( - IconType::RefreshCw01, - "Updated", - colors::brand(), - &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, - )); - } + let names: Vec = active_tab + .items(summary) + .iter() + .map(|item| resolve_name(item, meta)) + .collect(); rect() .vertical() - .width(Size::flex(1.0)) + .width(Size::fill()) .height(Size::fill()) .content(Content::Flex) .padding(Gaps::new_all(22.)) @@ -194,7 +209,8 @@ fn content(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> i .color(colors::fg_secondary()), ), ) - .child(sections) + .child(tabs) + .child(change_list(active_tab, &names)) .child( rect() .horizontal() @@ -218,31 +234,43 @@ fn content(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> i ) } -fn change_section( - icon: IconType, - heading: &str, - accent: Color, - items: &[String], -) -> impl IntoElement { - let mut list = rect().vertical().width(Size::fill()).spacing(1.); - for item in items { - list = list.child( +fn change_list(tab: UpdateTab, names: &[String]) -> impl IntoElement { + let accent = tab.accent(); + + let mut scroll = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(1.); + + 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() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(9.) - .padding(Gaps::new_symmetric(4., 4.)) - .child( - rect() - .width(Size::px(5.)) - .height(Size::px(5.)) - .corner_radius(CornerRadius::new_all(3.)) - .background(accent), - ) + .padding(Gaps::new_symmetric(5., 6.)) + .child(Icon::new(tab.icon()).size(13.).color(accent)) .child( label() - .text(item.clone()) + .text(name.clone()) .font_size(12.5) .max_lines(1) .width(Size::flex(1.0)) @@ -251,27 +279,5 @@ fn change_section( ); } - rect() - .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) + scroll.into_element() } diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index a1dbeb16..a52a285a 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -26,6 +26,7 @@ mod recents_row; mod screenshot_viewer; mod scrollview; mod segmented_control; +mod splash_curtain; mod status_bar; mod tab_bar; mod text_input; @@ -64,6 +65,7 @@ pub use screenshot_viewer::ScreenshotViewer; pub(crate) use scrollview::corrected_scroll; pub use scrollview::{ScrollArea, ScrollAreaCtx}; pub use segmented_control::{Segment, SegmentedControl}; +pub use splash_curtain::SplashCurtain; pub use status_bar::StatusBar; pub use tab_bar::{TabBar, TabItem}; pub use text_input::{TextInput, validate_number}; diff --git a/packages/oneclient_app/src/components/splash_curtain.rs b/packages/oneclient_app/src/components/splash_curtain.rs new file mode 100644 index 00000000..8435781b --- /dev/null +++ b/packages/oneclient_app/src/components/splash_curtain.rs @@ -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() + } +} diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index 3694c0d8..67f03dc7 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -15,6 +15,25 @@ pub fn use_active_cluster_id() -> State> { consume_root_context::().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, + /// Home content (clusters + background) has finished loading. + pub home_ready: State, +} + +pub fn use_provide_splash(state: SplashState) { + use_provide_root_context(move || state); +} + +pub fn use_splash() -> SplashState { + consume_root_context::() +} + #[derive(Clone)] pub struct BrowserCompatState(pub State); diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index f49c8600..80b1adfe 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -7,10 +7,10 @@ pub use view_state::{PersistedView, use_view_state}; pub use active_cluster::{ ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState, - OnboardingSelectionState, use_active_cluster_id, use_browser_compat, use_browser_state_store, - use_link_confirm, use_onboarding_selection, use_provide_active_cluster, + OnboardingSelectionState, SplashState, use_active_cluster_id, use_browser_compat, + 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_onboarding_selection, + use_provide_onboarding_selection, use_provide_splash, use_splash, }; pub use dispatch::BridgeDispatch; diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index 4b898b0f..e62f1da6 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -17,8 +17,8 @@ use oneclient_db::models::ClusterId; use crate::hooks::{ ActiveClusterState, BrowserCompatState, BrowserStateStore, use_active_cluster_id, use_clusters, - use_game_snapshot, use_provide_active_cluster, use_provide_browser_compat, - use_provide_browser_state, + use_game_snapshot, use_launcher, use_provide_active_cluster, use_provide_browser_compat, + use_provide_browser_state, use_splash, }; use crate::theme::colors; use oneclient_core::notification::LaunchStage; @@ -245,6 +245,24 @@ impl Component for AppHomeBackground { let parallax_enabled = use_settings_snapshot().settings.dynamic_background_enabled; let clusters_query = use_clusters(); 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 reader = clusters_query.read(); diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 3566684b..41d7bf61 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -6,7 +6,9 @@ use oneclient_core::clusters::Cluster; use oneclient_core::parse_mc_version; 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::theme::colors; use crate::ui::entrance_motion_layer; @@ -113,10 +115,12 @@ impl Component for ClusterShell { let dispatch = use_dispatch(); let game = use_game_snapshot(); + let launcher = use_launcher(); + let syncing = launcher.fetching || launcher.syncing_bundles; let cluster = load_cluster(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 // before the cluster list settles, and a hook that only runs once the diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 9f886d72..b9c5c1ef 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,9 +2,10 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, ClusterUpdatePopup, JavaPromptOverlay, NotificationCenter, StatusBar, Toasts, - UpdatePromptOverlay, + AccountSwitcher, ClusterUpdatePopup, JavaPromptOverlay, NotificationCenter, SplashCurtain, + StatusBar, Toasts, UpdatePromptOverlay, }; +use crate::hooks::{SplashState, use_provide_splash}; use crate::routes::Route; use crate::theme; use crate::theme::colors; @@ -14,6 +15,10 @@ pub struct RootLayout; impl Component for RootLayout { fn render(&self) -> impl IntoElement { + let active = use_state(|| false); + let home_ready = use_state(|| false); + use_provide_splash(SplashState { active, home_ready }); + rect() .width(Size::fill()) .height(Size::fill()) @@ -35,5 +40,6 @@ impl Component for RootLayout { .child(JavaPromptOverlay) .child(ClusterUpdatePopup) .child(StatusBar) + .child(SplashCurtain) } } diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 1fffaa5d..8e29796f 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -4,6 +4,7 @@ use std::time::{Duration, Instant}; use oneclient_core::notification::{ GroupedProgressEvent, Notification, NotificationLevel, PromptKind, TaskCategory, UserChoice, }; +use oneclient_core::packages::ProviderId; use oneclient_db::models::ClusterId; use tokio::sync::oneshot; use uuid::Uuid; @@ -23,13 +24,33 @@ pub enum NotificationActionKind { 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, + /// 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) -> Self { + Self { + provider: ProviderId::Local, + project_id: None, + fallback: name.into(), + } + } +} + #[derive(Clone, Debug, PartialEq)] pub struct ClusterUpdateSummary { pub cluster_id: ClusterId, pub cluster_name: String, - pub updated: Vec, - pub added: Vec, - pub removed: Vec, + pub updated: Vec, + pub added: Vec, + pub removed: Vec, } 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, + session_id: Uuid, + spec: Option, + ) { + 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) { let Some(&entry_id) = self.grouped_entries.get(&session_id) else { return; diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index b721bcc4..4adc5fa6 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -7,7 +7,8 @@ use oneclient_core::packages::domain::GameLoader; use crate::components::{Button, ClusterLandscapeArt, Dropdown, Icon, IconType, MajorVersionCard}; 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::theme::colors; @@ -196,6 +197,8 @@ fn detail_sidebar( let active_id = use_active_cluster_id(); let dispatch = use_dispatch(); let game = use_game_snapshot(); + let launcher = use_launcher(); + let syncing = launcher.fetching || launcher.syncing_bundles; let keys = version_keys(clusters_for_major); let loaders = loaders_for_major(clusters_for_major); let cluster_id = cluster.id; @@ -273,7 +276,7 @@ fn detail_sidebar( .child(play_button( cluster_id, dispatch, - launch_button_state(&game, cluster_id), + launch_button_state(&game, cluster_id, syncing), )) .child(view_button(cluster_id, active_id)), ), diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index eeb5e971..ca73baf8 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -7,7 +7,9 @@ use oneclient_core::auth::preview_samples; use crate::components::{Button, Icon, IconType, TextInput, login_dialog, toggle}; use crate::hooks::use_dispatch; -use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind}; +use crate::notifications::{ + ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, +}; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; @@ -245,9 +247,18 @@ impl Component for ClusterUpdateSimulator { let summary = ClusterUpdateSummary { cluster_id: cluster_id.read().trim().parse().unwrap_or(1), cluster_name: name.clone(), - updated: split_csv(&updated.read()), - added: split_csv(&added.read()), - removed: split_csv(&removed.read()), + updated: split_csv(&updated.read()) + .into_iter() + .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(); simulate diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs index 924029ff..7faa1664 100644 --- a/packages/oneclient_app/src/view/app/mod.rs +++ b/packages/oneclient_app/src/view/app/mod.rs @@ -13,14 +13,23 @@ pub mod settings; use crate::bridge::GameSnapshot; use oneclient_core::notification::LaunchStage; -pub fn launch_button_state(game: &GameSnapshot, cluster_id: i64) -> (&'static str, bool) { - match game.stage(cluster_id) { +pub fn launch_button_state( + game: &GameSnapshot, + cluster_id: i64, + syncing: bool, +) -> (&'static str, bool) { + let state = match game.stage(cluster_id) { Some(LaunchStage::Checking) => ("Checking", false), Some(LaunchStage::Downloading) => ("Downloading", false), Some(LaunchStage::Launching) => ("Launching", false), Some(LaunchStage::Running) => ("Running", false), _ => ("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; diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs index db41121a..ac3f44b9 100644 --- a/packages/oneclient_app/src/view/startup.rs +++ b/packages/oneclient_app/src/view/startup.rs @@ -4,8 +4,8 @@ use freya::{prelude::*, router::*}; use crate::AppAssets; use crate::Route; use crate::hooks::{ - terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, - use_settings_snapshot, use_terms, + terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, use_settings_snapshot, + use_splash, use_terms, }; use crate::theme::colors; @@ -18,6 +18,7 @@ impl Component for Startup { let settings = use_settings_snapshot(); let notifications = use_notifications_snapshot(); let terms = use_terms(); + let splash = use_splash(); let intro = use_animation(|conf| { 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()); - if launcher.ready && !launcher.fetching && !terms_is_loading(&terms) { + if launcher.ready && !terms_is_loading(&terms) { let document = terms_document(&terms); let required_terms = document.as_ref().map(|doc| doc.version).unwrap_or(1); let required_privacy = document @@ -56,8 +57,27 @@ impl Component for Startup { } else { 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); diff --git a/packages/oneclient_core/src/bundles/runtime.rs b/packages/oneclient_core/src/bundles/runtime.rs index fed583a7..0646898f 100644 --- a/packages/oneclient_core/src/bundles/runtime.rs +++ b/packages/oneclient_core/src/bundles/runtime.rs @@ -4,6 +4,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::bundles::manager::BundlesManager; use crate::bundles::ApplyBundleUpdatesResult; use crate::bundles::updates::apply_bundle_updates_for_all_clusters; +use crate::notification::GroupedProgressSession; use crate::state::LauncherServices; static BUNDLE_SYNCING: AtomicBool = AtomicBool::new(false); @@ -12,14 +13,18 @@ pub fn is_bundle_syncing() -> bool { 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)] pub async fn sync_all_cluster_bundles( bundles: &BundlesManager, services: &LauncherServices, + session: Option<&GroupedProgressSession>, ) -> Vec<(i64, ApplyBundleUpdatesResult)> { tracing::info!("syncing all cluster bundles"); 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, Err(err) => { tracing::error!("failed to apply bundle updates for clusters: {err:#}"); diff --git a/packages/oneclient_core/src/bundles/types.rs b/packages/oneclient_core/src/bundles/types.rs index 03f51acb..eab5489b 100644 --- a/packages/oneclient_core/src/bundles/types.rs +++ b/packages/oneclient_core/src/bundles/types.rs @@ -109,6 +109,14 @@ pub struct BundlePackageRemoval { pub hash: String, pub package_id: 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, + /// Remote project id of the installed artifact, for meta-cache lookups. + pub project_id: Option, + /// 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, } #[derive(Debug, Clone)] diff --git a/packages/oneclient_core/src/bundles/updates.rs b/packages/oneclient_core/src/bundles/updates.rs index f33cea64..173fd27b 100644 --- a/packages/oneclient_core/src/bundles/updates.rs +++ b/packages/oneclient_core/src/bundles/updates.rs @@ -263,11 +263,21 @@ async fn check_bundle_updates_inner( continue; } + let linked = all_linked_by_hash.get(&bundle_pkg.hash); removals_available.push(BundlePackageRemoval { cluster_id, hash: bundle_pkg.hash.clone(), package_id: pkg_id.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, bundles: &BundlesManager, services: &LauncherServices, +) -> LauncherResult { + // 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 { let lock = cluster_lock(cluster_id); 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()) - .then(|| { - crate::notification::GroupedProgressSession::start( - &services.notifier, - "Updating bundle content", - ) - }); - - if let Some(s) = session.as_ref() { + let has_downloads = + !check.updates_available.is_empty() || !check.additions_available.is_empty(); + if let Some(s) = session.filter(|_| has_downloads) { let count = (check.updates_available.len() + check.additions_available.len()) as u64; let bytes: u64 = check .updates_available @@ -379,7 +404,7 @@ pub async fn apply_bundle_updates( } for update in check.updates_available { - let child = session.as_ref().map(|s| { + let child = session.map(|s| { let c = s.child( update.new_file.display_name(), update.new_file.size.max(1), @@ -396,7 +421,7 @@ 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 child = session.map(|s| { let c = s.child( addition.new_file.display_name(), 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 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( bundles: &BundlesManager, services: &LauncherServices, + session: Option<&crate::notification::GroupedProgressSession>, ) -> LauncherResult> { let mut changed = Vec::new(); 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) => { if !result.updates_applied.is_empty() || !result.additions_applied.is_empty() diff --git a/packages/oneclient_core/src/notification/grouped.rs b/packages/oneclient_core/src/notification/grouped.rs index 4c2e1523..4891d74e 100644 --- a/packages/oneclient_core/src/notification/grouped.rs +++ b/packages/oneclient_core/src/notification/grouped.rs @@ -139,6 +139,14 @@ impl GroupedProgressSession { pub fn finish(self) { 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 {