feat(ui): Placeholder UI for bundle update notification + modal

This commit is contained in:
LynithDev 2026-07-12 23:25:49 +02:00
parent d0c97b4177
commit 21313b2093
12 changed files with 667 additions and 129 deletions

View file

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

View file

@ -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<NotificationSpec> {
let updated: Vec<String> = result
.updates_applied
.iter()
.map(|u| u.new_file.display_name())
.collect();
let added: Vec<String> = result
.additions_applied
.iter()
.map(|a| a.new_file.display_name())
.collect();
let removed: Vec<String> = 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<BridgeSnapshot>, snapshots: &BridgeSnapshot) {
let _ = tx.send(snapshots.clone());
}

View file

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

View file

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

View file

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

View file

@ -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<crate::notifications::NotificationAction>,
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

View file

@ -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<String>) -> NotificationBuilder {
NotificationBuilder {
dispatch: self.clone(),
@ -299,13 +307,13 @@ impl NotificationBuilder {
self
}
pub fn action(mut self, label: impl Into<String>) -> 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<Item = impl Into<String>>) -> Self {
self.spec.actions = actions.into_iter().map(Into::into).collect();
pub fn actions(mut self, actions: impl IntoIterator<Item = NotificationAction>) -> Self {
self.spec.actions = actions.into_iter().collect();
self
}

View file

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

View file

@ -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<String>,
pub added: Vec<String>,
pub removed: Vec<String>,
}
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<String>,
pub actions: Vec<NotificationAction>,
pub tasks: Vec<TaskView>,
}
@ -41,7 +68,7 @@ pub struct NotificationSpec {
pub level: NotificationLevel,
pub icon: Option<IconType>,
pub progress: Option<(u64, u64)>,
pub actions: Vec<String>,
pub actions: Vec<NotificationAction>,
}
impl InboxEntry {
@ -89,6 +116,7 @@ pub struct NotificationSnapshot {
pub inbox: Vec<InboxEntry>,
pub center_open: bool,
pub pending_prompt: Option<PendingPromptView>,
pub cluster_update: Option<ClusterUpdateSummary>,
pub active_toast_entry_ids: Vec<u64>,
}
@ -106,6 +134,7 @@ pub struct NotificationState {
grouped_entries: HashMap<Uuid, u64>,
grouped_tasks: HashMap<Uuid, GroupedTasks>,
pending_timers: Vec<ToastDismissTimer>,
cluster_update: Option<ClusterUpdateSummary>,
}
#[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);
}

View file

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

View file

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

View file

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