diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs index 8e673e7d..5e64e9f6 100644 --- a/packages/oneclient_app/src/bridge/commands.rs +++ b/packages/oneclient_app/src/bridge/commands.rs @@ -6,7 +6,7 @@ use oneclient_core::packages::{ContentType, ProviderId}; use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate}; use oneclient_db::models::ClusterId; -use crate::notifications::NotificationSpec; +use crate::notifications::{ClusterUpdateSummary, NotificationSpec}; #[derive(Debug)] pub enum BridgeCommand { @@ -62,6 +62,8 @@ pub enum BridgeCommand { MarkNotificationRead(u64), DismissNotification(u64), AnswerPrompt(UserChoice), + OpenClusterUpdate(ClusterUpdateSummary), + CloseClusterUpdate, SendNotification { spec: NotificationSpec }, SendTestProgress { current: u64, diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 50fbbaf9..067aa53b 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -7,8 +7,10 @@ use oneclient_core::settings::store::{ use oneclient_core::{ClusterManager, LauncherState}; use tokio::sync::{mpsc, watch}; +use crate::components::IconType; use crate::notifications::{ - InboxEntry, MESSAGE_TOAST_TTL, NotificationState, PendingPrompt, PendingPromptView, + ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, NotificationActionKind, + NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, }; use super::commands::BridgeCommand; @@ -515,6 +517,13 @@ impl CoreBridgeRuntime { let _ = reply_tx.send(choice); } } + BridgeCommand::OpenClusterUpdate(summary) => { + notification_engine.open_cluster_update(summary); + *notification_center_open = false; + } + BridgeCommand::CloseClusterUpdate => { + notification_engine.close_cluster_update(); + } BridgeCommand::SendNotification { spec } => { notification_engine.push_custom(inbox, spec); } @@ -637,24 +646,36 @@ impl CoreBridgeRuntime { } BridgeCommand::ApplyBundleUpdates { cluster_id } => { let state = LauncherState::get()?; - oneclient_core::apply_bundle_updates( + let result = oneclient_core::apply_bundle_updates( cluster_id, state.bundles.as_ref(), &state.services, ) .await?; bump_clusters_generation(snapshots); + if let Some(spec) = + cluster_update_notification(cluster_id, &result, &state.services).await + { + notification_engine.push_custom(inbox, spec); + } } BridgeCommand::SyncBundles => { let state = LauncherState::get()?; state.bundles.sync(&state.services).await?; oneclient_core::clusters::ensure_from_bundles(&state).await?; - oneclient_core::bundles::sync_all_cluster_bundles( + 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); + } + } } } @@ -662,6 +683,63 @@ impl CoreBridgeRuntime { } } +async fn cluster_update_notification( + cluster_id: i64, + result: &oneclient_core::ApplyBundleUpdatesResult, + services: &oneclient_core::LauncherServices, +) -> Option { + let updated: Vec = result + .updates_applied + .iter() + .map(|u| u.new_file.display_name()) + .collect(); + let added: Vec = result + .additions_applied + .iter() + .map(|a| a.new_file.display_name()) + .collect(); + let removed: Vec = result + .removals_applied + .iter() + .map(|r| r.package_id.clone()) + .collect(); + + if updated.is_empty() && added.is_empty() && removed.is_empty() { + return None; + } + + let cluster_name = PackageStore::get_cluster(cluster_id, services) + .await + .map(|c| c.name) + .unwrap_or_else(|_| "Cluster".to_string()); + + let summary = ClusterUpdateSummary { + cluster_id, + cluster_name: cluster_name.clone(), + updated, + added, + removed, + }; + + let total = summary.total(); + let body = format!( + "{total} package{} changed in {cluster_name}", + if total == 1 { "" } else { "s" } + ); + + Some(NotificationSpec { + title: "Cluster updated".to_string(), + body, + level: NotificationLevel::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: vec![NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summary), + }], + }) +} + fn publish(tx: &watch::Sender, snapshots: &BridgeSnapshot) { let _ = tx.send(snapshots.clone()); } diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs new file mode 100644 index 00000000..7880627c --- /dev/null +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -0,0 +1,267 @@ +use freya::{prelude::*, router::RouterContext}; + +use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::notifications::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_H: f32 = 400.; +const BANNER_W: f32 = 300.; + +#[derive(PartialEq)] +pub struct ClusterUpdatePopup; + +impl Component for ClusterUpdatePopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + let Some(summary) = snapshot.cluster_update.clone() else { + return rect().into_element(); + }; + + let close = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.close_cluster_update()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(&summary, dispatch)), + ) + .into_element() + } +} + +fn dialog(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> impl IntoElement { + rect() + .horizontal() + .width(Size::px(DIALOG_W)) + .height(Size::px(DIALOG_H)) + .max_width(Size::window_percent(95.)) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(16.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from((0., 18., 52., 0., Color::from_argb(150, 0, 0, 0)))) + .child(banner(summary)) + .child(content(summary, 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 { + let dismiss = dispatch.clone(); + let cluster_id = summary.cluster_id; + let open = move |_| { + dispatch.close_cluster_update(); + let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); + }; + + let total = summary.total(); + + let mut sections = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(10.); + + 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, + )); + } + + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new_all(22.)) + .spacing(14.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text("Changes applied") + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{total} change{} synced to this cluster.", + if total == 1 { "" } else { "s" } + )) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) + .child(sections) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| dismiss.close_cluster_update()) + .text("Dismiss"), + ) + .child( + Button::new() + .primary() + .on_press(open) + .text("Open cluster") + .child(Icon::new(IconType::ArrowRight).size(15.)), + ), + ) +} + +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( + 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), + ) + .child( + label() + .text(item.clone()) + .font_size(12.5) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ), + ); + } + + 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) +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 44411060..4d708397 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -4,6 +4,7 @@ mod avatar; mod button; mod charts; mod cluster_landscape_art; +mod cluster_update_popup; mod context_menu; mod dropdown; mod dynamic_art; @@ -36,6 +37,7 @@ pub use avatar::Avatar; pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; pub use charts::{BarChart, ValueUnit}; pub use cluster_landscape_art::ClusterLandscapeArt; +pub use cluster_update_popup::ClusterUpdatePopup; pub use context_menu::ContextMenu; pub use dropdown::Dropdown; pub use dynamic_art::DynamicArt; diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index 210607ba..ae17e46c 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -9,7 +9,7 @@ use crate::{ Route, components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea}, hooks::{use_dispatch, use_notifications_snapshot}, - notifications::InboxEntry, + notifications::{InboxEntry, NotificationActionKind}, theme::colors, }; @@ -139,18 +139,13 @@ impl Component for NotifEntryRow { let id = entry.id; let dispatch = use_dispatch(); let enter_dispatch = dispatch.clone(); + let body_dispatch = dispatch.clone(); let click_dismissable = entry.click_dismissable(); + let has_actions = !entry.actions.is_empty(); let mut hovered = use_state(|| false); let expanded = use_state(|| false); - let intro = use_animation(|conf| { - conf.on_creation(OnCreation::Run); - AnimNum::new(0., 1.).time(240).ease(Ease::Out) - }); - let progress = intro.read().value(); - let slide = (1.0 - progress) * 16.0; - let content = rect() .horizontal() .width(Size::fill()) @@ -158,9 +153,7 @@ impl Component for NotifEntryRow { .cross_align(Alignment::Start) .padding(Gaps::new_all(8.)) .corner_radius(CornerRadius::new_all(8.)) - .opacity(progress) - .margin(Gaps::new(0., 0., 0., slide)) - .maybe(*hovered.read(), |el| { + .maybe(*hovered.read() && !has_actions, |el| { el.background(colors::ghost_overlay_hover()) }) .on_pointer_enter(move |_| { @@ -183,7 +176,7 @@ impl Component for NotifEntryRow { .color(level_color(&entry.level)), ), ) - .child(row_body(&entry)); + .child(row_body(&entry, body_dispatch)); rect() .vertical() @@ -307,7 +300,7 @@ fn phase_badge(phase: &str) -> impl IntoElement { .into_element() } -fn row_body(entry: &InboxEntry) -> impl IntoElement { +fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement { rect() .vertical() .width(Size::flex(1.0)) @@ -354,10 +347,10 @@ fn row_body(entry: &InboxEntry) -> impl IntoElement { .font_size(12.) .color(colors::fg_secondary()), ) - .child(row_extra(entry)) + .child(row_extra(entry, dispatch)) } -fn row_extra(entry: &InboxEntry) -> impl IntoElement { +fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement { if let Some((current, total)) = entry.progress.filter(|_| entry.is_loading) { let frac = (current as f32 / total.max(1) as f32).clamp(0.0, 1.0); return rect() @@ -377,17 +370,40 @@ fn row_extra(entry: &InboxEntry) -> impl IntoElement { } if !entry.actions.is_empty() { + let id = entry.id; + let dismiss_dispatch = dispatch.clone(); + let mut row = rect() .horizontal() - .spacing(16.) - .margin(Gaps::new(8., 0., 0., 0.)); + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .margin(Gaps::new(8., 0., 0., 0.)) + .child( + Button::new() + .small() + .secondary() + .width(Size::flex(1.0)) + .text("Dismiss") + .on_press(move |_| dismiss_dispatch.dismiss_notification(id)), + ); - for (i, text) in entry.actions.iter().enumerate() { - row = row.child(Button::new().small().text(text.clone()).variant(if i == 0 { - ButtonVariant::Primary - } else { - ButtonVariant::Secondary - })); + for (i, action) in entry.actions.iter().enumerate() { + let action_dispatch = dispatch.clone(); + let kind = action.kind.clone(); + row = row.child( + Button::new() + .small() + .width(Size::flex(1.0)) + .text(action.label.clone()) + .variant(if i == 0 { + ButtonVariant::Primary + } else { + ButtonVariant::Secondary + }) + .on_press(move |_| run_action(&action_dispatch, &kind)), + ); } return row.into_element(); @@ -396,6 +412,14 @@ fn row_extra(entry: &InboxEntry) -> impl IntoElement { rect().into_element() } +fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { + match kind { + NotificationActionKind::OpenClusterUpdate(summary) => { + dispatch.open_cluster_update(summary.clone()); + } + } +} + fn divider() -> impl IntoElement { rect() .width(Size::fill()) diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index 857c119e..2ea7c958 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -7,7 +7,7 @@ use oneclient_core::notification::NotificationLevel; use crate::{ components::{Button, ButtonSize, Icon, IconType}, hooks::{use_dispatch, use_notifications_snapshot}, - notifications::InboxEntry, + notifications::{InboxEntry, NotificationActionKind}, theme::colors, }; @@ -263,11 +263,7 @@ fn message_card( } fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl_pct: f32) -> Rect { - let review_label = entry - .actions - .first() - .cloned() - .unwrap_or_else(|| "Review".to_string()); + let action = entry.actions.first().cloned(); toast_shell(entry) .child( @@ -287,60 +283,60 @@ fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl .child(header(entry)), ) .child(divider()) - .child(action_row(review_label, dispatch, id)), + .child(action_row(action, dispatch, id)), ) .child(ttl_bar(entry, ttl_pct)) } -fn action_row(review_label: String, dispatch: crate::BridgeDispatch, id: u64) -> impl IntoElement { +fn action_row( + action: Option, + dispatch: crate::BridgeDispatch, + id: u64, +) -> impl IntoElement { let dismiss = dispatch.clone(); + let review_label = action + .as_ref() + .map(|a| a.label.clone()) + .unwrap_or_else(|| "Review".to_string()); + let review_kind = action.map(|a| a.kind); + rect() .horizontal() .width(Size::fill()) .content(Content::Flex) .cross_align(Alignment::Center) - .padding(Gaps::new(0., 30., 0., 30.)) + .spacing(8.) .child( - rect() - .horizontal() + Button::new() + .small() + .secondary() .width(Size::flex(1.0)) - .cross_align(Alignment::Center) - .spacing(8.) - .on_press(move |_| dispatch.mark_notification_read(id)) - .child( - Icon::new(IconType::Eye) - .size(18.) - .color(colors::toast_action()), - ) - .child( - label() - .text(review_label) - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(colors::toast_action()), - ), + .on_press(move |_| dismiss.dismiss_toast(id)) + .child(Icon::new(IconType::XClose).size(16.)) + .text("Dismiss"), ) .child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(8.) - .on_press(move |_| dismiss.dismiss_toast(id)) - .child( - Icon::new(IconType::XClose) - .size(18.) - .color(colors::toast_action()), - ) - .child( - label() - .text("Dismiss") - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(colors::toast_action()), - ), + Button::new() + .small() + .primary() + .width(Size::flex(1.0)) + .on_press(move |_| match &review_kind { + Some(kind) => run_action(&dispatch, kind), + None => dispatch.mark_notification_read(id), + }) + .child(Icon::new(IconType::Eye).size(16.)) + .text(review_label), ) } +fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { + match kind { + NotificationActionKind::OpenClusterUpdate(summary) => { + dispatch.open_cluster_update(summary.clone()); + } + } +} + fn progress_card(entry: &InboxEntry) -> Rect { let frac = entry .progress diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs index 4a3fc3f3..488dd8b8 100644 --- a/packages/oneclient_app/src/hooks/dispatch.rs +++ b/packages/oneclient_app/src/hooks/dispatch.rs @@ -4,7 +4,7 @@ use oneclient_db::models::ClusterId; use crate::bridge::{BridgeCommand, OneClientBridge}; use crate::components::IconType; -use crate::notifications::NotificationSpec; +use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationSpec}; #[derive(Clone)] pub struct BridgeDispatch { @@ -184,6 +184,14 @@ impl BridgeDispatch { self.send(BridgeCommand::AnswerPrompt(choice)); } + pub fn open_cluster_update(&self, summary: ClusterUpdateSummary) { + self.send(BridgeCommand::OpenClusterUpdate(summary)); + } + + pub fn close_cluster_update(&self) { + self.send(BridgeCommand::CloseClusterUpdate); + } + pub fn notify(&self, title: impl Into) -> NotificationBuilder { NotificationBuilder { dispatch: self.clone(), @@ -299,13 +307,13 @@ impl NotificationBuilder { self } - pub fn action(mut self, label: impl Into) -> Self { - self.spec.actions.push(label.into()); + pub fn action(mut self, action: NotificationAction) -> Self { + self.spec.actions.push(action); self } - pub fn actions(mut self, actions: impl IntoIterator>) -> Self { - self.spec.actions = actions.into_iter().map(Into::into).collect(); + pub fn actions(mut self, actions: impl IntoIterator) -> Self { + self.spec.actions = actions.into_iter().collect(); self } diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 664aca99..c8d6df51 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,7 +2,7 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay, + AccountSwitcher, ClusterUpdatePopup, NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay, }; use crate::routes::Route; use crate::theme; @@ -31,6 +31,7 @@ impl Component for RootLayout { .child(AccountSwitcher) .child(Toasts) .child(UpdatePromptOverlay) + .child(ClusterUpdatePopup) .child(StatusBar) } } diff --git a/packages/oneclient_app/src/notifications/mod.rs b/packages/oneclient_app/src/notifications.rs similarity index 93% rename from packages/oneclient_app/src/notifications/mod.rs rename to packages/oneclient_app/src/notifications.rs index d7d4f6f0..4b829575 100644 --- a/packages/oneclient_app/src/notifications/mod.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, UserChoice, }; +use oneclient_db::models::ClusterId; use tokio::sync::oneshot; use uuid::Uuid; @@ -11,6 +12,32 @@ use crate::components::IconType; pub const MESSAGE_TOAST_TTL: Duration = Duration::from_secs(5); +#[derive(Clone, Debug, PartialEq)] +pub struct NotificationAction { + pub label: String, + pub kind: NotificationActionKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum NotificationActionKind { + OpenClusterUpdate(ClusterUpdateSummary), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ClusterUpdateSummary { + pub cluster_id: ClusterId, + pub cluster_name: String, + pub updated: Vec, + pub added: Vec, + pub removed: Vec, +} + +impl ClusterUpdateSummary { + pub fn total(&self) -> usize { + self.updated.len() + self.added.len() + self.removed.len() + } +} + #[derive(Clone, Debug, PartialEq)] pub struct InboxEntry { pub level: NotificationLevel, @@ -22,7 +49,7 @@ pub struct InboxEntry { pub is_loading: bool, pub read: bool, pub created_at: Instant, - pub actions: Vec, + pub actions: Vec, pub tasks: Vec, } @@ -41,7 +68,7 @@ pub struct NotificationSpec { pub level: NotificationLevel, pub icon: Option, pub progress: Option<(u64, u64)>, - pub actions: Vec, + pub actions: Vec, } impl InboxEntry { @@ -89,6 +116,7 @@ pub struct NotificationSnapshot { pub inbox: Vec, pub center_open: bool, pub pending_prompt: Option, + pub cluster_update: Option, pub active_toast_entry_ids: Vec, } @@ -106,6 +134,7 @@ pub struct NotificationState { grouped_entries: HashMap, grouped_tasks: HashMap, pending_timers: Vec, + cluster_update: Option, } #[derive(Default)] @@ -145,10 +174,19 @@ impl NotificationState { inbox: inbox.to_vec(), center_open, pending_prompt, + cluster_update: self.cluster_update.clone(), active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(), } } + pub fn open_cluster_update(&mut self, summary: ClusterUpdateSummary) { + self.cluster_update = Some(summary); + } + + pub fn close_cluster_update(&mut self) { + self.cluster_update = None; + } + pub fn unread_count(inbox: &[InboxEntry]) -> usize { inbox.iter().filter(|entry| !entry.read).count() } @@ -283,7 +321,7 @@ impl NotificationState { if inbox .iter() .find(|e| e.id == entry_id) - .is_some_and(|e| e.click_dismissable()) + .is_some_and(|e| e.dismissable()) { self.forget_entry(inbox, entry_id); } diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 68196d88..13e6b66b 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -3,6 +3,7 @@ use freya::router::RouterContext; use crate::components::{Button, Icon, IconType, TextInput, toggle}; use crate::hooks::use_dispatch; +use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind}; use crate::routes::Route; use crate::theme::colors; @@ -20,48 +21,58 @@ impl Component for Debug { .vertical() .width(Size::fill()) .height(Size::fill()) - .overflow(Overflow::Clip) .padding(40.) .spacing(8.) .child( - rect() - .vertical() + ScrollView::new() .child( - label() - .text("Debug") - .font_size(32.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), + rect() + .vertical() + .child( + label() + .text("Debug") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("The end user won't really be looking at this page.") + .font_size(13.) + .color(colors::fg_secondary()), + ), ) - .child( - label() - .text("The end user won't really be looking at this page.") - .font_size(13.) - .color(colors::fg_secondary()), - ), + .child(divider()) + .child(section( + "Settings", + vec![ + toggle_row("Log Debug Info", log_debug_info), + toggle_row("Show Dev stuff", show_dev_stuff), + toggle_row("Seen Onboarding", seen_onboarding), + toggle_row("Use Grid On Mods List", use_grid_on_mods_list), + ], + )) + .child(divider()) + .child(section( + "Toast Controller", + vec![ToastController.into_element()], + )) + .child(divider()) + .child(section( + "Cluster Update Simulator", + vec![ClusterUpdateSimulator.into_element()], + )) + .child(divider()) + .child(section( + "Other", + vec![action_row(vec![ + ("Open Dev Tools", IconType::CodeSnippet02), + ("Open Onboarding", IconType::Rocket02), + ("Open Launcher Data", IconType::Folder), + ("Log Running Processes", IconType::Terminal), + ])], + )), ) - .child(divider()) - .child(section( - "Settings", - vec![ - toggle_row("Log Debug Info", log_debug_info), - toggle_row("Show Dev stuff", show_dev_stuff), - toggle_row("Seen Onboarding", seen_onboarding), - toggle_row("Use Grid On Mods List", use_grid_on_mods_list), - ], - )) - .child(divider()) - .child(section("Toast Controller", vec![ToastController.into_element()])) - .child(divider()) - .child(section( - "Other", - vec![action_row(vec![ - ("Open Dev Tools", IconType::CodeSnippet02), - ("Open Onboarding", IconType::Rocket02), - ("Open Launcher Data", IconType::Folder), - ("Log Running Processes", IconType::Terminal), - ])], - )) } } @@ -123,7 +134,8 @@ impl Component for ToastController { .child(Icon::new(IconType::AlertTriangle).size(16.)) .text("Send Error") .on_press(move |_| { - error.notify(title.read().clone()) + error + .notify(title.read().clone()) .body(body.read().clone()) .error() .send(); @@ -154,6 +166,101 @@ impl Component for ToastController { } } +#[derive(PartialEq)] +struct ClusterUpdateSimulator; + +impl Component for ClusterUpdateSimulator { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let cluster_id = use_state(|| "1".to_string()); + let cluster_name = use_state(|| "PolyBlock".to_string()); + let updated = use_state(|| "Sodium 0.5 → 0.6, Iris 1.7 → 1.8".to_string()); + let added = use_state(|| "Lithium".to_string()); + let removed = use_state(|| "OptiFine".to_string()); + + let simulate = dispatch; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + rect() + .width(Size::px(120.)) + .child(TextInput::new(cluster_id).placeholder("Cluster ID")), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(cluster_name).placeholder("Cluster name")), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(updated).placeholder("Updated (comma separated)"), + ), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(added).placeholder("Added (comma separated)")), + ) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(removed).placeholder("Removed (comma separated)"), + ), + ), + ) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::DownloadCloud02).size(16.)) + .text("Simulate Cluster Update") + .on_press(move |_| { + let name = cluster_name.read().clone(); + 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()), + }; + let total = summary.total(); + simulate + .notify("Cluster updated") + .body(format!( + "{total} package{} changed in {name}", + if total == 1 { "" } else { "s" } + )) + .icon(IconType::DownloadCloud02) + .action(NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summary), + }) + .send(); + }), + ) + .into_element() + } +} + +fn split_csv(raw: &str) -> Vec { + raw.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + fn divider() -> impl IntoElement { rect() .width(Size::fill()) @@ -230,10 +337,7 @@ fn toggle_row(title: &'static str, on: State) -> Element { } fn action_row(buttons: Vec<(&'static str, IconType)>) -> Element { - let mut row = rect() - .horizontal() - .width(Size::fill()) - .spacing(12.); + let mut row = rect().horizontal().width(Size::fill()).spacing(12.); for (text, icon) in buttons { let mut button = Button::new() diff --git a/packages/oneclient_core/src/bundles/runtime.rs b/packages/oneclient_core/src/bundles/runtime.rs index c182cec0..28323789 100644 --- a/packages/oneclient_core/src/bundles/runtime.rs +++ b/packages/oneclient_core/src/bundles/runtime.rs @@ -1,6 +1,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::state::LauncherServices; @@ -10,10 +11,18 @@ pub fn is_bundle_syncing() -> bool { BUNDLE_SYNCING.load(Ordering::Relaxed) } -pub async fn sync_all_cluster_bundles(bundles: &BundlesManager, services: &LauncherServices) { +pub async fn sync_all_cluster_bundles( + bundles: &BundlesManager, + services: &LauncherServices, +) -> Vec<(i64, ApplyBundleUpdatesResult)> { BUNDLE_SYNCING.store(true, Ordering::Relaxed); - if let Err(err) = apply_bundle_updates_for_all_clusters(bundles, services).await { - tracing::error!("failed to apply bundle updates for clusters: {err:#}"); - } + let changed = match apply_bundle_updates_for_all_clusters(bundles, services).await { + Ok(changed) => changed, + Err(err) => { + tracing::error!("failed to apply bundle updates for clusters: {err:#}"); + Vec::new() + } + }; BUNDLE_SYNCING.store(false, Ordering::Relaxed); + changed } diff --git a/packages/oneclient_core/src/bundles/updates.rs b/packages/oneclient_core/src/bundles/updates.rs index 7192da30..c568cf00 100644 --- a/packages/oneclient_core/src/bundles/updates.rs +++ b/packages/oneclient_core/src/bundles/updates.rs @@ -593,17 +593,26 @@ pub async fn get_bundles_with_update_status( pub async fn apply_bundle_updates_for_all_clusters( bundles: &BundlesManager, services: &LauncherServices, -) -> LauncherResult<()> { +) -> LauncherResult> { + let mut changed = Vec::new(); for cluster in cluster_dao::list_all(&services.db).await? { - if let Err(err) = apply_bundle_updates(cluster.id, bundles, services).await { - tracing::warn!( + match apply_bundle_updates(cluster.id, bundles, services).await { + Ok(result) => { + if !result.updates_applied.is_empty() + || !result.additions_applied.is_empty() + || !result.removals_applied.is_empty() + { + changed.push((cluster.id, result)); + } + } + Err(err) => tracing::warn!( cluster_id = cluster.id, error = %err, "bundle update apply failed for cluster" - ); + ), } } - Ok(()) + Ok(changed) } fn bundle_package_key(