diff --git a/.sqlx/query-3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f.json b/.sqlx/query-3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f.json new file mode 100644 index 00000000..e2886846 --- /dev/null +++ b/.sqlx/query-3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO cluster_optional_mods\n (cluster_id, bundle_name, package_id, bundle_version_id, seen_status)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT(cluster_id, package_id) DO UPDATE SET\n bundle_name = excluded.bundle_name,\n bundle_version_id = excluded.bundle_version_id\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f" +} diff --git a/.sqlx/query-5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71.json b/.sqlx/query-5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71.json new file mode 100644 index 00000000..73de2a51 --- /dev/null +++ b/.sqlx/query-5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id, cluster_id, bundle_name, package_id, bundle_version_id,\n seen_status, queued_at\n FROM cluster_optional_mods\n WHERE cluster_id = ?\n ORDER BY queued_at, id\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_optional_mods", + "name": "id" + } + } + }, + { + "name": "cluster_id", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_optional_mods", + "name": "cluster_id" + } + } + }, + { + "name": "bundle_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_optional_mods", + "name": "bundle_name" + } + } + }, + { + "name": "package_id", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_optional_mods", + "name": "package_id" + } + } + }, + { + "name": "bundle_version_id", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_optional_mods", + "name": "bundle_version_id" + } + } + }, + { + "name": "seen_status", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_optional_mods", + "name": "seen_status" + } + } + }, + { + "name": "queued_at", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_optional_mods", + "name": "queued_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71" +} diff --git a/.sqlx/query-e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca.json b/.sqlx/query-e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca.json new file mode 100644 index 00000000..9e6af624 --- /dev/null +++ b/.sqlx/query-e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE cluster_optional_mods SET seen_status = ?\n WHERE cluster_id = ? AND package_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca" +} diff --git a/.sqlx/query-e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13.json b/.sqlx/query-e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13.json new file mode 100644 index 00000000..5ecd038a --- /dev/null +++ b/.sqlx/query-e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM cluster_optional_mods\n WHERE cluster_id = ? AND package_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13" +} diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs index 64df1c52..4a94d153 100644 --- a/packages/oneclient_app/src/components/cluster_update_popup.rs +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -21,16 +21,23 @@ enum UpdateTab { Updates, Additions, Removals, + Optional, } impl UpdateTab { - const ALL: [UpdateTab; 3] = [UpdateTab::Updates, UpdateTab::Additions, UpdateTab::Removals]; + const ALL: [UpdateTab; 4] = [ + UpdateTab::Updates, + UpdateTab::Additions, + UpdateTab::Removals, + UpdateTab::Optional, + ]; fn label(self) -> &'static str { match self { UpdateTab::Updates => "Updated", UpdateTab::Additions => "Added", UpdateTab::Removals => "Removed", + UpdateTab::Optional => "Optional", } } @@ -39,6 +46,7 @@ impl UpdateTab { UpdateTab::Updates => "Nothing was updated.", UpdateTab::Additions => "Nothing was added.", UpdateTab::Removals => "Nothing was removed.", + UpdateTab::Optional => "Nothing optional was offered.", } } @@ -47,6 +55,7 @@ impl UpdateTab { UpdateTab::Updates => IconType::RefreshCw01, UpdateTab::Additions => IconType::Plus, UpdateTab::Removals => IconType::Trash01, + UpdateTab::Optional => IconType::Plus, } } @@ -55,6 +64,7 @@ impl UpdateTab { UpdateTab::Updates => colors::brand(), UpdateTab::Additions => colors::success(), UpdateTab::Removals => colors::danger(), + UpdateTab::Optional => colors::brand(), } } @@ -63,6 +73,7 @@ impl UpdateTab { UpdateTab::Updates => &summary.updated, UpdateTab::Additions => &summary.added, UpdateTab::Removals => &summary.removed, + UpdateTab::Optional => &summary.optional, } } } @@ -88,7 +99,13 @@ impl Component for ClusterUpdatePopup { let all_items: Vec<&ClusterUpdateItem> = summaries .iter() .flatten() - .flat_map(|s| s.updated.iter().chain(&s.added).chain(&s.removed)) + .flat_map(|s| { + s.updated + .iter() + .chain(&s.added) + .chain(&s.removed) + .chain(&s.optional) + }) .collect(); let mut meta = MetaMap::new(); for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index af74c911..48388d22 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -6,6 +6,7 @@ mod charts; mod cluster_landscape_art; mod cluster_update_popup; mod context_menu; +mod optional_mods_popup; mod dropdown; mod dynamic_art; mod file_drop; @@ -45,6 +46,7 @@ pub use charts::{BarChart, PieChart, ValueUnit, slice_color}; pub use cluster_landscape_art::ClusterLandscapeArt; pub use cluster_update_popup::ClusterUpdatePopup; pub use context_menu::ContextMenu; +pub use optional_mods_popup::OptionalModsPopup; pub use dropdown::Dropdown; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; pub use file_drop::FileDropOverlay; @@ -62,6 +64,7 @@ pub use navbar::{AppNavbar, OnboardingNavbar}; pub use notifications::NotificationCenter; pub use overlay_popup::OverlayPopup; pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge}; +pub(crate) use package_row::{CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon}; pub use package_update_popup::PackageUpdatePopup; pub use pagination::Pagination; pub use player_model::PlayerModel; diff --git a/packages/oneclient_app/src/components/optional_mods_popup.rs b/packages/oneclient_app/src/components/optional_mods_popup.rs new file mode 100644 index 00000000..ca952f54 --- /dev/null +++ b/packages/oneclient_app/src/components/optional_mods_popup.rs @@ -0,0 +1,380 @@ +use std::collections::{HashMap, HashSet}; + +use freya::prelude::*; +use oneclient_content::packages::{CachedPackageMeta, ProviderId}; + +use crate::components::{ + Button, CARD_GRID_H, GRID_GAP, GRID_MIN_W, Icon, IconType, OverlayPopup, PackageEntry, + grid_card, package_icon, +}; +use crate::hooks::{ + package_meta_batch, use_cached_image, use_dispatch, use_notifications_snapshot, + use_package_meta_batch, +}; +use crate::notifications::{ClusterUpdateItem, OptionalModRef, OptionalModsGroup}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 720.; +const DIALOG_PAD: f32 = 22.; +const LIST_MAX_H: f32 = 306.; +const ICON_SIZE: f32 = 52.; + +type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; + +type Picked = HashSet<(i64, String)>; + +#[derive(PartialEq)] +pub struct OptionalModsPopup; + +impl Component for OptionalModsPopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let picked = use_state(Picked::new); + + let groups = snapshot.optional_mods.clone(); + + let mut meta = MetaMap::new(); + for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + let ids: Vec = groups + .iter() + .flatten() + .flat_map(|group| &group.mods) + .filter(|item| item.provider == provider) + .filter_map(|item| item.project_id.clone()) + .collect(); + let query = use_package_meta_batch(provider, ids); + for (project_id, cached) in package_meta_batch(&query) { + meta.insert((provider, project_id), cached); + } + } + + let Some(groups) = groups.filter(|groups| !groups.is_empty()) else { + return rect().into_element(); + }; + + let close = dispatch.clone(); + let dismissed: Vec<(i64, OptionalModRef)> = + groups.iter().flat_map(|group| group.offers()).collect(); + + OverlayPopup::new() + .on_close(move |_| close.decline_optional_mods(dismissed.clone())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(&groups, &meta, picked, dispatch)), + ) + .into_element() + } +} + +fn package_key(item: &ClusterUpdateItem) -> String { + item.offer + .as_ref() + .map(|(_, package_id)| package_id.clone()) + .unwrap_or_else(|| item.fallback.clone()) +} + +fn entry_from_item( + item: &ClusterUpdateItem, + meta: &MetaMap, + package_id: String, + selected: bool, +) -> PackageEntry { + let cached = item + .project_id + .as_ref() + .and_then(|project_id| meta.get(&(item.provider, project_id.clone()))); + + PackageEntry { + package_id, + bundle_name: item.offer.as_ref().map(|(bundle, _)| bundle.clone()), + provider: item.provider, + name: cached + .map(|cached| cached.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| item.fallback.clone()), + file_name: item.fallback.clone(), + author: cached + .map(|cached| cached.author.clone()) + .unwrap_or_default(), + description: cached + .map(|cached| cached.summary.clone()) + .unwrap_or_default(), + icon_url: cached.and_then(|cached| cached.icon_url.clone()), + size: 0, + categories: Vec::new(), + enabled: selected, + installed: false, + hash: None, + manifest_default: false, + hidden: false, + update_available: false, + } +} + +fn dialog( + groups: &[OptionalModsGroup], + meta: &MetaMap, + picked: State, + dispatch: crate::Actions, +) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::Inner) + .max_width(Size::window_percent(95.)) + .max_height(Size::window_percent(85.)) + .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(content(groups, meta, picked, dispatch)) +} + +fn content( + groups: &[OptionalModsGroup], + meta: &MetaMap, + picked: State, + dispatch: crate::Actions, +) -> impl IntoElement { + let total: usize = groups.iter().map(|group| group.mods.len()).sum(); + let offers: Vec<(i64, OptionalModRef)> = + groups.iter().flat_map(|group| group.offers()).collect(); + + let chosen = picked.read().clone(); + let selected = + |cluster_id: i64, package_id: &str| chosen.contains(&(cluster_id, package_id.to_string())); + let (enable, skip): (Vec<_>, Vec<_>) = offers + .iter() + .cloned() + .partition(|(cluster_id, (_, package_id))| selected(*cluster_id, package_id)); + + let plural = if total == 1 { "" } else { "s" }; + let subtitle = match groups { + [only] => format!( + "{} includes {total} optional mod{plural} that {} off by default. Pick the {} to turn on.", + only.cluster_name, + if total == 1 { "is" } else { "are" }, + if total == 1 { "one" } else { "ones" } + ), + _ => format!( + "Your bundles include {total} optional mod{plural} across {} clusters that are off by default. Pick the ones to turn on.", + groups.len() + ), + }; + + let cancel = dispatch.clone(); + let declined = offers.clone(); + + let enable_text = if enable.is_empty() { + "Enable".to_string() + } else { + format!("Enable {}", enable.len()) + }; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::Inner) + .padding(Gaps::new_all(DIALOG_PAD)) + .spacing(14.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text("Optional mods available") + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.5) + .max_lines(3) + .color(colors::fg_secondary()), + ), + ) + .child(mod_list(groups, meta, picked)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| cancel.decline_optional_mods(declined.clone())) + .text("Cancel"), + ) + .maybe_child((!offers.is_empty()).then(|| { + let apply = dispatch.clone(); + Button::new() + .primary() + .disabled(enable.is_empty()) + .on_press(move |_| { + apply.record_skipped_optional_mods(skip.clone()); + apply.enable_optional_mods(enable.clone()); + }) + .child(Icon::new(IconType::Check).size(15.)) + .text(enable_text.clone()) + .into_element() + })), + ) +} + +fn grid_columns() -> usize { + let content_w = DIALOG_W - DIALOG_PAD * 2.; + (((content_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 3) +} + +fn mod_list( + groups: &[OptionalModsGroup], + meta: &MetaMap, + picked: State, +) -> impl IntoElement { + let show_headers = groups.len() > 1; + let cols = grid_columns(); + + let mut list = rect().vertical().width(Size::fill()).spacing(GRID_GAP); + + for (group_index, group) in groups.iter().enumerate() { + if show_headers { + list = list.child(cluster_header(&group.cluster_name, group_index == 0)); + } + + for (row_index, row_items) in group.mods.chunks(cols).enumerate() { + list = list.child(grid_row( + group.cluster_id, + row_items, + meta, + picked, + cols, + format!("{group_index}-{row_index}"), + )); + } + } + + ScrollView::new() + .width(Size::fill()) + .height(Size::Inner) + .max_height(Size::px(LIST_MAX_H)) + .child(list) +} + +fn grid_row( + cluster_id: i64, + items: &[ClusterUpdateItem], + meta: &MetaMap, + picked: State, + cols: usize, + key: String, +) -> impl IntoElement { + let mut row = rect() + .key(key) + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_GRID_H)) + .spacing(GRID_GAP) + .content(Content::Flex); + + for slot in 0..cols { + let cell = rect().width(Size::flex(1.0)).height(Size::px(CARD_GRID_H)); + row = row.child(match items.get(slot) { + Some(item) => { + let package_id = package_key(item); + cell.key(package_id.clone()) + .child(mod_card(cluster_id, item, meta, picked, package_id)) + } + None => cell, + }); + } + + row +} + +fn mod_card( + cluster_id: i64, + item: &ClusterUpdateItem, + meta: &MetaMap, + mut picked: State, + package_id: String, +) -> impl IntoElement { + let key = (cluster_id, package_id.clone()); + let selected = picked.read().contains(&key); + + let on_toggle: EventHandler<()> = (move |()| { + let mut next = picked.read().clone(); + if !next.remove(&key) { + next.insert(key.clone()); + } + picked.set(next); + }) + .into(); + + OptionalModCard { + entry: entry_from_item(item, meta, package_id, selected), + cluster_id, + on_toggle, + } +} + +#[derive(PartialEq)] +struct OptionalModCard { + entry: PackageEntry, + cluster_id: i64, + on_toggle: EventHandler<()>, +} + +impl Component for OptionalModCard { + fn render(&self) -> impl IntoElement { + let icon_query = use_cached_image(self.entry.icon_url.clone(), 256); + let icon = package_icon(&self.entry, &icon_query, ICON_SIZE); + + grid_card( + &self.entry, + "mod", + self.cluster_id, + icon, + self.on_toggle.clone(), + false, + ) + } +} + +fn cluster_header(name: &str, first: bool) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(6., 8.)) + .margin(Gaps::new(if first { 0. } else { 8. }, 0., 3., 0.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .child( + label() + .text(name.to_string()) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) +} diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 65c0047b..defb589e 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -12,6 +12,9 @@ use crate::utils::format_size; pub(crate) const CARD_BG: Color = Color::from_rgb(26, 34, 41); pub(crate) const CARD_NAME: Color = Color::from_rgb(213, 219, 255); pub(crate) const CARD_H: f32 = 84.; +pub(crate) const CARD_GRID_H: f32 = 148.; +pub(crate) const GRID_GAP: f32 = 10.; +pub(crate) const GRID_MIN_W: f32 = 260.; #[derive(Clone, Copy, PartialEq)] pub enum CardLayout { @@ -111,11 +114,7 @@ impl Component for PackageRow { CardLayout::Grid => 52., }; let icon_query = use_cached_image(item.icon_url.clone(), 256); - let icon = if item.is_remote() { - remote_icon(&item.icon_url, &icon_query, icon_size) - } else { - local_icon(icon_size) - }; + let icon = package_icon(&item, &icon_query, icon_size); let on_toggle: EventHandler<()> = { let hash = item.hash.clone(); @@ -166,7 +165,7 @@ impl Component for PackageRow { remove_hover, ) } - CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle), + CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle, true), } } } @@ -199,12 +198,13 @@ fn list_card( .into_element() } -fn grid_card( +pub(crate) fn grid_card( item: &PackageEntry, package_type: &'static str, cluster_id: i64, icon: impl IntoElement, on_toggle: EventHandler<()>, + navigable: bool, // if true, it takes the user to the mod page ) -> Element { let remote = item.is_remote(); let enabled = item.enabled; @@ -220,7 +220,7 @@ fn grid_card( (CARD_BG, colors::component_border(), 140) }; - let badge = if remote { + let badge = if remote && navigable { let provider = item.provider; let package_id = item.package_id.clone(); let package_type_owned = package_type.to_string(); @@ -237,6 +237,8 @@ fn grid_card( }) .child(provider_badge(item.provider)) .into_element() + } else if remote { + provider_badge(item.provider) } else { local_badge() }; @@ -402,6 +404,18 @@ fn package_info( .into_element() } +pub(crate) fn package_icon( + item: &PackageEntry, + icon_query: &freya::query::UseQuery, + size: f32, +) -> Element { + if item.is_remote() { + remote_icon(&item.icon_url, icon_query, size) + } else { + local_icon(size) + } +} + fn remote_icon( icon_url: &Option, icon_query: &freya::query::UseQuery, diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index aee585fc..35de417e 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -22,7 +22,8 @@ use tokio::sync::mpsc; use crate::components::IconType; use crate::launcher; use crate::notifications::{ - ClusterUpdateSummary, NotificationAction, NotificationSpec, PackageUpdateGroup, PendingPrompt, + ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup, + PackageUpdateGroup, PendingPrompt, }; use crate::state::{AppChannel, AppState, AsyncStatus}; @@ -541,6 +542,151 @@ impl Actions { self.with_engine(|state| state.notifications.close_cluster_update()); } + pub fn open_optional_mods( + &self, + groups: Vec, + done: Option>, + ) { + self.with_engine(move |state| state.notifications.open_optional_mods(groups, done)); + } + + pub fn close_optional_mods(&self) { + self.with_engine(|state| state.notifications.finish_optional_mods()); + } + + pub fn decline_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { + self.close_optional_mods(); + self.record_skipped_optional_mods(mods); + } + + pub fn record_skipped_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { + if mods.is_empty() { + return; + } + + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let content = state.services.content(); + + for (cluster_id, package_ids) in group_by_cluster(&mods) { + if let Err(err) = + oneclient_core::skip_optional_mods(cluster_id, &package_ids, &content).await + { + tracing::warn!( + cluster_id, + error = %err, + "failed to record skipped optional mods, they will come back as new" + ); + } + } + }); + } + + /// Holds the launch until the mods are actually on disk Minecraft reads + /// `mods/` once at startup, so finishing after the process starts is useless + pub fn enable_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { + if mods.is_empty() { + self.close_optional_mods(); + return; + } + + // Off screen straight away, but the launch keeps waiting until the + // install below is done + self.with_engine(|state| state.notifications.hide_optional_mods()); + let actions = self.clone(); + + spawn_forever(async move { + let Ok(state) = launcher::state() else { + actions.close_optional_mods(); + return; + }; + let content = state.services.content(); + + let mut clusters: Vec = Vec::new(); + let mut failed = 0usize; + for (cluster_id, (bundle_name, package_id)) in &mods { + match oneclient_core::set_bundle_package_enabled( + *cluster_id, + bundle_name, + package_id, + true, + false, + &content, + ) + .await + { + Ok(()) => { + if !clusters.contains(cluster_id) { + clusters.push(*cluster_id); + } + } + Err(err) => { + failed += 1; + tracing::warn!( + cluster_id, + %bundle_name, + %package_id, + error = %err, + "failed to opt in to an optional mod" + ); + } + } + } + + let mut installed = 0usize; + for cluster_id in &clusters { + match oneclient_core::apply_bundle_updates( + *cluster_id, + state.bundles.as_ref(), + &content, + ) + .await + { + Ok(result) => { + installed += result.additions_applied.len(); + failed += result.additions_failed.len(); + } + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to install optional mods"); + } + } + } + + for (cluster_id, package_ids) in group_by_cluster(&mods) { + if let Err(err) = + oneclient_core::resolve_optional_mods(cluster_id, &package_ids, &content).await + { + tracing::warn!(cluster_id, error = %err, "failed to clear answered offers"); + } + } + + super::invalidate_cluster_queries().await; + + let events = state.services.events.clone(); + if installed > 0 { + events + .notify("Mods added") + .body(format!( + "{installed} mod{} added", + if installed == 1 { "" } else { "s" } + )) + .send(); + } + if failed > 0 { + events + .notify("Some mods were not added") + .body(format!( + "{failed} mod{} could not be installed", + if failed == 1 { "" } else { "s" } + )) + .error() + .send(); + } + + actions.close_optional_mods(); + }); + } + pub fn close_package_updates(&self) { self.with_engine(|state| state.notifications.close_package_updates()); } @@ -909,6 +1055,8 @@ impl Actions { crate::install::cluster_update_notification(cluster_id, &result, &state.services) .await { + // The offers are queued by the sync itself and raised at launch + // this path only reports what changed actions.push_notification(spec); } }); @@ -1115,6 +1263,46 @@ impl Actions { } } + async fn resolve_optional_mods_before_launch( + &self, + state: &Arc, + cluster_id: ClusterId, + ) { + let pending = match oneclient_core::pending_optional_mods( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + { + Ok(pending) => pending, + Err(err) => { + tracing::warn!( + cluster_id, + error = %err, + "could not read queued optional mods, launching anyway" + ); + return; + } + }; + + // `pending_optional_mods` already drops everything the player turned + // down, so an empty list here means there is nothing left to ask + let Some(group) = + crate::install::pending_optional_group(cluster_id, &pending, &state.services).await + else { + return; + }; + + let (done, wait) = tokio::sync::oneshot::channel(); + self.with_engine(move |state| { + state.notifications.open_optional_mods(vec![group], Some(done)); + state.center_open = false; + }); + + let _ = wait.await; + } + async fn prompt_package_updates(&self, group: PackageUpdateGroup) { let (done, wait) = tokio::sync::oneshot::channel(); @@ -1123,7 +1311,6 @@ impl Actions { state.center_open = false; }); - // A replaced or torn-down modal is equally a reason to stop waiting let _ = wait.await; } @@ -1216,8 +1403,19 @@ impl Actions { } } -/// Failures are reported as game events so the caller only has to know the -/// attempt is over Everything before `launch_cluster` is pre-launch work +fn group_by_cluster(mods: &[(ClusterId, OptionalModRef)]) -> Vec<(ClusterId, Vec)> { + let mut grouped: Vec<(ClusterId, Vec)> = Vec::new(); + + for (cluster_id, (_bundle_name, package_id)) in mods { + match grouped.iter_mut().find(|(id, _)| id == cluster_id) { + Some((_, package_ids)) => package_ids.push(package_id.clone()), + None => grouped.push((*cluster_id, vec![package_id.clone()])), + } + } + + grouped +} + async fn launch(actions: &Actions, cluster_id: ClusterId) { let Ok(state) = launcher::state() else { return }; let events = state.services.events.clone(); @@ -1246,6 +1444,11 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) { .resolve_package_updates_before_launch(&state, cluster_id) .await; + // After the updates so the player never faces two modals at once + actions + .resolve_optional_mods_before_launch(&state, cluster_id) + .await; + if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await { // A missing file is the one failure the launcher can fix itself and a // path inside our metadata folder gives the user nothing to act on diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index 62961989..57d9c24c 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -8,7 +8,7 @@ use oneclient_events::Level; use crate::components::IconType; use crate::notifications::{ ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, - NotificationSpec, PackageUpdateGroup, + NotificationSpec, OptionalModsGroup, PackageUpdateGroup, }; /// Falls back to a placeholder a missing row must not lose the whole message @@ -48,15 +48,26 @@ pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdate provider: *provider, project_id: Some(project_id.clone()), fallback: file.display_name(), + offer: None, + status: None, }, oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem { provider: oneclient_content::packages::ProviderId::Local, project_id: None, fallback: file.display_name(), + offer: None, + status: None, }, } } +fn item_from_optional(package: &oneclient_core::BundleOptionalPackage) -> ClusterUpdateItem { + ClusterUpdateItem { + offer: Some((package.bundle_name.clone(), package.package_id.clone())), + ..item_from_bundle_file(&package.file) + } +} + async fn cluster_update_summary( cluster_id: i64, result: &oneclient_core::ApplyBundleUpdatesResult, @@ -84,10 +95,17 @@ async fn cluster_update_summary( .display_name .clone() .unwrap_or_else(|| r.package_id.clone()), + offer: None, + status: None, }) .collect(); + let optional: Vec = result + .optional_available + .iter() + .map(item_from_optional) + .collect(); - if updated.is_empty() && added.is_empty() && removed.is_empty() { + if updated.is_empty() && added.is_empty() && removed.is_empty() && optional.is_empty() { return None; } @@ -103,6 +121,7 @@ async fn cluster_update_summary( updated, added, removed, + optional, }) } @@ -113,14 +132,31 @@ pub async fn cluster_update_notification( ) -> Option { let summary = cluster_update_summary(cluster_id, result, services).await?; let total = summary.total(); - let body = format!( - "{total} package{} changed in {}", - if total == 1 { "" } else { "s" }, - summary.cluster_name - ); + // A bundle can offer an opt-in mod without changing anything else + // "0 packages changed" would be both wrong and alarming + let (title, body) = if total == 0 { + let offers = summary.optional.len(); + ( + "Optional mods available", + format!( + "{offers} optional mod{} in {}", + if offers == 1 { "" } else { "s" }, + summary.cluster_name + ), + ) + } else { + ( + "Cluster updated", + format!( + "{total} package{} changed in {}", + if total == 1 { "" } else { "s" }, + summary.cluster_name + ), + ) + }; Some(NotificationSpec { - title: "Cluster updated".to_string(), + title: title.to_string(), body, level: Level::Info, icon: Some(IconType::DownloadCloud02), @@ -152,14 +188,29 @@ pub async fn combined_cluster_update_spec( } let cluster_count = summaries.len(); - let body = format!( - "{total_changes} package{} updated across {cluster_count} cluster{}", - if total_changes == 1 { "" } else { "s" }, - if cluster_count == 1 { "" } else { "s" } - ); + let (title, body) = if total_changes == 0 { + let offers: usize = summaries.iter().map(|s| s.optional.len()).sum(); + ( + "Optional mods available", + format!( + "{offers} optional mod{} across {cluster_count} cluster{}", + if offers == 1 { "" } else { "s" }, + if cluster_count == 1 { "" } else { "s" } + ), + ) + } else { + ( + "Mods updated", + 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(), + title: title.to_string(), body, level: Level::Info, icon: Some(IconType::DownloadCloud02), @@ -171,14 +222,35 @@ pub async fn combined_cluster_update_spec( }) } -/// The session is detached not finished so the caller can reuse its notification -/// as the "Installed" / "Install failed" result -/// `session_id` is `None` if no download ran +pub async fn pending_optional_group( + cluster_id: i64, + pending: &[oneclient_core::PendingOptionalMod], + services: &oneclient_core::LauncherServices, +) -> Option { + if pending.is_empty() { + return None; + } + + let mods: Vec = pending + .iter() + .map(|entry| ClusterUpdateItem { + offer: Some((entry.bundle_name.clone(), entry.package_id.clone())), + status: Some(entry.status), + ..item_from_bundle_file(&entry.file) + }) + .collect(); + + Some(OptionalModsGroup { + cluster_id, + cluster_name: cluster_display_name(cluster_id, services).await, + mods, + }) +} + pub struct PackageInstall { pub session_id: Option, pub result: anyhow::Result, pub dependencies: Vec, - /// Unresolved or failed dependencies the package still installs pub missing_dependencies: Vec, } diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 7489dcaf..85753841 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, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, + AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, UpdatePromptOverlay, }; @@ -72,6 +72,7 @@ impl Component for RootLayout { // Must stay last it renders whatever the overlays above did not claim .child(GenericPromptOverlay) .child(ClusterUpdatePopup) + .child(OptionalModsPopup) .child(PackageUpdatePopup) .child(StatusBar) .child(SplashCurtain) diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 25f793cf..5bc1d3fe 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -6,7 +6,7 @@ use oneclient_events::{ }; use oneclient_content::packages::ProviderId; use oneclient_core::BrowserPackageUpdate; -use oneclient_db::models::ClusterId; +use oneclient_db::models::{ClusterId, OptionalModStatus}; use tokio::sync::oneshot; use uuid::Uuid; @@ -36,12 +36,16 @@ pub struct PackageUpdateGroup { pub packages: Vec, } +pub type OptionalModRef = (String, String); + #[derive(Clone, Debug, PartialEq)] pub struct ClusterUpdateItem { pub provider: ProviderId, pub project_id: Option, /// Used when the meta cache has no entry (file name / package id) pub fallback: String, + pub offer: Option, + pub status: Option, } impl ClusterUpdateItem { @@ -50,16 +54,45 @@ impl ClusterUpdateItem { provider: ProviderId::Local, project_id: None, fallback: name.into(), + offer: None, + status: None, } } } +#[derive(Clone, Debug, PartialEq)] +pub struct OptionalModsGroup { + pub cluster_id: ClusterId, + pub cluster_name: String, + pub mods: Vec, +} + +impl OptionalModsGroup { + pub fn from_summary(summary: &ClusterUpdateSummary) -> Option { + if summary.optional.is_empty() { + return None; + } + Some(Self { + cluster_id: summary.cluster_id, + cluster_name: summary.cluster_name.clone(), + mods: summary.optional.clone(), + }) + } + + pub fn offers(&self) -> impl Iterator + '_ { + self.mods + .iter() + .filter_map(move |item| item.offer.clone().map(|reference| (self.cluster_id, reference))) + } +} + #[derive(Clone, Debug, PartialEq)] pub struct ClusterUpdateSummary { pub cluster_id: ClusterId, pub cluster_name: String, pub updated: Vec, pub added: Vec, + pub optional: Vec, pub removed: Vec, } @@ -167,6 +200,7 @@ pub struct NotificationSnapshot { pub center_open: bool, pub pending_prompt: Option, pub cluster_update: Option>, + pub optional_mods: Option>, pub package_updates: Option>, pub active_toast_entry_ids: Vec, } @@ -186,11 +220,13 @@ pub struct NotificationState { grouped_tasks: HashMap, pending_timers: Vec, cluster_update: Option>, + optional_mods: Option>, package_updates: Option>, /// Resumes the launch that opened the update modal /// Held here not by the launch task /// because every way the modal can end goes through this state package_updates_done: Option>, + optional_mods_done: Option>, } const CATEGORY_ORDER: [TaskCategory; 7] = [ @@ -294,6 +330,7 @@ impl NotificationState { center_open, pending_prompt, cluster_update: self.cluster_update.clone(), + optional_mods: self.optional_mods.clone(), package_updates: self.package_updates.clone(), active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(), } @@ -315,8 +352,39 @@ impl NotificationState { self.cluster_update = None; } - /// `done` is the launch's continuation it fires immediately when there is nothing - /// to show so a launch never waits on a modal that was never drawn + pub fn open_optional_mods( + &mut self, + groups: Vec, + done: Option>, + ) { + let groups: Vec = groups + .into_iter() + .filter(|group| !group.mods.is_empty()) + .collect(); + + if groups.is_empty() { + if let Some(done) = done { + let _ = done.send(()); + } + return; + } + + self.finish_optional_mods(); + self.optional_mods = Some(groups); + self.optional_mods_done = done; + } + + pub fn hide_optional_mods(&mut self) { + self.optional_mods = None; + } + + pub fn finish_optional_mods(&mut self) { + self.optional_mods = None; + if let Some(done) = self.optional_mods_done.take() { + let _ = done.send(()); + } + } + pub fn open_package_updates( &mut self, groups: Vec, diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 22587ce6..e5ead9aa 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -6,7 +6,7 @@ use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId}; use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; use oneclient_db::models::OverrideType; -use crate::components::{CardLayout, PackageEntry}; +use crate::components::{CARD_GRID_H, CardLayout, GRID_GAP, GRID_MIN_W, PackageEntry}; use crate::hooks::{package_meta_batch, use_game_snapshot, use_package_meta_batch, use_view_state}; mod views; @@ -14,9 +14,6 @@ use views::{ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, tool const CARD_H: f32 = 84.; const CARD_SPACING: f32 = 8.; -const CARD_GRID_H: f32 = 148.; -const GRID_GAP: f32 = 10.; -const GRID_MIN_W: f32 = 260.; const LAZY_OVERSCAN: i64 = 2; pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 030d1063..5ebc7bfd 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -1,6 +1,7 @@ use freya::prelude::*; use freya::router::RouterContext; use oneclient_db::console::{ConsoleQueryResult, run_console_query}; +use oneclient_db::models::OptionalModStatus; use oneclient_auth::preview_samples; use oneclient_core::simulate::Damage; @@ -10,6 +11,7 @@ use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialo use crate::hooks::use_dispatch; use crate::notifications::{ ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, + OptionalModsGroup, }; use crate::routes::Route; use crate::theme::colors; @@ -209,6 +211,7 @@ impl Component for ClusterUpdateSimulator { 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 optional = use_state(|| "Lithium".to_string()); let simulate = dispatch.clone(); @@ -218,7 +221,7 @@ impl Component for ClusterUpdateSimulator { .spacing(10.) .child( label() - .text("Builds the same notification the bundle sync sends: one \"View changes\" action carrying every changed cluster, opened into the changes modal.") + .text("Builds the same notification the bundle sync sends: one \"View changes\" action carrying every changed cluster, opened into the changes modal. Anything listed under Optional also raises the \"Optional mods available\" prompt, exactly as the real sync does. Optional mods are not installed, so they are independent of Added rather than a subset of it.") .font_size(13.) .color(colors::fg_secondary()), ) @@ -257,6 +260,11 @@ impl Component for ClusterUpdateSimulator { rect().width(Size::flex(1.0)).child( TextInput::new(removed).placeholder("Removed (comma separated)"), ), + ) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(optional).placeholder("Optional (comma separated)"), + ), ), ) .child( @@ -271,6 +279,7 @@ impl Component for ClusterUpdateSimulator { updated: cluster_update_items(&split_csv(&updated.read())), added: cluster_update_items(&split_csv(&added.read())), removed: cluster_update_items(&split_csv(&removed.read())), + optional: cluster_update_items(&split_csv(&optional.read())) }; send_cluster_update(&simulate, vec![summary]); }), @@ -289,7 +298,7 @@ impl Component for ClusterUpdateSimulator { type ClusterUpdatePreset = fn() -> Vec; /// Each preset pins one variation plural copy empty category tab single- vs multi-cluster footer grouping header overflow -const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ +const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 7] = [ ( "1 cluster · all categories", IconType::DownloadCloud02, @@ -300,6 +309,7 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ &["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8", "Patcher 1.8 → 1.9"], &["Lithium", "FerriteCore"], &["OptiFine"], + &["Distant Horizons"], )] }, ), @@ -315,10 +325,11 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ ], &[], &[], + &[], )] }), ("1 cluster · single change", IconType::Plus, || { - vec![preset_summary(1, "PolyBlock", &[], &["Lithium"], &[])] + vec![preset_summary(1, "PolyBlock", &[], &["Lithium"], &[], &[])] }), ("3 clusters · mixed", IconType::DotsGrid, || { vec![ @@ -328,15 +339,16 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ &["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8"], &["Lithium"], &[], + &[], ), - preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"]), - preset_summary(3, "Vanilla+", &[], &["Sodium", "Iris", "FerriteCore"], &[]), + preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"], &[]), + preset_summary(3, "Vanilla+", &[], &["Sodium", "Iris", "FerriteCore"], &[], &[]), ] }), ("2 clusters · removals only", IconType::Trash01, || { vec![ - preset_summary(1, "PolyBlock", &[], &[], &["OptiFine"]), - preset_summary(2, "Skyblock", &[], &[], &["Skytils", "NotEnoughUpdates"]), + preset_summary(1, "PolyBlock", &[], &[], &["OptiFine"], &[]), + preset_summary(2, "Skyblock", &[], &[], &["Skytils", "NotEnoughUpdates"], &[]), ] }), ("6 clusters · long names", IconType::Database01, || { @@ -353,10 +365,31 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ ]), added: cluster_update_items(&[format!("Lithium {i}")]), removed: Vec::new(), + optional: Vec::new(), } }) .collect() }), + ("2 clusters · offers only", IconType::Plus, || { + vec![ + preset_summary( + 1, + "PolyBlock", + &[], + &[], + &[], + &["Lithium", "FerriteCore"], + ), + preset_summary( + 2, + "Skyblock", + &[], + &["Skytils"], + &[], + &["Skytils"], + ), + ] + }), ]; fn cluster_update_preset_rows(dispatch: &crate::Actions) -> Vec { @@ -388,6 +421,7 @@ fn preset_summary( updated: &[&str], added: &[&str], removed: &[&str], + optional: &[&str], ) -> ClusterUpdateSummary { ClusterUpdateSummary { cluster_id, @@ -395,17 +429,26 @@ fn preset_summary( updated: cluster_update_items(updated), added: cluster_update_items(added), removed: cluster_update_items(removed), + optional: cluster_update_items(optional) } } fn cluster_update_items(names: &[impl AsRef]) -> Vec { names .iter() - .map(|name| ClusterUpdateItem::from_name(name.as_ref())) + .enumerate() + .map(|(index, name)| ClusterUpdateItem { + offer: Some(("simulated-bundle".to_string(), name.as_ref().to_string())), + status: Some(if index % 2 == 0 { + OptionalModStatus::New + } else { + OptionalModStatus::Skipped + }), + ..ClusterUpdateItem::from_name(name.as_ref()) + }) .collect() } -/// Mirrors the copy the bridge builds so the simulated notification matches the real one fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec) { if summaries.is_empty() { return; @@ -427,6 +470,11 @@ fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec = summaries + .iter() + .filter_map(OptionalModsGroup::from_summary) + .collect(); + dispatch .notify(title) .body(body) @@ -436,6 +484,9 @@ fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec ContentResult> { + let rows: Vec<_> = optional_dao::list_pending(&ctx.db, cluster_id) + .await? + .into_iter() + .filter(|row| row.status() == OptionalModStatus::New) + .collect(); + if rows.is_empty() { + return Ok(Vec::new()); + } + + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + + let mut pending = Vec::new(); + for row in rows { + let Some((bundle_name, file)) = find_shipped(&archives, &row.bundle_name, &row.package_id) + else { + tracing::debug!( + cluster_id, + package_id = %row.package_id, + "queued offer is no longer shipped by any bundle" + ); + continue; + }; + + let status = row.status(); + pending.push(PendingOptionalMod { + cluster_id, + bundle_name, + package_id: row.package_id, + status, + file, + }); + } + + Ok(pending) +} + +fn find_shipped( + archives: &[BundleArchive], + preferred_bundle: &str, + package_id: &str, +) -> Option<(String, BundleFile)> { + let in_archive = |archive: &BundleArchive| { + archive + .manifest + .files + .iter() + .find(|file| file.kind.package_id() == package_id) + .map(|file| (archive.manifest.name.clone(), file.clone())) + }; + + archives + .iter() + .find(|archive| archive.manifest.name == preferred_bundle) + .and_then(in_archive) + .or_else(|| archives.iter().find_map(in_archive)) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn skip_optional_mods( + cluster_id: i64, + package_ids: &[String], + ctx: &ContentCtx, +) -> ContentResult<()> { + optional_dao::mark_skipped(&ctx.db, cluster_id, package_ids).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn resolve_optional_mods( + cluster_id: i64, + package_ids: &[String], + ctx: &ContentCtx, +) -> ContentResult<()> { + optional_dao::resolve(&ctx.db, cluster_id, package_ids).await?; + Ok(()) +} diff --git a/packages/oneclient_content/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs index 5c11a4f3..d535924c 100644 --- a/packages/oneclient_content/src/bundles/types.rs +++ b/packages/oneclient_content/src/bundles/types.rs @@ -130,6 +130,15 @@ pub struct BundleUpdateCheckResult { pub updates_available: Vec, pub removals_available: Vec, pub additions_available: Vec, + pub optional_available: Vec, +} + +#[derive(Debug, Clone)] +pub struct BundleOptionalPackage { + pub cluster_id: i64, + pub bundle_name: String, + pub package_id: String, + pub file: BundleFile, } #[derive(Debug, Clone, Default)] @@ -137,6 +146,7 @@ pub struct ApplyBundleUpdatesResult { pub updates_applied: Vec, pub removals_applied: Vec, pub additions_applied: Vec, + pub optional_available: Vec, pub updates_failed: Vec, pub removals_failed: Vec, pub additions_failed: Vec, diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 944fd547..02dd9961 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -5,8 +5,11 @@ use std::sync::{Arc, Mutex, OnceLock}; use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::dao::cluster as cluster_dao; use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::dao::cluster_optional_mod as optional_dao; use oneclient_db::models::ClusterPatch; -use oneclient_db::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; +use oneclient_db::models::{ + BundleTrackedArtifactRow, ClusterBundleOverrideRow, OptionalModStatus, OverrideType, +}; use tokio::sync::Mutex as AsyncMutex; use futures_util::StreamExt; @@ -19,9 +22,9 @@ use crate::bundles::install::{ use crate::bundles::manager::BundlesManager; use crate::bundles::overrides; use crate::bundles::types::{ - ApplyBundleUpdatesResult, BundleArchive, BundleFileKind, BundlePackageAddition, - BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus, - FileUpdateStatus, external_bundle_key, managed_bundle_key, + ApplyBundleUpdatesResult, BundleArchive, BundleFileKind, BundleOptionalPackage, + BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, + BundleWithUpdateStatus, FileUpdateStatus, external_bundle_key, managed_bundle_key, }; use oneclient_common::domain::{GameLoader, ProviderId}; use crate::packages::store::PackageStore; @@ -306,6 +309,7 @@ async fn check_bundle_updates_inner( .await?; let mut additions_available = Vec::new(); + let mut optional_available = Vec::new(); let mut planned_addition_keys = all_installed_managed_keys.clone(); for hash in all_installed_external_hashes { planned_addition_keys.insert(external_bundle_key(&hash)); @@ -321,9 +325,6 @@ async fn check_bundle_updates_inner( let user_override = overrides_map .get(&(archive.manifest.name.clone(), file_id.clone())) .copied(); - if !crate::bundles::effective_enabled(file, user_override) { - continue; - } let file_key = match &file.kind { BundleFileKind::Managed { @@ -337,6 +338,20 @@ async fn check_bundle_updates_inner( continue; } + if !crate::bundles::effective_enabled(file, user_override) { + if user_override.is_none() && !file.hidden { + optional_available.push(BundleOptionalPackage { + cluster_id, + bundle_name: archive.manifest.name.clone(), + package_id: file_id, + file: file.clone(), + }); + + planned_addition_keys.insert(file_key); + } + continue; + } + additions_available.push(BundlePackageAddition { cluster_id, bundle_name: archive.manifest.name.clone(), @@ -351,6 +366,7 @@ async fn check_bundle_updates_inner( updates_available, removals_available, additions_available, + optional_available, }) } @@ -515,6 +531,11 @@ pub async fn apply_bundle_updates_with( } } + let applied_something = + !result.updates_applied.is_empty() || !result.additions_applied.is_empty(); + result.optional_available = + settle_optional_offers(cluster_id, check.optional_available, applied_something, ctx).await; + { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); @@ -550,9 +571,79 @@ pub async fn apply_bundle_updates_with( Ok(result) } -/// `enabled` is set not flipped and overrides are read across all bundles -/// packages get re-resolved between bundles so a per-bundle lookup misses -/// objections filed while the file lived elsewhere +#[tracing::instrument(level = "debug", skip_all, fields(cluster_id, offers = offers.len()))] +async fn settle_optional_offers( + cluster_id: i64, + offers: Vec, + applied_something: bool, + ctx: &ContentCtx, +) -> Vec { + let queued = match optional_dao::list_pending(&ctx.db, cluster_id).await { + Ok(rows) => rows, + Err(err) => { + tracing::warn!( + cluster_id, + error = %err, + "could not read the optional mod queue, leaving it untouched" + ); + return offers; + } + }; + + let has_new_offer = offers + .iter() + .any(|offer| !queued.iter().any(|row| row.package_id == offer.package_id)); + + if applied_something || has_new_offer { + queue_optional_offers(cluster_id, &offers, ctx).await; + } + + let skipped: HashSet<&str> = queued + .iter() + .filter(|row| row.status() == OptionalModStatus::Skipped) + .map(|row| row.package_id.as_str()) + .collect(); + + offers + .into_iter() + .filter(|offer| !skipped.contains(offer.package_id.as_str())) + .collect() +} + +#[tracing::instrument(level = "debug", skip_all, fields(cluster_id, offers = offers.len()))] +async fn queue_optional_offers( + cluster_id: i64, + offers: &[BundleOptionalPackage], + ctx: &ContentCtx, +) { + for offer in offers { + if let Err(err) = optional_dao::queue( + &ctx.db, + cluster_id, + &offer.bundle_name, + &offer.package_id, + &offer.file.kind.bundle_version_id(), + ) + .await + { + tracing::warn!( + cluster_id, + package_id = %offer.package_id, + error = %err, + "failed to queue an optional mod offer" + ); + } + } + + let offered: Vec = offers + .iter() + .map(|offer| offer.package_id.clone()) + .collect(); + if let Err(err) = optional_dao::retain(&ctx.db, cluster_id, &offered).await { + tracing::warn!(cluster_id, error = %err, "failed to prune stale optional mod offers"); + } +} + #[tracing::instrument(level = "debug", skip_all, fields(cluster_id = update.cluster_id, bundle = %update.bundle_name, new_version = %update.new_version_id))] async fn reconcile_update( update: &BundlePackageUpdate, @@ -565,11 +656,11 @@ async fn reconcile_update( let enabled = !disable_was_deliberate(update.new_file.hidden, suppression); set_artifact_enabled_to(update.cluster_id, hash, enabled, ctx).await?; - if hash == update.installed_hash { - return Ok(()); + if hash != update.installed_hash { + remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, ctx).await?; } - remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, ctx).await + Ok(()) } #[tracing::instrument(level = "debug", skip_all, fields(cluster_id = addition.cluster_id, bundle = %addition.bundle_name))] @@ -582,7 +673,9 @@ async fn reconcile_addition( let file_id = addition.new_file.kind.package_id(); let suppression = find_user_suppression(overrides, &file_id); let enabled = !disable_was_deliberate(addition.new_file.hidden, suppression); - set_artifact_enabled_to(addition.cluster_id, hash, enabled, ctx).await + set_artifact_enabled_to(addition.cluster_id, hash, enabled, ctx).await?; + + Ok(()) } #[tracing::instrument(level = "debug", skip(bundles, ctx))] @@ -744,6 +837,7 @@ pub async fn apply_bundle_updates_for_all_clusters( if !result.updates_applied.is_empty() || !result.additions_applied.is_empty() || !result.removals_applied.is_empty() + || !result.optional_available.is_empty() { changed.push((cluster.id, result)); } diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 2cc161d2..9000b70e 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -25,10 +25,11 @@ pub use oneclient_content::bundles::{ apply_bundle_updates, check_bundle_updates, effective_enabled, install_bundle, install_cluster_bundles, install_package_from_bundle, is_bundle_syncing, list_cluster_bundle_overrides, + pending_optional_mods, resolve_optional_mods, skip_optional_mods, PendingOptionalMod, set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, set_bundle_package_overrides, ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, - BundleFileKind, BundleManifest, BundlesManager, BundleUpdateCheckResult, + BundleFileKind, BundleManifest, BundleOptionalPackage, BundlesManager, BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status, remove_artifact_from_cluster, toggle_artifact_enabled, }; diff --git a/packages/oneclient_db/migrations/20260817120000_optional_mod_queue.sql b/packages/oneclient_db/migrations/20260817120000_optional_mod_queue.sql new file mode 100644 index 00000000..506ddc3e --- /dev/null +++ b/packages/oneclient_db/migrations/20260817120000_optional_mod_queue.sql @@ -0,0 +1,13 @@ +CREATE TABLE cluster_optional_mods ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + cluster_id INTEGER NOT NULL, + bundle_name TEXT NOT NULL, + package_id TEXT NOT NULL, + bundle_version_id TEXT NOT NULL, + seen_status INTEGER NOT NULL, + queued_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE, + UNIQUE (cluster_id, package_id) +); + +CREATE INDEX cluster_optional_mods_cluster_id_idx ON cluster_optional_mods (cluster_id); diff --git a/packages/oneclient_db/src/dao/cluster_optional_mod.rs b/packages/oneclient_db/src/dao/cluster_optional_mod.rs new file mode 100644 index 00000000..6d9a011c --- /dev/null +++ b/packages/oneclient_db/src/dao/cluster_optional_mod.rs @@ -0,0 +1,120 @@ +use sqlx::SqlitePool; + +use crate::models::{ClusterOptionalModRow, OptionalModStatus}; + +pub async fn queue( + pool: &SqlitePool, + cluster_id: i64, + bundle_name: &str, + package_id: &str, + bundle_version_id: &str, +) -> Result<(), sqlx::Error> { + let new = OptionalModStatus::New.as_i64(); + + sqlx::query!( + r#" + INSERT INTO cluster_optional_mods + (cluster_id, bundle_name, package_id, bundle_version_id, seen_status) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(cluster_id, package_id) DO UPDATE SET + bundle_name = excluded.bundle_name, + bundle_version_id = excluded.bundle_version_id + "#, + cluster_id, + bundle_name, + package_id, + bundle_version_id, + new + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_pending( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterOptionalModRow, + r#" + SELECT id, cluster_id, bundle_name, package_id, bundle_version_id, + seen_status, queued_at + FROM cluster_optional_mods + WHERE cluster_id = ? + ORDER BY queued_at, id + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +// if a mod is marked as skipped, it will never show in a modal again (we mark it as seen by a user, so he doesn't have to see it again) +pub async fn mark_skipped( + pool: &SqlitePool, + cluster_id: i64, + package_ids: &[String], +) -> Result<(), sqlx::Error> { + if package_ids.is_empty() { + return Ok(()); + } + + let seen = OptionalModStatus::Skipped.as_i64(); + let mut tx = pool.begin().await?; + for package_id in package_ids { + sqlx::query!( + r#" + UPDATE cluster_optional_mods SET seen_status = ? + WHERE cluster_id = ? AND package_id = ? + "#, + seen, + cluster_id, + package_id + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +pub async fn resolve( + pool: &SqlitePool, + cluster_id: i64, + package_ids: &[String], +) -> Result<(), sqlx::Error> { + if package_ids.is_empty() { + return Ok(()); + } + + let mut tx = pool.begin().await?; + for package_id in package_ids { + sqlx::query!( + r#" + DELETE FROM cluster_optional_mods + WHERE cluster_id = ? AND package_id = ? + "#, + cluster_id, + package_id + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +pub async fn retain( + pool: &SqlitePool, + cluster_id: i64, + keep: &[String], +) -> Result<(), sqlx::Error> { + let stale: Vec = list_pending(pool, cluster_id) + .await? + .into_iter() + .filter(|row| row.status() != OptionalModStatus::Skipped) + .map(|row| row.package_id) + .filter(|package_id| !keep.contains(package_id)) + .collect(); + + resolve(pool, cluster_id, &stale).await +} diff --git a/packages/oneclient_db/src/dao/mod.rs b/packages/oneclient_db/src/dao/mod.rs index 63874833..16fd2281 100644 --- a/packages/oneclient_db/src/dao/mod.rs +++ b/packages/oneclient_db/src/dao/mod.rs @@ -4,6 +4,7 @@ pub mod browser_package_update; pub mod bundle; pub mod cluster; pub mod cluster_bundle; +pub mod cluster_optional_mod; pub mod game_session; pub mod java; pub mod package_metadata; diff --git a/packages/oneclient_db/src/models/cluster_optional_mod.rs b/packages/oneclient_db/src/models/cluster_optional_mod.rs new file mode 100644 index 00000000..be6a2e59 --- /dev/null +++ b/packages/oneclient_db/src/models/cluster_optional_mod.rs @@ -0,0 +1,39 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i64)] +pub enum OptionalModStatus { + New = 0, + Skipped = 1, +} + +impl OptionalModStatus { + pub fn as_i64(self) -> i64 { + self as i64 + } + + pub fn from_repr(value: i64) -> Option { + match value { + 0 => Some(Self::New), + 1 => Some(Self::Skipped), + _ => None, + } + } +} + +#[derive(Debug, Clone, FromRow)] +pub struct ClusterOptionalModRow { + pub id: i64, + pub cluster_id: i64, + pub bundle_name: String, + pub package_id: String, + pub bundle_version_id: String, + pub seen_status: i64, + pub queued_at: String, +} + +impl ClusterOptionalModRow { + pub fn status(&self) -> OptionalModStatus { + OptionalModStatus::from_repr(self.seen_status).unwrap_or(OptionalModStatus::New) + } +} diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs index f34ef93b..8cc11b4f 100644 --- a/packages/oneclient_db/src/models/mod.rs +++ b/packages/oneclient_db/src/models/mod.rs @@ -3,6 +3,7 @@ mod browser_package_update; mod bundle; mod cluster; mod cluster_bundle; +mod cluster_optional_mod; mod game_session; mod java; mod package_metadata; @@ -16,6 +17,7 @@ pub use cluster::{ClusterId, ClusterPatch, ClusterRow, NewCluster}; pub use cluster_bundle::{ BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType, }; +pub use cluster_optional_mod::{ClusterOptionalModRow, OptionalModStatus}; pub use game_session::{ GameSessionId, GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, SessionSpan, UnfinishedSession,