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

View file

@ -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<InboxEntry>,
notification_center_open: &mut bool,
pending_prompt: &mut Option<PendingPrompt>,
commands_tx: &mpsc::UnboundedSender<BridgeCommand>,
) -> 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<NotificationSpec> {
let updated: Vec<String> = result
) -> Option<ClusterUpdateSummary> {
let updated: Vec<ClusterUpdateItem> = result
.updates_applied
.iter()
.map(|u| u.new_file.display_name())
.map(|u| item_from_bundle_file(&u.new_file))
.collect();
let added: Vec<String> = result
let added: Vec<ClusterUpdateItem> = result
.additions_applied
.iter()
.map(|a| a.new_file.display_name())
.map(|a| item_from_bundle_file(&a.new_file))
.collect();
let removed: Vec<String> = result
let removed: Vec<ClusterUpdateItem> = 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<NotificationSpec> {
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<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) {
let _ = tx.send(snapshots.clone());
}

View file

@ -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<String>,
pub data_dir: String,
}

View file

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

View file

@ -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<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();
};
@ -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<UpdateTab>,
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<UpdateTab>,
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<String> = 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()
}

View file

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

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
}
/// 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)]
pub struct BrowserCompatState(pub State<bool>);

View file

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

View file

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

View file

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

View file

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

View file

@ -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<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)]
pub struct ClusterUpdateSummary {
pub cluster_id: ClusterId,
pub cluster_name: String,
pub updated: Vec<String>,
pub added: Vec<String>,
pub removed: Vec<String>,
pub updated: Vec<ClusterUpdateItem>,
pub added: Vec<ClusterUpdateItem>,
pub removed: Vec<ClusterUpdateItem>,
}
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) {
let Some(&entry_id) = self.grouped_entries.get(&session_id) else {
return;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<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)]

View file

@ -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<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> {
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<Vec<(i64, ApplyBundleUpdatesResult)>> {
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()

View file

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