diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 4d708397..3187b3fa 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -51,7 +51,7 @@ pub(crate) use navbar::window_controls; pub use navbar::{AppNavbar, OnboardingNavbar}; pub use notifications::NotificationCenter; pub use overlay_popup::OverlayPopup; -pub use package_row::{CardLayout, PackageEntry, PackageRow}; +pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge}; pub use pagination::Pagination; pub use player_model::PlayerModel; pub use recents_row::RecentsRow; diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 7e6d1324..b139e59a 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -44,6 +44,7 @@ pub struct PackageEntry { pub enabled: bool, pub installed: bool, pub hash: Option, + pub manifest_default: bool, } impl PackageEntry { @@ -113,6 +114,7 @@ impl Component for PackageRow { let bundle_name = item.bundle_name.clone(); let package_id = item.package_id.clone(); let enabled_now = item.enabled; + let manifest_default = item.manifest_default; (move |()| { if let Some(h) = &hash { cluster.mutate(ClusterAction::ToggleArtifact { @@ -125,6 +127,7 @@ impl Component for PackageRow { bundle_name: bundle.clone(), package_id: package_id.clone(), enabled: !enabled_now, + manifest_default, }); } }) @@ -442,7 +445,7 @@ fn meta_size(size: u64) -> impl IntoElement { .into_element() } -fn provider_badge(provider: ProviderId) -> Element { +pub fn provider_badge(provider: ProviderId) -> Element { badge( Icon::new(provider).size(12.).into_element(), provider.to_string(), diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index e7eb3fc9..3694c0d8 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -70,7 +70,13 @@ pub fn use_browser_state_store() -> State> { #[derive(Clone)] pub struct OnboardingSelectionState { pub selected: State>, - pub seeded: State, + /// Set once the user changes the selection themselves. Until then the shell + /// keeps re-deriving `selected` from the catalog, so a bundle list that + /// settles late still gets the right defaults. + pub user_touched: State, + /// Bundle categories the user had in their v1 install, once the migration + /// step has been answered. `None` means "no migration decision yet". + pub migrated_categories: State>>, pub language: State, pub reduce_motion: State, pub predownload: State, diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index 54d59e08..3c539d2c 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; use oneclient_core::LauncherState; use oneclient_core::packages::{ContentType, PackageStore}; -use oneclient_db::models::ClusterId; +use oneclient_db::models::{ClusterId, OverrideType}; use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; use super::cluster_content::ClusterContentQuery; @@ -53,6 +53,10 @@ pub enum ClusterAction { bundle_name: String, package_id: String, enabled: bool, + /// The bundle manifest's `enabled` flag, so we know whether the user's + /// choice matches the default (clear the override) or contradicts it + /// (write `Enabled` / `Disabled`). + manifest_default: bool, }, ImportLocalFile { cluster_id: ClusterId, @@ -90,12 +94,20 @@ impl MutationCapability for ClusterMutation { bundle_name, package_id, enabled, + manifest_default, } => { - oneclient_core::set_bundle_package_enabled( + // Matching the manifest default means "no opinion" -> clear the + // override. Contradicting it pins the choice in either direction. + let override_type = match (*enabled, *manifest_default) { + (true, true) | (false, false) => None, + (true, false) => Some(OverrideType::Enabled), + (false, true) => Some(OverrideType::Disabled), + }; + oneclient_core::set_bundle_package_override( *cluster_id, bundle_name, package_id, - *enabled, + override_type, services, ) .await diff --git a/packages/oneclient_app/src/layout/onboarding_shell.rs b/packages/oneclient_app/src/layout/onboarding_shell.rs index 26dad383..0f6c489a 100644 --- a/packages/oneclient_app/src/layout/onboarding_shell.rs +++ b/packages/oneclient_app/src/layout/onboarding_shell.rs @@ -10,15 +10,18 @@ use crate::hooks::{ use_onboarding_bundles, use_provide_onboarding_selection, }; use crate::theme::colors; -use crate::view::onboarding::{LoadingBackdrop, onboarding_step_index, onboarding_total}; +use crate::view::onboarding::{ + LoadingBackdrop, default_selection, onboarding_step_index, onboarding_total, +}; #[derive(PartialEq)] pub struct OnboardingShell; impl Component for OnboardingShell { fn render(&self) -> impl IntoElement { - let selected = use_state(HashSet::new); - let seeded = use_state(|| false); + let mut selected = use_state(HashSet::new); + let user_touched = use_state(|| false); + let migrated_categories = use_state(|| None::>); let language = use_state(|| "English".to_string()); let reduce_motion = use_state(|| false); let predownload = use_state(|| true); @@ -27,7 +30,8 @@ impl Component for OnboardingShell { let import_dedicated = use_state(|| false); use_provide_onboarding_selection(OnboardingSelectionState { selected, - seeded, + user_touched, + migrated_categories, language, reduce_motion, predownload, @@ -43,6 +47,20 @@ impl Component for OnboardingShell { let step_index = onboarding_step_index(&route, detected_migration); let bundles = use_onboarding_bundles(); + + use_side_effect(move || { + if *user_touched.peek() { + return; + } + let Some(items) = onboarding_bundles_items(&bundles) else { + return; + }; + let categories = migrated_categories.read().clone(); + let defaults = default_selection(&items, categories.as_deref()); + if *selected.peek() != defaults { + selected.set(defaults); + } + }); let show_backdrop = matches!(&route, Route::OnboardingDownloading {}) && *setup_started.read(); let backdrop = show_backdrop.then(|| { diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index 09a4db72..ce354ce7 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -127,7 +127,7 @@ impl Route { Route::OnboardingLanguage { .. } => "Language".to_string(), Route::OnboardingAccount { .. } => "Account".to_string(), Route::OnboardingPreferences { .. } => "Accessibility".to_string(), - Route::OnboardingBundles { .. } => "Bundles".to_string(), + Route::OnboardingBundles { .. } => "Mods".to_string(), Route::OnboardingDownloading { .. } => "Finishing".to_string(), Route::Home { .. } => "Home".to_string(), Route::Clusters { .. } => "Versions".to_string(), 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 3665b0aa..1d901677 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 @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; use freya::prelude::*; use oneclient_core::packages::{CachedPackageMeta, ContentType, ProviderId}; use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; +use oneclient_db::models::OverrideType; use crate::components::{CardLayout, PackageEntry}; use crate::hooks::{package_meta_batch, use_package_meta_batch, use_view_state}; @@ -113,7 +114,7 @@ pub fn bundle_packages( .map(String::as_str); let enabled = match installed_info { Some(info) => info.enabled, - None => file.enabled && ov != Some("disabled") && ov != Some("removed"), + None => oneclient_core::effective_enabled(file, ov.and_then(OverrideType::parse)), }; let categories = if file.hidden || category.is_empty() { @@ -129,6 +130,7 @@ pub fn bundle_packages( file.size, categories, enabled, + file.enabled, installed_info, meta, file.display_name(), @@ -151,6 +153,7 @@ pub fn bundle_packages( 0, Vec::new(), info.enabled, + true, Some(info), meta, info.display_name @@ -170,6 +173,7 @@ fn make_row( size: u64, categories: Vec, enabled: bool, + manifest_default: bool, installed_info: Option<&LinkedArtifactInfo>, meta: &PackageMetaMap, fallback_name: String, @@ -208,6 +212,7 @@ fn make_row( size, categories, enabled, + manifest_default, installed: installed_info.is_some(), hash: installed_info.map(|i| i.hash.clone()), } diff --git a/packages/oneclient_app/src/view/onboarding/bundles/card.rs b/packages/oneclient_app/src/view/onboarding/bundles/card.rs deleted file mode 100644 index 4263ad22..00000000 --- a/packages/oneclient_app/src/view/onboarding/bundles/card.rs +++ /dev/null @@ -1,160 +0,0 @@ -use super::*; - -use oneclient_core::clusters::Cluster; - -use crate::components::{Button, DynamicArt, Icon, IconType}; -use crate::theme::colors; -use crate::ui::border_all_color; - -pub(super) struct BundleCard { - pub(super) display_name: String, - pub(super) art_cluster: Option, - pub(super) mod_count: usize, - pub(super) selected: bool, - pub(super) on_toggle: EventHandler<()>, - pub(super) on_open: EventHandler<()>, -} - -impl PartialEq for BundleCard { - fn eq(&self, other: &Self) -> bool { - self.display_name == other.display_name - && self.mod_count == other.mod_count - && self.selected == other.selected - && self.art_cluster.as_ref().map(|c| c.id) == other.art_cluster.as_ref().map(|c| c.id) - } -} - -impl Component for BundleCard { - fn render(&self) -> impl IntoElement { - let mut hovering = use_state(|| false); - - let selected = self.selected; - let hovered = *hovering.read(); - let on_toggle = self.on_toggle.clone(); - let on_open = self.on_open.clone(); - - let opacity = if selected { - 1.0 - } else if hovered { - 0.9 - } else { - 0.65 - }; - let border_color = if selected { - colors::brand() - } else if hovered { - colors::component_border_hover() - } else { - colors::component_border() - }; - - let art: Element = match &self.art_cluster { - Some(cluster) => DynamicArt::for_cluster(cluster) - .max_edge(512) - .into_element(), - None => rect() - .width(Size::fill()) - .height(Size::fill()) - .background(colors::component_bg()) - .into_element(), - }; - - let badge_text = if selected { - format!("{} Mods Selected", self.mod_count) - } else { - format!("{} Mods", self.mod_count) - }; - - rect() - .width(Size::fill()) - .height(Size::fill()) - .overflow(Overflow::Clip) - .corner_radius(CornerRadius::new_all(12.)) - .opacity(opacity) - .a11y_role(AccessibilityRole::Button) - .on_pointer_enter(move |_| { - *hovering.write() = true; - Cursor::set(CursorIcon::Pointer); - }) - .on_pointer_leave(move |_| { - *hovering.write() = false; - Cursor::set(CursorIcon::default()); - }) - .on_press(move |_| on_toggle.call(())) - .child( - rect() - .width(Size::fill()) - .height(Size::fill()) - .position(Position::new_absolute()) - .child(art), - ) - .child( - rect() - .vertical() - .width(Size::fill()) - .height(Size::fill()) - .padding(Gaps::new_symmetric(10., 12.)) - .main_align(Alignment::SpaceBetween) - .corner_radius(CornerRadius::new_all(12.)) - .border(border_all_color(2., border_color).alignment(BorderAlignment::Inner)) - .layer(Layer::Relative(3)) - .background( - LinearGradient::new() - .angle(0.) - .stop((Color::from_af32rgb(0.75, 0, 0, 0), 0.)) - .stop((Color::from_af32rgb(0.2, 0, 0, 0), 45.)) - .stop((Color::from_af32rgb(0.6, 0, 0, 0), 100.)), - ) - .child( - rect() - .horizontal() - .width(Size::fill()) - .main_align(Alignment::End) - .child( - rect() - .padding(Gaps::new_symmetric(3., 8.)) - .corner_radius(CornerRadius::new_all(999.)) - .background(CARD_NAME) - .child( - label() - .text(badge_text) - .font_size(11.) - .font_weight(FontWeight::MEDIUM) - .color(colors::brand()), - ), - ), - ) - .child( - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .content(Content::Flex) - .child( - label() - .text(self.display_name.clone()) - .width(Size::flex(1.0)) - .font_size(20.) - .font_weight(FontWeight::BOLD) - .max_lines(1) - .color(colors::fg_primary()), - ) - .child( - Button::new() - .ghost() - .icon() - .small() - .on_press(move |e: Event| { - e.stop_propagation(); - on_open.call(()); - }) - .child( - Icon::new(IconType::DotsVertical) - .size(18.) - .color(colors::fg_primary()), - ), - ), - ), - ) - } -} diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs index 78a1643f..5bf807b2 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs @@ -3,42 +3,33 @@ use std::collections::{HashMap, HashSet}; use freya::prelude::*; use freya::query::QueryStateData; use oneclient_core::clusters::Cluster; -use oneclient_core::packages::{ContentType, ProviderId}; +use oneclient_core::packages::ProviderId; use oneclient_core::{BundleArchive, BundleFile, BundleFileKind}; use crate::components::ScrollArea; use crate::hooks::{ - ClusterBundles, onboarding_bundles_items, use_onboarding_bundles, use_onboarding_selection, + ClusterBundles, onboarding_bundles_items, package_meta_batch, use_onboarding_bundles, + use_onboarding_selection, use_package_meta_batch, }; + +type MetaMap = HashMap; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; use crate::view::onboarding::{ - onboarding_nav, onboarding_slide, predownload_toggle_row, step_heading, + archive_selected, choice_row_sized, is_default_bundle, is_optional_file, onboarding_nav, + onboarding_slide, pkg_key, predownload_toggle_row, set_archive_selected, step_heading, + version_chip, }; -mod card; -mod popup; -use card::BundleCard; -use popup::{BundlePopup, empty_hint}; +mod row; +use row::{CARD_GRID_H, GRID_GAP, OnboardingModCard, empty_hint}; -const CARD_BG: Color = Color::from_rgb(26, 34, 41); -const CARD_NAME: Color = Color::from_rgb(213, 219, 255); -const CARD_HEIGHT: f32 = 150.; -const GRID_COLS: usize = 2; +const CHIPS_PER_ROW: usize = 4; -const SECTION_ORDER: [ContentType; 6] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, - ContentType::World, - ContentType::Modpack, -]; +const MOD_GRID_COLS: usize = 3; -fn pkg_key(cluster_id: i64, bundle_name: &str, package_id: &str) -> String { - format!("{cluster_id}|{bundle_name}|{package_id}") -} +const ANSWER_H: f32 = 48.; fn file_provider(file: &BundleFile) -> ProviderId { match &file.kind { @@ -47,17 +38,6 @@ fn file_provider(file: &BundleFile) -> ProviderId { } } -fn section_label(ct: ContentType) -> &'static str { - match ct { - ContentType::Mod => "Mods", - ContentType::ResourcePack => "Resource Packs", - ContentType::Shader => "Shaders", - ContentType::DataPack => "Data Packs", - ContentType::World => "Worlds", - ContentType::Modpack => "Modpacks", - } -} - fn bundle_display_name(archive: &BundleArchive) -> String { let category = archive.manifest.category.trim(); if category.is_empty() { @@ -68,104 +48,143 @@ fn bundle_display_name(archive: &BundleArchive) -> String { } #[derive(Clone)] -struct UnifiedMember { - cluster: Cluster, - archive: BundleArchive, -} - -#[derive(Clone)] -struct UnifiedBundle { +struct OptInBundle { display_name: String, - members: Vec, + members: Vec<(Cluster, BundleArchive)>, } -impl UnifiedBundle { +impl OptInBundle { + fn chosen(&self, selected: &HashSet) -> Vec { + self.members + .iter() + .filter(|(cluster, archive)| archive_selected(cluster.id, archive, selected)) + .map(|(cluster, _)| cluster.id) + .collect() + } + + fn wanted(&self, selected: &HashSet) -> bool { + !self.chosen(selected).is_empty() + } + fn mod_count(&self) -> usize { self.members .first() - .map(|m| { - m.archive - .manifest - .files - .iter() - .filter(|f| f.enabled && !f.hidden) - .count() - }) + .map(|(_, a)| a.manifest.files.iter().filter(|f| f.enabled && !f.hidden).count()) .unwrap_or(0) } } -fn unify_bundles(clusters: &[ClusterBundles]) -> Vec { +fn opt_in_bundles(clusters: &[ClusterBundles]) -> Vec { let mut order: Vec = Vec::new(); - let mut map: HashMap = HashMap::new(); + let mut map: HashMap = HashMap::new(); + for cb in clusters { for archive in &cb.archives { + if is_default_bundle(archive) { + continue; + } let name = bundle_display_name(archive); let entry = map.entry(name.clone()).or_insert_with(|| { order.push(name.clone()); - UnifiedBundle { + OptInBundle { display_name: name.clone(), members: Vec::new(), } }); - entry.members.push(UnifiedMember { - cluster: cb.cluster.clone(), - archive: archive.clone(), - }); + entry.members.push((cb.cluster.clone(), archive.clone())); } } + + for bundle in map.values_mut() { + bundle + .members + .sort_by(|(a, _), (b, _)| a.mc_version.cmp(&b.mc_version)); + } + order.into_iter().filter_map(|n| map.remove(&n)).collect() } -fn bundle_is_selected(bundle: &UnifiedBundle, selected: &HashSet) -> bool { - let Some(first) = bundle.members.first() else { - return false; - }; - let visible: Vec<&BundleFile> = first - .archive - .manifest - .files - .iter() - .filter(|f| f.enabled && !f.hidden) - .collect(); - !visible.is_empty() - && visible.iter().all(|f| { - selected.contains(&pkg_key( - first.cluster.id, - &first.archive.manifest.name, - &f.kind.package_id(), - )) - }) +#[derive(Clone)] +struct OptionalMod { + package_id: String, + provider: ProviderId, + fallback_name: String, + size: u64, + /// `(cluster_id, bundle_name)` pairs this mod is available from. + locations: Vec<(i64, String)>, } -fn toggle_bundle(mut selected: State>, bundle: &UnifiedBundle) { - let currently = bundle_is_selected(bundle, &selected.peek()); - let mut set = selected.peek().clone(); - for member in &bundle.members { - for file in &member.archive.manifest.files { - if !file.enabled { - continue; - } - let key = pkg_key( - member.cluster.id, - &member.archive.manifest.name, - &file.kind.package_id(), - ); - if currently { - set.remove(&key); - } else { - set.insert(key); +impl OptionalMod { + fn keys(&self) -> Vec { + self.locations + .iter() + .map(|(cluster_id, bundle)| pkg_key(*cluster_id, bundle, &self.package_id)) + .collect() + } + + fn selected(&self, selected: &HashSet) -> bool { + self.keys().iter().any(|k| selected.contains(k)) + } +} + +fn optional_mods(clusters: &[ClusterBundles]) -> Vec { + let mut order: Vec = Vec::new(); + let mut map: HashMap = HashMap::new(); + + for cb in clusters { + for archive in &cb.archives { + for file in archive.manifest.files.iter().filter(|f| is_optional_file(f)) { + let package_id = file.kind.package_id(); + let entry = map.entry(package_id.clone()).or_insert_with(|| { + order.push(package_id.clone()); + OptionalMod { + package_id: package_id.clone(), + provider: file_provider(file), + fallback_name: file.display_name(), + size: file.size, + locations: Vec::new(), + } + }); + entry + .locations + .push((cb.cluster.id, archive.manifest.name.clone())); } } } + + order.into_iter().filter_map(|n| map.remove(&n)).collect() +} + +fn set_bundle_versions( + mut selected: State>, + mut user_touched: State, + bundle: &OptInBundle, + clusters: &[i64], +) { + let mut set = selected.peek().clone(); + for (cluster, archive) in &bundle.members { + let on = clusters.contains(&cluster.id); + set_archive_selected(cluster.id, archive, on, &mut set); + } + user_touched.set(true); selected.set(set); } -fn flip(mut selected: State>, key: String) { +fn toggle_optional_mod( + mut selected: State>, + mut user_touched: State, + entry: &OptionalMod, +) { let mut set = selected.peek().clone(); - if !set.remove(&key) { - set.insert(key); + let on = entry.selected(&set); + for key in entry.keys() { + if on { + set.remove(&key); + } else { + set.insert(key); + } } + user_touched.set(true); selected.set(set); } @@ -176,37 +195,12 @@ impl Component for OnboardingBundles { fn render(&self) -> impl IntoElement { let bundles_query = use_onboarding_bundles(); let selection = use_onboarding_selection(); - let mut selected = selection.selected; - let mut seeded = selection.seeded; + let selected = selection.selected; + let user_touched = selection.user_touched; let predownload = selection.predownload; - let mut open = use_state(|| None::); - - use_side_effect(move || { - if *seeded.peek() { - return; - } - let Some(items) = onboarding_bundles_items(&bundles_query) else { - return; - }; - let mut defaults: HashSet = HashSet::new(); - for cb in &items { - for archive in &cb.archives { - for file in &archive.manifest.files { - if file.enabled && !file.hidden { - defaults.insert(pkg_key( - cb.cluster.id, - &archive.manifest.name, - &file.kind.package_id(), - )); - } - } - } - } - selected.set(defaults); - seeded.set(true); - }); let clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); + let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some(); let catalog_msg = { let reader = bundles_query.read(); match &*reader.state() { @@ -216,63 +210,40 @@ impl Component for OnboardingBundles { QueryStateData::Pending | QueryStateData::Loading { res: None } => { "Loading bundles...".to_string() } - _ => "No bundles available".to_string(), + _ => "No optional bundles available".to_string(), } }; - let bundles = unify_bundles(&clusters); let selected_set = selected.read().clone(); - let any_selected = !selected_set.is_empty(); + let opt_ins = opt_in_bundles(&clusters); + let extras = optional_mods(&clusters); - let mut rows: Vec = Vec::new(); - for chunk in bundles.chunks(GRID_COLS) { - let mut row = rect() - .horizontal() - .width(Size::fill()) - .height(Size::px(CARD_HEIGHT)) - .spacing(16.) - .content(Content::Flex); - for bundle in chunk { - let is_selected = bundle_is_selected(bundle, &selected_set); - let name = bundle.display_name.clone(); - let toggle_bundle_clone = bundle.clone(); - row = row.child( - rect() - .key(&bundle.display_name) - .width(Size::flex(1.0)) - .height(Size::fill()) - .child(BundleCard { - display_name: bundle.display_name.clone(), - art_cluster: bundle.members.first().map(|m| m.cluster.clone()), - mod_count: bundle.mod_count(), - selected: is_selected, - on_toggle: (move |()| toggle_bundle(selected, &toggle_bundle_clone)) - .into(), - on_open: (move |()| open.set(Some(name.clone()))).into(), - }), - ); - } - for _ in chunk.len()..GRID_COLS { - row = row.child(rect().width(Size::flex(1.0)).height(Size::fill())); - } - rows.push(row.into_element()); + // These are hooks, so they run every render regardless of whether there + // are any extras to show yet. + let (mr_ids, cf_ids) = collect_ids(&extras); + let mr_meta = package_meta_batch(&use_package_meta_batch(ProviderId::Modrinth, mr_ids)); + let cf_meta = package_meta_batch(&use_package_meta_batch(ProviderId::CurseForge, cf_ids)); + + let mut sections: Vec = Vec::new(); + + for bundle in &opt_ins { + sections.push( + opt_in_card(bundle, &selected_set, selected, user_touched).into_element(), + ); } - let grid_empty = bundles.is_empty(); - let popup = open.read().clone().and_then(|name| { - bundles - .iter() - .find(|b| b.display_name == name) - .cloned() - .map(|bundle| { - BundlePopup { - bundle, - selected, - open, - } - .into_element() - }) - }); + if !extras.is_empty() { + sections.push(extras_section( + &extras, + &selected_set, + selected, + user_touched, + &mr_meta, + &cf_meta, + )); + } + + let nothing_to_ask = opt_ins.is_empty() && extras.is_empty(); rect() .vertical() @@ -288,8 +259,8 @@ impl Component for OnboardingBundles { .padding(Gaps::new(40., 80., 12., 80.)) .spacing(16.) .child(step_heading( - "Bundles", - "Choose which bundles to install. Your selection is applied across every available version.", + "Mods", + "Everything you need is already included. Pick anything extra you want.", )) .child( rect() @@ -305,18 +276,225 @@ impl Component for OnboardingBundles { ScrollArea::new() .width(Size::fill()) .height(Size::flex(1.0)) - .spacing(16.) - .children(rows), + .spacing(20.) + .children(sections), ) - .maybe_child(grid_empty.then(|| empty_hint(&catalog_msg))), + .maybe_child(nothing_to_ask.then(|| empty_hint(&catalog_msg))), ) .child(predownload_toggle_row(predownload)), )) .child(onboarding_nav( Some(Route::OnboardingAccount {}), Route::OnboardingPreferences {}, - any_selected, + bundles_loaded, )) - .maybe_child(popup) } } + +fn opt_in_card( + bundle: &OptInBundle, + selected_set: &HashSet, + selected: State>, + user_touched: State, +) -> impl IntoElement { + let name = bundle.display_name.clone(); + let wanted = bundle.wanted(selected_set); + let chosen = bundle.chosen(selected_set); + let all_ids: Vec = bundle.members.iter().map(|(c, _)| c.id).collect(); + + let yes_bundle = bundle.clone(); + let yes_ids = all_ids.clone(); + let no_bundle = bundle.clone(); + + let mut card = rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text(format!("Do you want to install {name} mods?")) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(ANSWER_H)) + .child(choice_row_sized( + "Yes", + &format!("{} mods", yes_bundle.mod_count()), + wanted, + Size::fill(), + move |()| { + if wanted { + return; + } + set_bundle_versions( + selected, + user_touched, + &yes_bundle, + &yes_ids, + ) + }, + )), + ) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(ANSWER_H)) + .child(choice_row_sized( + "No", + "", + !wanted, + Size::fill(), + move |()| { + set_bundle_versions(selected, user_touched, &no_bundle, &[]) + }, + )), + ), + ); + + if wanted { + let mut rows: Vec = Vec::new(); + for chunk in bundle.members.chunks(CHIPS_PER_ROW) { + let mut row = rect().horizontal().spacing(8.); + for (cluster, _) in chunk { + let active = chosen.contains(&cluster.id); + let chip_bundle = bundle.clone(); + let chosen_now = chosen.clone(); + let cluster_id = cluster.id; + row = row.child(version_chip(&cluster.mc_version, active, move |()| { + let mut next = chosen_now.clone(); + if active { + next.retain(|id| *id != cluster_id); + } else { + next.push(cluster_id); + } + set_bundle_versions(selected, user_touched, &chip_bundle, &next); + })); + } + rows.push(row.into_element()); + } + + card = card.child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .padding(Gaps::new(4., 0., 0., 0.)) + .child( + label() + .text(format!("What versions do you play {name} on?")) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child(rect().vertical().spacing(8.).children(rows)), + ); + } + + card +} + +fn extras_section( + extras: &[OptionalMod], + selected_set: &HashSet, + selected: State>, + user_touched: State, + mr_meta: &MetaMap, + cf_meta: &MetaMap, +) -> Element { + let cards: Vec = extras + .iter() + .map(|entry| { + let meta = match entry.provider { + ProviderId::Modrinth => mr_meta.get(&entry.package_id), + ProviderId::CurseForge => cf_meta.get(&entry.package_id), + ProviderId::Local => None, + }; + let name = meta + .map(|m| m.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| entry.fallback_name.clone()); + let author = meta.map(|m| m.author.clone()).unwrap_or_default(); + let description = meta.map(|m| m.summary.clone()).unwrap_or_default(); + let icon_url = meta.and_then(|m| m.icon_url.clone()); + let toggle_entry = entry.clone(); + + OnboardingModCard { + provider: entry.provider, + name, + author, + description, + icon_url, + size: entry.size, + enabled: entry.selected(selected_set), + on_toggle: (move |()| toggle_optional_mod(selected, user_touched, &toggle_entry)) + .into(), + } + .into_element() + }) + .collect(); + + let mut rows: Vec = Vec::new(); + for (index, chunk) in cards.chunks(MOD_GRID_COLS).enumerate() { + let mut row = rect() + .key(index) + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_GRID_H)) + .spacing(GRID_GAP) + .content(Content::Flex); + for card in chunk { + row = row.child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .child(card.clone()), + ); + } + for _ in chunk.len()..MOD_GRID_COLS { + row = row.child(rect().width(Size::flex(1.0)).height(Size::fill())); + } + rows.push(row.into_element()); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Try out these additional mods:") + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(GRID_GAP) + .children(rows), + ) + .into_element() +} + +fn collect_ids(extras: &[OptionalMod]) -> (Vec, Vec) { + let mut mr = Vec::new(); + let mut cf = Vec::new(); + for entry in extras { + match entry.provider { + ProviderId::Modrinth => mr.push(entry.package_id.clone()), + ProviderId::CurseForge => cf.push(entry.package_id.clone()), + ProviderId::Local => {} + } + } + (mr, cf) +} diff --git a/packages/oneclient_app/src/view/onboarding/bundles/popup.rs b/packages/oneclient_app/src/view/onboarding/bundles/popup.rs deleted file mode 100644 index 05d888d2..00000000 --- a/packages/oneclient_app/src/view/onboarding/bundles/popup.rs +++ /dev/null @@ -1,378 +0,0 @@ -use super::*; - -use std::collections::HashSet; - -use freya::query::QueryStateData; -use oneclient_core::packages::{CachedPackageMeta, ProviderId}; -use oneclient_core::{BundleArchive, BundleFileKind}; - -use crate::components::{Button, Icon, OverlayPopup, ScrollArea, TabBar, TabItem}; -use crate::hooks::{package_meta_batch, use_cached_image, use_package_meta_batch}; -use crate::theme::colors; -use crate::ui::border_all_color; -use crate::utils::format_size; - -pub(super) struct BundlePopup { - pub(super) bundle: UnifiedBundle, - pub(super) selected: State>, - pub(super) open: State>, -} - -impl PartialEq for BundlePopup { - fn eq(&self, other: &Self) -> bool { - self.bundle.display_name == other.bundle.display_name - && self.bundle.members.len() == other.bundle.members.len() - } -} - -impl Component for BundlePopup { - fn render(&self) -> impl IntoElement { - let selected = self.selected; - let mut open = self.open; - let members = &self.bundle.members; - - let mut active = use_state(|| 0usize); - let active_idx = (*active.read()).min(members.len().saturating_sub(1)); - let member = members.get(active_idx); - - let cluster_id = member.map(|m| m.cluster.id).unwrap_or(0); - let bundle_name = member - .map(|m| m.archive.manifest.name.clone()) - .unwrap_or_default(); - - let (mr_ids, cf_ids) = member.map(|m| collect_ids(&m.archive)).unwrap_or_default(); - let mr_meta = package_meta_batch(&use_package_meta_batch(ProviderId::Modrinth, mr_ids)); - let cf_meta = package_meta_batch(&use_package_meta_batch(ProviderId::CurseForge, cf_ids)); - - let selected_set = selected.read().clone(); - - let tabs: Vec = members - .iter() - .enumerate() - .map(|(i, m)| { - TabItem::new(m.cluster.mc_version.clone(), i == active_idx) - .on_press(move |_| active.set(i)) - }) - .collect(); - - let mut sections: Vec = Vec::new(); - let mut selected_count = 0usize; - - if let Some(member) = member { - for ct in SECTION_ORDER { - let rows: Vec = member - .archive - .manifest - .files - .iter() - .filter(|f| !f.hidden && f.content_type() == ct) - .map(|file| { - let provider = file_provider(file); - let package_id = file.kind.package_id(); - let meta = match provider { - ProviderId::Modrinth => mr_meta.get(&package_id), - ProviderId::CurseForge => cf_meta.get(&package_id), - ProviderId::Local => None, - }; - let name = meta - .map(|m| m.name.clone()) - .filter(|n| !n.is_empty()) - .unwrap_or_else(|| file.display_name()); - let author = meta.map(|m| m.author.clone()).unwrap_or_default(); - let icon_url = meta.and_then(|m: &CachedPackageMeta| m.icon_url.clone()); - let key = pkg_key(cluster_id, &bundle_name, &package_id); - let enabled = selected_set.contains(&key); - if enabled { - selected_count += 1; - } - let toggle_key = key.clone(); - - rect() - .key(&package_id) - .width(Size::fill()) - .child(OnboardingPackageRow { - provider, - name, - author, - icon_url, - size: file.size, - enabled, - on_toggle: (move |()| flip(selected, toggle_key.clone())).into(), - }) - .into_element() - }) - .collect(); - if !rows.is_empty() { - sections.push(section(section_label(ct), rows).into_element()); - } - } - } - let sections_empty = sections.is_empty(); - - let title = self.bundle.display_name.clone(); - - OverlayPopup::new() - .on_close(move |()| open.set(None)) - .child( - rect() - .width(Size::window_percent(100.)) - .height(Size::window_percent(100.)) - .center() - .child( - rect() - .vertical() - .width(Size::px(640.)) - .max_width(Size::window_percent(92.)) - .height(Size::px(560.)) - .max_height(Size::window_percent(88.)) - .spacing(14.) - .padding(Gaps::new_all(20.)) - .corner_radius(CornerRadius::new_all(16.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .content(Content::Flex) - .child( - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(6.) - .child( - label() - .text("Content for ") - .font_size(18.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child( - label() - .text(title) - .font_size(18.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::brand()), - ), - ) - .maybe_child((members.len() > 1).then(|| { - TabBar::new() - .tabs(tabs) - .width(Size::fill()) - .height(Size::px(30.)) - .spacing(20.) - .font_size(13.) - .into_element() - })) - .child( - rect() - .vertical() - .width(Size::fill()) - .height(Size::flex(1.0)) - .content(Content::Flex) - .child( - ScrollArea::new() - .width(Size::fill()) - .height(Size::flex(1.0)) - .spacing(16.) - .children(sections), - ) - .maybe_child( - sections_empty - .then(|| empty_hint("No content in this bundle.")), - ), - ) - .child( - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(16.) - .content(Content::Flex) - .child( - label() - .text(format!("Selected {selected_count}")) - .width(Size::flex(1.0)) - .font_size(13.) - .color(colors::fg_secondary()), - ) - .child( - Button::new() - .primary() - .on_press(move |_| open.set(None)) - .text("Confirm"), - ), - ), - ), - ) - } -} - -fn collect_ids(archive: &BundleArchive) -> (Vec, Vec) { - let mut mr = Vec::new(); - let mut cf = Vec::new(); - for file in &archive.manifest.files { - if file.hidden { - continue; - } - if let BundleFileKind::Managed { - provider, - project_id, - .. - } = &file.kind - { - match provider { - ProviderId::Modrinth => mr.push(project_id.clone()), - ProviderId::CurseForge => cf.push(project_id.clone()), - ProviderId::Local => {} - } - } - } - (mr, cf) -} - -fn section(title: &str, rows: Vec) -> impl IntoElement { - rect() - .vertical() - .width(Size::fill()) - .spacing(8.) - .child( - label() - .text(title.to_string()) - .font_size(13.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_secondary()), - ) - .child( - rect() - .vertical() - .width(Size::fill()) - .spacing(8.) - .children(rows), - ) - .into_element() -} - -#[derive(PartialEq)] -struct OnboardingPackageRow { - provider: ProviderId, - name: String, - author: String, - icon_url: Option, - size: u64, - enabled: bool, - on_toggle: EventHandler<()>, -} - -impl Component for OnboardingPackageRow { - fn render(&self) -> impl IntoElement { - let icon_query = use_cached_image(self.icon_url.clone(), 128); - let loaded = { - let reader = icon_query.read(); - match (&self.icon_url, &*reader.state()) { - (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) - | ( - Some(url), - QueryStateData::Loading { - res: Some(Ok(bytes)), - }, - ) => Some((url.clone(), bytes.clone())), - _ => None, - } - }; - - let icon = match loaded { - Some((url, bytes)) => ImageViewer::new((url, bytes)) - .width(Size::px(44.)) - .height(Size::px(44.)) - .aspect_ratio(AspectRatio::Min) - .corner_radius(CornerRadius::new_all(8.)) - .into_element(), - - None => icon_box(self.provider).into_element(), - }; - - let (bg, border) = if self.enabled { - (colors::brand().with_a(38), colors::brand()) - } else { - (CARD_BG, colors::component_border()) - }; - let alpha = if self.enabled { 255 } else { 150 }; - - let subtitle = if self.author.is_empty() { - self.provider.to_string() - } else { - format!("by {}", self.author) - }; - - let on_toggle = self.on_toggle.clone(); - let size = self.size; - - rect() - .horizontal() - .width(Size::fill()) - .height(Size::px(64.)) - .cross_align(Alignment::Center) - .spacing(12.) - .padding(Gaps::new_all(10.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(bg) - .border(border_all_color(1.5, border)) - .content(Content::Flex) - .a11y_role(AccessibilityRole::Button) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| on_toggle.call(())) - .child(icon) - .child( - rect() - .vertical() - .width(Size::flex(1.0)) - .spacing(3.) - .child( - label() - .text(self.name.clone()) - .font_size(15.) - .font_weight(FontWeight::MEDIUM) - .max_lines(1) - .color(CARD_NAME.with_a(alpha)), - ) - .child( - label() - .text(subtitle) - .font_size(10.) - .max_lines(1) - .color(colors::fg_secondary().with_a(alpha)), - ), - ) - .maybe_child((size > 0).then(|| { - label() - .text(format_size(size)) - .font_size(11.) - .color(colors::fg_secondary()) - .into_element() - })) - } -} - -fn icon_box(provider: ProviderId) -> impl IntoElement { - rect() - .center() - .width(Size::px(44.)) - .height(Size::px(44.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(colors::component_bg()) - .child(Icon::new(provider).size(20.).color(colors::fg_secondary())) - .into_element() -} - -pub(super) fn empty_hint(text: &str) -> impl IntoElement { - rect() - .width(Size::fill()) - .padding(Gaps::new_all(24.)) - .center() - .child( - label() - .text(text.to_string()) - .font_size(13.) - .color(colors::fg_secondary()), - ) - .into_element() -} diff --git a/packages/oneclient_app/src/view/onboarding/bundles/row.rs b/packages/oneclient_app/src/view/onboarding/bundles/row.rs new file mode 100644 index 00000000..ceb51314 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/bundles/row.rs @@ -0,0 +1,171 @@ +use freya::prelude::*; +use freya::query::QueryStateData; +use oneclient_core::packages::ProviderId; + +use crate::components::{Icon, provider_badge}; +use crate::hooks::use_cached_image; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_size; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const CARD_NAME: Color = Color::from_rgb(213, 219, 255); + +pub(super) const CARD_GRID_H: f32 = 148.; +pub(super) const GRID_GAP: f32 = 10.; + +#[derive(PartialEq)] +pub(super) struct OnboardingModCard { + pub(super) provider: ProviderId, + pub(super) name: String, + pub(super) author: String, + pub(super) description: String, + pub(super) icon_url: Option, + pub(super) size: u64, + pub(super) enabled: bool, + pub(super) on_toggle: EventHandler<()>, +} + +impl Component for OnboardingModCard { + fn render(&self) -> impl IntoElement { + let icon_query = use_cached_image(self.icon_url.clone(), 128); + let loaded = { + let reader = icon_query.read(); + match (&self.icon_url, &*reader.state()) { + (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) + | ( + Some(url), + QueryStateData::Loading { + res: Some(Ok(bytes)), + }, + ) => Some((url.clone(), bytes.clone())), + _ => None, + } + }; + + let icon = match loaded { + Some((url, bytes)) => ImageViewer::new((url, bytes)) + .width(Size::px(44.)) + .height(Size::px(44.)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(8.)) + .into_element(), + + None => icon_box(self.provider).into_element(), + }; + + let (bg, border, alpha) = if self.enabled { + (colors::brand().with_a(38), colors::brand(), 255) + } else { + (CARD_BG, colors::component_border(), 140) + }; + + let on_toggle = self.on_toggle.clone(); + let size = self.size; + + let header = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .content(Content::Flex) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(self.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(CARD_NAME.with_a(alpha)), + ) + .maybe(!self.author.is_empty(), |el| { + el.child( + label() + .text(format!("by {}", self.author)) + .font_size(10.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary().with_a(alpha)), + ) + }) + .child(provider_badge(self.provider)), + ); + + let description = (!self.description.is_empty()).then(|| { + label() + .text(self.description.clone()) + .font_size(11.) + .max_lines(3) + .width(Size::fill()) + .color(colors::fg_secondary().with_a(alpha)) + .into_element() + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(8.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(bg) + .border(border_all_color(1.5, border)) + .content(Content::Flex) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_press(move |_| on_toggle.call(())) + .child(header) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .maybe_child(description), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .maybe_child((size > 0).then(|| { + label() + .text(format_size(size)) + .font_size(11.) + .color(colors::fg_secondary().with_a(alpha)) + .into_element() + })), + ) + } +} + +fn icon_box(provider: ProviderId) -> impl IntoElement { + rect() + .center() + .width(Size::px(44.)) + .height(Size::px(44.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .child(Icon::new(provider).size(20.).color(colors::fg_secondary())) + .into_element() +} + +pub(super) fn empty_hint(text: &str) -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_all(24.)) + .center() + .child( + label() + .text(text.to_string()) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index aa2414e5..10dbc8fe 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -4,10 +4,11 @@ use std::time::{Duration, Instant}; use freya::animation::*; use freya::prelude::*; use freya::router::RouterContext; -use oneclient_core::ImportTarget; +use oneclient_core::{BundleArchive, BundleFile, ImportTarget}; use oneclient_core::notification::{ GroupedProgressEvent, GroupedProgressSession, Notification, NotificationService, }; +use oneclient_db::models::OverrideType; use tokio::sync::mpsc; use uuid::Uuid; @@ -18,7 +19,7 @@ use crate::hooks::{ }; use crate::routes::Route; use crate::theme::colors; -use crate::view::onboarding::matching_new_cluster_id; +use crate::view::onboarding::{matching_new_cluster_id, pkg_key}; mod backdrop; mod progress; @@ -36,7 +37,9 @@ const MAX_TASK_ROWS: usize = 6; struct ClusterPlan { cluster_id: i64, mc_version: String, - overrides: Vec<(String, String)>, + /// `(bundle_name, package_id, override)` for every file whose fate differs + /// from the bundle manifest's default. + overrides: Vec<(String, String, OverrideType)>, } #[derive(Clone, PartialEq)] @@ -448,16 +451,7 @@ fn build_plans( .map(|cb| { let mut overrides = Vec::new(); for archive in &cb.archives { - for file in &archive.manifest.files { - if file.hidden { - continue; - } - let package_id = file.kind.package_id(); - let key = format!("{}|{}|{}", cb.cluster.id, archive.manifest.name, package_id); - if file.enabled && !selected.contains(&key) { - overrides.push((archive.manifest.name.clone(), package_id)); - } - } + overrides.extend(archive_overrides(cb.cluster.id, archive, selected)); } ClusterPlan { cluster_id: cb.cluster.id, @@ -468,6 +462,44 @@ fn build_plans( .collect() } +fn archive_overrides( + cluster_id: i64, + archive: &BundleArchive, + selected: &std::collections::HashSet, +) -> Vec<(String, String, OverrideType)> { + let bundle_name = &archive.manifest.name; + let wants = |file: &BundleFile| { + selected.contains(&pkg_key(cluster_id, bundle_name, &file.kind.package_id())) + }; + + // Hidden files are dependencies the user never sees, so they follow the + // bundle: kept if anything from it was taken (including a single opted-in + // extra, which would otherwise lose its dependencies), dropped if not. + let bundle_taken = archive + .manifest + .files + .iter() + .filter(|f| !f.hidden) + .any(wants); + + let mut overrides = Vec::new(); + for file in &archive.manifest.files { + let wanted = if file.hidden { bundle_taken } else { wants(file) }; + + let override_type = match (wanted, file.enabled) { + // Matches the manifest default; nothing to record. + (true, true) | (false, false) => continue, + // Opting into a mod the bundle ships turned off. + (true, false) => OverrideType::Enabled, + // Declined: don't fetch it at all. + (false, true) => OverrideType::Removed, + }; + + overrides.push((bundle_name.clone(), file.kind.package_id(), override_type)); + } + overrides +} + const GAME_SIZE_GUESS: u64 = 180_000_000; const JRE_SIZE_GUESS: u64 = 45_000_000; @@ -478,17 +510,29 @@ fn rough_download_estimate( let mut total: u64 = 0; for cb in items { for archive in &cb.archives { + let dropped: std::collections::HashSet = + archive_overrides(cb.cluster.id, archive, selected) + .into_iter() + .filter(|(_, _, ty)| *ty == OverrideType::Removed) + .map(|(_, pid, _)| pid) + .collect(); + let opted_in: std::collections::HashSet = + archive_overrides(cb.cluster.id, archive, selected) + .into_iter() + .filter(|(_, _, ty)| *ty == OverrideType::Enabled) + .map(|(_, pid, _)| pid) + .collect(); + for file in &archive.manifest.files { - if !file.enabled { - continue; - } - let key = format!( - "{}|{}|{}", - cb.cluster.id, - archive.manifest.name, - file.kind.package_id() - ); - if file.hidden || selected.contains(&key) { + let pid = file.kind.package_id(); + let installing = if opted_in.contains(&pid) { + true + } else if dropped.contains(&pid) { + false + } else { + file.enabled + }; + if installing { total += file.size; } } @@ -658,17 +702,13 @@ async fn install_one( "Saving your package choices...".to_string(), )); } - for (bundle_name, package_id) in &plan.overrides { - oneclient_core::set_bundle_package_enabled( - plan.cluster_id, - bundle_name, - package_id, - false, - &state.services, - ) - .await - .map_err(|err| err.to_string())?; - } + oneclient_core::set_bundle_package_overrides( + plan.cluster_id, + &plan.overrides, + &state.services, + ) + .await + .map_err(|err| err.to_string())?; if !predownload { return Ok(()); @@ -801,3 +841,152 @@ fn finish_onboarding( let _ = RouterContext::get().replace(Route::Home {}); }); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::{archive, cluster, file}; + use std::collections::HashSet; + + fn skyblock() -> BundleArchive { + archive( + "SkyBlock", + false, + vec![ + file("skyblock-main", true, false), + file("skycubed", false, false), + file("sb-dep", true, true), + ], + ) + } + + fn items(archives: Vec) -> Vec { + vec![ClusterBundles { + cluster: cluster(1), + archives, + }] + } + + fn keys(archive: &BundleArchive, pids: &[&str]) -> HashSet { + pids.iter() + .map(|p| pkg_key(1, &archive.manifest.name, p)) + .collect() + } + + #[test] + fn declining_a_bundle_removes_its_visible_and_hidden_files() { + let sb = skyblock(); + let plans = build_plans(&items(vec![sb.clone()]), &HashSet::new()); + + let ov = &plans[0].overrides; + // The hidden dependency must be dropped too, or it installs anyway. + assert!(ov.contains(&( + sb.manifest.name.clone(), + "sb-dep".to_string(), + OverrideType::Removed + ))); + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skyblock-main".to_string(), + OverrideType::Removed + ))); + assert!(!ov.iter().any(|(_, pid, _)| pid == "skycubed")); + } + + #[test] + fn accepting_a_bundle_records_nothing_for_its_defaults() { + let sb = skyblock(); + let selected = keys(&sb, &["skyblock-main"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected); + + assert!(plans[0].overrides.is_empty()); + } + + #[test] + fn opting_into_an_extra_writes_enabled() { + let sb = skyblock(); + let selected = keys(&sb, &["skyblock-main", "skycubed"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected); + + assert_eq!( + plans[0].overrides, + vec![( + sb.manifest.name.clone(), + "skycubed".to_string(), + OverrideType::Enabled + )] + ); + } + + #[test] + fn an_extra_alone_still_keeps_hidden_dependencies() { + let sb = skyblock(); + let selected = keys(&sb, &["skycubed"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected); + let ov = &plans[0].overrides; + + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skycubed".to_string(), + OverrideType::Enabled + ))); + assert!(!ov.iter().any(|(_, pid, _)| pid == "sb-dep")); + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skyblock-main".to_string(), + OverrideType::Removed + ))); + } + + #[test] + fn rejections_are_removed_never_disabled() { + let plans = build_plans(&items(vec![skyblock()]), &HashSet::new()); + + assert!( + !plans[0] + .overrides + .iter() + .any(|(_, _, ty)| *ty == OverrideType::Disabled) + ); + } + + #[test] + fn per_cluster_choices_are_independent() { + let sb = skyblock(); + let both = vec![ + ClusterBundles { + cluster: cluster(1), + archives: vec![sb.clone()], + }, + ClusterBundles { + cluster: cluster(2), + archives: vec![sb.clone()], + }, + ]; + let selected: HashSet = + [pkg_key(1, &sb.manifest.name, "skyblock-main")].into(); + let plans = build_plans(&both, &selected); + + assert!(plans[0].overrides.is_empty()); + assert!(plans[1].overrides.iter().any(|(_, pid, ty)| pid + == "skyblock-main" + && *ty == OverrideType::Removed)); + } + + #[test] + fn estimate_skips_declined_bundles_and_counts_opted_in_extras() { + let sb = skyblock(); + let all = items(vec![sb.clone()]); + let baseline = GAME_SIZE_GUESS + JRE_SIZE_GUESS; + + let declined = rough_download_estimate(&all, &HashSet::new()); + assert_eq!(declined, baseline, "declined bundle should cost nothing"); + + let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"])); + assert_eq!(accepted, baseline + 2); + + let with_extra = + rough_download_estimate(&all, &keys(&sb, &["skyblock-main", "skycubed"])); + assert_eq!(with_extra, baseline + 3); + } +} diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs index f457a13d..c5887188 100644 --- a/packages/oneclient_app/src/view/onboarding/migration.rs +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use freya::prelude::*; use freya::router::RouterContext; use oneclient_core::SourceInstance; @@ -12,51 +10,18 @@ use crate::hooks::{ use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; -use crate::view::onboarding::{onboarding_illustration, onboarding_slide, step_heading}; +use crate::view::onboarding::{choice_row, onboarding_illustration, onboarding_slide, step_heading}; -/// Bundle-selection key, identical to `bundles::pkg_key` and the format the -/// downloading step reads. Must stay in sync with those. -fn pkg_key(cluster_id: i64, bundle_name: &str, package_id: &str) -> String { - format!("{cluster_id}|{bundle_name}|{package_id}") -} - -/// Build the pre-selected bundle set from the source install: for each new -/// cluster, match a source instance by version+loader, then select every -/// enabled/visible file of bundles whose category the user had installed. -fn build_migrated_selection(source: &[SourceInstance], new: &[ClusterBundles]) -> HashSet { - let mut selected = HashSet::new(); - - for cb in new { - let Some(instance) = source - .iter() - .find(|o| o.mc_version == cb.cluster.mc_version && o.mc_loader == cb.cluster.mc_loader) - else { - continue; - }; - - for archive in &cb.archives { - let category = archive.manifest.category.trim(); - let wanted = instance - .categories - .iter() - .any(|c| c.eq_ignore_ascii_case(category)); - if !wanted { - continue; - } - - for file in &archive.manifest.files { - if file.enabled && !file.hidden { - selected.insert(pkg_key( - cb.cluster.id, - &archive.manifest.name, - &file.kind.package_id(), - )); - } +fn migrated_categories(source: &[SourceInstance]) -> Vec { + let mut categories: Vec = Vec::new(); + for instance in source { + for category in &instance.categories { + if !categories.iter().any(|c| c.eq_ignore_ascii_case(category)) { + categories.push(category.clone()); } } } - - selected + categories } /// Whether a new cluster exists matching this source instance's version+loader @@ -81,8 +46,7 @@ impl Component for OnboardingMigration { let migration_query = use_migration(); let bundles_query = use_onboarding_bundles(); let selection = use_onboarding_selection(); - let mut selected = selection.selected; - let mut seeded = selection.seeded; + let mut categories = selection.migrated_categories; // Shared across the flow; the file import is dispatched later, on Setup. let mut import_folder = selection.import_folder; let import_dedicated = selection.import_dedicated; @@ -201,14 +165,7 @@ impl Component for OnboardingMigration { ), )) .child(migration_nav(bundles_loaded, move || { - // Apply migrated category selection and mark seeded so the - // Bundles page does not overwrite it with "everything on". The - // file import itself is deferred to the Setup step, like every - // other onboarding action; the chosen folder/mode lives in the - // shared selection state. - let migrated = build_migrated_selection(&detection.instances, &new_clusters); - selected.set(migrated); - seeded.set(true); + categories.set(Some(migrated_categories(&detection.instances))); let _ = RouterContext::get().replace(Route::OnboardingLanguage {}); })) @@ -316,7 +273,7 @@ fn version_card( .font_size(12.) .color(colors::fg_primary()), ) - .child(target_option( + .child(choice_row( "Shared game directory", "Available to every version.", !dedicated, @@ -324,7 +281,7 @@ fn version_card( )); if dedicated_available { - targets = targets.child(target_option( + targets = targets.child(choice_row( "This version only", "Keep the files isolated to the matching version.", dedicated, @@ -398,56 +355,6 @@ fn import_choice_card( .into_element() } -/// A shared/dedicated radio row nested inside a selected version card. -fn target_option( - title: &str, - subtitle: &str, - active: bool, - on_press: impl FnMut(()) + 'static, -) -> Element { - let mut on_press = on_press; - let border_color = if active { - colors::fg_primary() - } else { - colors::component_border() - }; - - rect() - .horizontal() - .width(Size::fill()) - .content(Content::Flex) - .cross_align(Alignment::Center) - .spacing(10.) - .padding(Gaps::new_symmetric(8., 12.)) - .corner_radius(CornerRadius::new_all(8.)) - .border(border_all_color(1., border_color)) - .a11y_role(AccessibilityRole::Button) - .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) - .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) - .on_press(move |_| on_press(())) - .child( - rect() - .vertical() - .width(Size::flex(1.0)) - .spacing(2.) - .child( - label() - .text(title.to_string()) - .font_size(13.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_primary()), - ) - .child( - label() - .text(subtitle.to_string()) - .font_size(11.) - .color(colors::fg_primary()), - ), - ) - .maybe_child(active.then(|| Icon::new(IconType::Check).size(15.))) - .into_element() -} - /// Custom nav: like `onboarding_nav` but the Next button runs `on_next` (which /// applies the migration + navigates) instead of a plain route replace. fn migration_nav(next_enabled: bool, on_next: impl FnMut() + 'static) -> impl IntoElement { diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs index b7403f1b..5f5a0458 100644 --- a/packages/oneclient_app/src/view/onboarding/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/mod.rs @@ -4,9 +4,17 @@ mod downloading; mod language; mod migration; mod preferences; +mod selection; mod terms; +#[cfg(test)] +pub(crate) mod test_support; mod welcome; +pub(crate) use selection::{ + archive_selected, default_selection, is_default_bundle, is_optional_file, pkg_key, + set_archive_selected, +}; + pub use account::OnboardingAccount; pub use bundles::OnboardingBundles; pub use downloading::{LoadingBackdrop, OnboardingDownloading}; @@ -218,6 +226,109 @@ pub(crate) fn predownload_toggle_row(predownload: State) -> impl IntoEleme .into_element() } +pub(crate) fn choice_row( + title: &str, + subtitle: &str, + active: bool, + on_press: impl FnMut(()) + 'static, +) -> Element { + choice_row_sized(title, subtitle, active, Size::auto(), on_press) +} + +pub(crate) fn choice_row_sized( + title: &str, + subtitle: &str, + active: bool, + height: Size, + on_press: impl FnMut(()) + 'static, +) -> Element { + let mut on_press = on_press; + let border_color = if active { + colors::fg_primary() + } else { + colors::component_border() + }; + + rect() + .horizontal() + .width(Size::fill()) + .height(height) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., border_color)) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_press(move |_| on_press(())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title.to_string()) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .maybe_child((!subtitle.is_empty()).then(|| { + label() + .text(subtitle.to_string()) + .font_size(11.) + .color(colors::fg_primary()) + .into_element() + })), + ) + .maybe_child(active.then(|| Icon::new(IconType::Check).size(15.))) + .into_element() +} + +pub(crate) fn version_chip( + text: &str, + active: bool, + on_press: impl FnMut(()) + 'static, +) -> Element { + let mut on_press = on_press; + let (bg, border, fg) = if active { + ( + colors::brand().with_a(38), + colors::brand(), + colors::fg_primary(), + ) + } else { + ( + Color::TRANSPARENT, + colors::component_border(), + colors::fg_secondary(), + ) + }; + + rect() + .horizontal() + .height(Size::px(32.)) + .center() + .padding(Gaps::new_symmetric(0., 14.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(bg) + .border(border_all_color(1.5, border)) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_press(move |_| on_press(())) + .child( + label() + .text(text.to_string()) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(fg), + ) + .into_element() +} + pub(crate) fn step_heading(title: &str, subtitle: &str) -> impl IntoElement { rect() .vertical() diff --git a/packages/oneclient_app/src/view/onboarding/selection.rs b/packages/oneclient_app/src/view/onboarding/selection.rs new file mode 100644 index 00000000..a9538820 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/selection.rs @@ -0,0 +1,220 @@ +use std::collections::HashSet; + +use oneclient_core::{BundleArchive, BundleFile}; + +use crate::hooks::ClusterBundles; + +pub fn pkg_key(cluster_id: i64, bundle_name: &str, package_id: &str) -> String { + format!("{cluster_id}|{bundle_name}|{package_id}") +} + +pub fn is_default_bundle(archive: &BundleArchive) -> bool { + archive.manifest.enabled +} + +pub fn is_default_file(file: &BundleFile) -> bool { + file.enabled && !file.hidden +} + +pub fn is_optional_file(file: &BundleFile) -> bool { + !file.enabled && !file.hidden +} + +pub fn default_selection( + items: &[ClusterBundles], + opted_in_categories: Option<&[String]>, +) -> HashSet { + let mut selected = HashSet::new(); + + for cb in items { + for archive in &cb.archives { + if !is_default_bundle(archive) && !category_opted_in(archive, opted_in_categories) { + continue; + } + for file in &archive.manifest.files { + if is_default_file(file) { + selected.insert(pkg_key( + cb.cluster.id, + &archive.manifest.name, + &file.kind.package_id(), + )); + } + } + } + } + + selected +} + +fn category_opted_in(archive: &BundleArchive, opted_in: Option<&[String]>) -> bool { + let Some(categories) = opted_in else { + return false; + }; + let category = archive.manifest.category.trim(); + if category.is_empty() { + return false; + } + categories.iter().any(|c| c.eq_ignore_ascii_case(category)) +} + +pub fn archive_selected( + cluster_id: i64, + archive: &BundleArchive, + selected: &HashSet, +) -> bool { + let mut any = false; + for file in archive.manifest.files.iter().filter(|f| is_default_file(f)) { + any = true; + if !selected.contains(&pkg_key( + cluster_id, + &archive.manifest.name, + &file.kind.package_id(), + )) { + return false; + } + } + any +} + +pub fn set_archive_selected( + cluster_id: i64, + archive: &BundleArchive, + on: bool, + selected: &mut HashSet, +) { + for file in archive.manifest.files.iter().filter(|f| is_default_file(f)) { + let key = pkg_key( + cluster_id, + &archive.manifest.name, + &file.kind.package_id(), + ); + if on { + selected.insert(key); + } else { + selected.remove(&key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::{archive, cluster, file}; + + fn catalog() -> Vec { + vec![ClusterBundles { + cluster: cluster(1), + archives: vec![ + archive("QoL", true, vec![file("qol-a", true, false)]), + archive( + "Utility", + true, + vec![file("util-a", true, false), file("util-opt", false, false)], + ), + archive("PvP", false, vec![file("pvp-a", true, false)]), + archive("SkyBlock", false, vec![file("sb-a", true, false)]), + ], + }] + } + + #[test] + fn opt_in_bundles_are_not_selected_by_default() { + let selected = default_selection(&catalog(), None); + + assert!(!selected.iter().any(|k| k.contains("[PvP]"))); + assert!(!selected.iter().any(|k| k.contains("[SkyBlock]"))); + } + + #[test] + fn default_bundles_are_selected_by_default() { + let selected = default_selection(&catalog(), None); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "qol-a"))); + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [Utility]", "util-a"))); + } + + #[test] + fn files_the_bundle_ships_off_are_not_selected_by_default() { + let selected = default_selection(&catalog(), None); + + assert!(!selected.contains(&pkg_key( + 1, + "OneClient 1.21.11 Fabric [Utility]", + "util-opt" + ))); + } + + #[test] + fn migrated_categories_opt_into_their_bundles_without_losing_defaults() { + let cats = vec!["SkyBlock".to_string()]; + let selected = default_selection(&catalog(), Some(&cats)); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [SkyBlock]", "sb-a"))); + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "qol-a"))); + assert!(!selected.iter().any(|k| k.contains("[PvP]"))); + } + + #[test] + fn migrated_category_match_ignores_case() { + let cats = vec!["skyblock".to_string()]; + let selected = default_selection(&catalog(), Some(&cats)); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [SkyBlock]", "sb-a"))); + } + + #[test] + fn hidden_files_are_never_selected() { + let items = vec![ClusterBundles { + cluster: cluster(1), + archives: vec![archive( + "QoL", + true, + vec![file("shown", true, false), file("dep", true, true)], + )], + }]; + let selected = default_selection(&items, None); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "shown"))); + assert!(!selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "dep"))); + } + + #[test] + fn selection_is_per_cluster() { + let items = vec![ + ClusterBundles { + cluster: cluster(1), + archives: vec![archive("SkyBlock", false, vec![file("sb-a", true, false)])], + }, + ClusterBundles { + cluster: cluster(2), + archives: vec![archive("SkyBlock", false, vec![file("sb-a", true, false)])], + }, + ]; + let mut selected = HashSet::new(); + let sb = &items[0].archives[0]; + set_archive_selected(1, sb, true, &mut selected); + + assert!(archive_selected(1, sb, &selected)); + assert!(!archive_selected(2, &items[1].archives[0], &selected)); + } + + #[test] + fn set_archive_selected_round_trips() { + let items = catalog(); + let pvp = &items[0].archives[2]; + let mut selected = HashSet::new(); + + set_archive_selected(1, pvp, true, &mut selected); + assert!(archive_selected(1, pvp, &selected)); + + set_archive_selected(1, pvp, false, &mut selected); + assert!(!archive_selected(1, pvp, &selected)); + assert!(selected.is_empty()); + } + + #[test] + fn archive_with_no_default_files_is_not_selected() { + let a = archive("Odd", true, vec![file("opt", false, false)]); + assert!(!archive_selected(1, &a, &HashSet::new())); + } +} diff --git a/packages/oneclient_app/src/view/onboarding/test_support.rs b/packages/oneclient_app/src/view/onboarding/test_support.rs new file mode 100644 index 00000000..9aea0908 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/test_support.rs @@ -0,0 +1,67 @@ +use oneclient_core::clusters::{Cluster, ClusterStage}; +use oneclient_core::packages::domain::{ContentType, GameLoader}; +use oneclient_core::packages::types::ExternalFile; +use oneclient_core::{Bundle, BundleArchive, BundleFile, BundleFileKind, BundleManifest}; +use std::path::PathBuf; + +pub fn file(package_id: &str, enabled: bool, hidden: bool) -> BundleFile { + BundleFile { + enabled, + hidden, + path: format!("mods/{package_id}.jar"), + size: 1, + kind: BundleFileKind::External(ExternalFile { + name: format!("{package_id}.jar"), + url: format!("https://example.invalid/{package_id}.jar"), + sha1: package_id.to_string(), + size: 1, + content_type: ContentType::Mod, + }), + } +} + +pub fn archive(category: &str, enabled: bool, files: Vec) -> BundleArchive { + let name = format!("OneClient 1.21.11 Fabric [{category}]"); + BundleArchive { + bundle: Bundle { + remote_path: format!("/bundles/{category}.mrpack"), + mc_version: "1.21.11".to_string(), + loader: GameLoader::Fabric, + file_name: format!("{category}.mrpack"), + name: name.clone(), + version_id: "1.0.0".to_string(), + category: category.to_string(), + loader_version: "0.16.0".to_string(), + path: PathBuf::from("/tmp/unused.mrpack"), + hidden: false, + }, + manifest: BundleManifest { + name, + version_id: "1.0.0".to_string(), + category: category.to_string(), + mc_version: "1.21.11".to_string(), + loader: GameLoader::Fabric, + loader_version: "0.16.0".to_string(), + enabled, + files, + }, + } +} + +pub fn cluster(id: i64) -> Cluster { + Cluster { + id, + name: format!("1.21.11 Fabric #{id}"), + folder_name: format!("cluster-{id}"), + setting_profile_name: None, + mc_version: "1.21.11".to_string(), + mc_loader: GameLoader::Fabric, + mc_loader_version: None, + stage: ClusterStage::default(), + created_at: None, + last_played: None, + overall_played: std::time::Duration::ZERO, + linked_modpack_hash: None, + } +} + diff --git a/packages/oneclient_core/src/bundles/install.rs b/packages/oneclient_core/src/bundles/install.rs index a1be47a7..c328d7a2 100644 --- a/packages/oneclient_core/src/bundles/install.rs +++ b/packages/oneclient_core/src/bundles/install.rs @@ -21,6 +21,39 @@ fn is_base62(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) } +pub fn effective_enabled(file: &BundleFile, user_override: Option) -> bool { + match user_override { + Some(OverrideType::Removed | OverrideType::Disabled) => false, + Some(OverrideType::Enabled) => true, + None => file.enabled, + } +} + +fn find_override( + overrides: &[oneclient_db::models::ClusterBundleOverrideRow], + bundle_name: &str, + package_id: &str, +) -> Option { + overrides + .iter() + .find(|o| o.bundle_name == bundle_name && o.package_id == package_id) + .and_then(|o| OverrideType::parse(&o.override_type)) +} + +async fn clear_suppressing_override( + cluster_id: i64, + bundle_name: &str, + package_id: &str, + services: &LauncherServices, +) -> LauncherResult<()> { + let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; + if let Some(OverrideType::Enabled) = find_override(&overrides, bundle_name, package_id) { + return Ok(()); + } + bundle_dao::remove_override(&services.db, cluster_id, bundle_name, package_id).await?; + Ok(()) +} + #[tracing::instrument(level = "debug", skip(file, child, services), fields(package = %file.display_name()))] pub async fn install_package_from_bundle( file: &BundleFile, @@ -178,19 +211,8 @@ pub async fn install_enabled_bundle_files( .files .iter() .filter(|file| { - if !file.enabled { - return false; - } let package_id = file.kind.package_id(); - let overridden = overrides.iter().any(|o| { - o.bundle_name == bundle_name - && o.package_id == package_id - && matches!( - OverrideType::parse(&o.override_type), - Some(OverrideType::Removed | OverrideType::Disabled) - ) - }); - if overridden { + if !effective_enabled(file, find_override(&overrides, &bundle_name, &package_id)) { return false; } let already_installed = match &file.kind { @@ -285,19 +307,8 @@ pub async fn enabled_bundle_bytes( for archive in &archives { let bundle_name = &archive.manifest.name; for file in &archive.manifest.files { - if !file.enabled { - continue; - } let package_id = file.kind.package_id(); - let overridden = overrides.iter().any(|o| { - &o.bundle_name == bundle_name - && o.package_id == package_id - && matches!( - OverrideType::parse(&o.override_type), - Some(OverrideType::Removed | OverrideType::Disabled) - ) - }); - if overridden { + if !effective_enabled(file, find_override(&overrides, bundle_name, &package_id)) { continue; } let already_installed = match &file.kind { @@ -316,6 +327,26 @@ pub async fn enabled_bundle_bytes( Ok(total) } +#[tracing::instrument(level = "debug", skip(services))] +pub async fn set_bundle_package_override( + cluster_id: i64, + bundle_name: &str, + package_id: &str, + override_type: Option, + services: &LauncherServices, +) -> LauncherResult<()> { + match override_type { + Some(ty) => { + bundle_dao::save_override(&services.db, cluster_id, bundle_name, package_id, ty).await?; + } + None => { + bundle_dao::remove_override(&services.db, cluster_id, bundle_name, package_id).await?; + } + } + + Ok(()) +} + #[tracing::instrument(level = "debug", skip(services))] pub async fn set_bundle_package_enabled( cluster_id: i64, @@ -324,22 +355,40 @@ pub async fn set_bundle_package_enabled( enabled: bool, services: &LauncherServices, ) -> LauncherResult<()> { - if enabled { - bundle_dao::remove_override(&services.db, cluster_id, bundle_name, package_id).await?; + let override_type = if enabled { + None } else { - bundle_dao::save_override( - &services.db, - cluster_id, - bundle_name, - package_id, - OverrideType::Disabled, - ) - .await?; - } + Some(OverrideType::Disabled) + }; + set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, services).await +} +#[tracing::instrument(level = "debug", skip(overrides, services), fields(count = overrides.len()))] +pub async fn set_bundle_package_overrides( + cluster_id: i64, + overrides: &[(String, String, OverrideType)], + services: &LauncherServices, +) -> LauncherResult<()> { + bundle_dao::save_overrides(&services.db, cluster_id, overrides).await?; Ok(()) } +#[tracing::instrument(level = "debug", skip(services))] +pub async fn set_bundle_package_opt_in( + cluster_id: i64, + bundle_name: &str, + package_id: &str, + opted_in: bool, + services: &LauncherServices, +) -> LauncherResult<()> { + let override_type = if opted_in { + Some(OverrideType::Enabled) + } else { + None + }; + set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, services).await +} + #[tracing::instrument(level = "debug", skip(services))] pub async fn list_cluster_bundle_overrides( cluster_id: i64, @@ -415,8 +464,7 @@ pub async fn on_user_enable_artifact( ) -> LauncherResult<()> { if let Some(tracked) = bundle_dao::get_bundle_tracked(&services.db, cluster_id, hash).await? && let (Some(bundle_name), Some(package_id)) = (tracked.bundle_name, tracked.package_id) { - bundle_dao::remove_override(&services.db, cluster_id, &bundle_name, &package_id) - .await?; + clear_suppressing_override(cluster_id, &bundle_name, &package_id, services).await?; } Ok(()) } @@ -500,11 +548,11 @@ pub async fn remove_artifact_from_cluster( ) .await?; if !replacement_exists { - bundle_dao::remove_override( - &services.db, + clear_suppressing_override( cluster_id, &bundle_name, &package_id, + services, ) .await?; } @@ -513,3 +561,84 @@ pub async fn remove_artifact_from_cluster( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::packages::domain::ContentType; + use oneclient_db::models::ClusterBundleOverrideRow; + + fn file(enabled: bool) -> BundleFile { + BundleFile { + enabled, + hidden: false, + path: "mods/example.jar".to_string(), + size: 1, + kind: BundleFileKind::External(ExternalFile { + name: "example.jar".to_string(), + url: "https://example.invalid/example.jar".to_string(), + sha1: "abc123".to_string(), + size: 1, + content_type: ContentType::Mod, + }), + } + } + + #[test] + fn override_none_falls_back_to_manifest_default() { + assert!(effective_enabled(&file(true), None)); + assert!(!effective_enabled(&file(false), None)); + } + + #[test] + fn suppressing_overrides_win_over_enabled_manifest() { + assert!(!effective_enabled(&file(true), Some(OverrideType::Disabled))); + assert!(!effective_enabled(&file(true), Some(OverrideType::Removed))); + } + + #[test] + fn enabled_override_wins_over_disabled_manifest() { + assert!(effective_enabled(&file(false), Some(OverrideType::Enabled))); + } + + #[test] + fn enabled_override_round_trips_through_the_db_string() { + let parsed = OverrideType::parse(OverrideType::Enabled.as_str()); + assert_eq!(parsed, Some(OverrideType::Enabled)); + } + + #[test] + fn unknown_override_string_is_ignored() { + assert_eq!(OverrideType::parse("something-new"), None); + assert!(!effective_enabled(&file(false), None)); + } + + fn row(bundle: &str, pid: &str, ty: OverrideType) -> ClusterBundleOverrideRow { + ClusterBundleOverrideRow { + id: 1, + cluster_id: 1, + bundle_name: bundle.to_string(), + package_id: pid.to_string(), + override_type: ty.as_str().to_string(), + } + } + + #[test] + fn find_override_matches_on_bundle_and_package() { + let rows = vec![ + row("Bundle A", "pkg-1", OverrideType::Enabled), + row("Bundle B", "pkg-2", OverrideType::Disabled), + ]; + assert_eq!( + find_override(&rows, "Bundle A", "pkg-1"), + Some(OverrideType::Enabled) + ); + assert_eq!( + find_override(&rows, "Bundle B", "pkg-2"), + Some(OverrideType::Disabled) + ); + // Same package id under a different bundle must not collide. + assert_eq!(find_override(&rows, "Bundle B", "pkg-1"), None); + assert_eq!(find_override(&rows, "Bundle A", "pkg-3"), None); + } +} diff --git a/packages/oneclient_core/src/bundles/manager.rs b/packages/oneclient_core/src/bundles/manager.rs index 3e9b9395..d02d85b4 100644 --- a/packages/oneclient_core/src/bundles/manager.rs +++ b/packages/oneclient_core/src/bundles/manager.rs @@ -61,7 +61,7 @@ impl Bundle { pub struct BundlesManager { manifest: RwLock, - archive_cache: RwLock>, + pub(crate) archive_cache: RwLock>, } impl BundlesManager { @@ -74,12 +74,17 @@ impl BundlesManager { #[tracing::instrument(skip_all)] pub async fn sync(&self, services: &LauncherServices) -> LauncherResult { - let Some((manifest, manifest_changed)) = Self::fetch_manifest(services).await? else { + let Some(fetched) = Self::fetch_manifest(services).await? else { tracing::debug!( "skipping bundle sync because no remote or cached manifest is available" ); return Ok(false); }; + let FetchedManifest { + manifest, + changed: manifest_changed, + etag, + } = fetched; *self.manifest.write().await = manifest.clone(); self.archive_cache.write().await.clear(); @@ -99,8 +104,10 @@ impl BundlesManager { let bundles_root = paths::bundles_dir()?; polyio::create_dir_all(&bundles_root.join("bundles")).await?; + let mut synced_all = true; for entry in manifest.remote_paths() { if let Err(err) = self.sync_bundle(&entry, services, &bundles_root).await { + synced_all = false; tracing::warn!( remote_path = %entry.remote_path, error = %err, @@ -109,6 +116,18 @@ impl BundlesManager { } } + if synced_all { + if let Some(etag) = etag { + let etag_path = paths::bundles_dir()?.join("metadata.json.etag"); + let _ = polyio::write(&etag_path, etag.as_bytes()).await; + } + } else { + tracing::warn!( + "bundle catalog incomplete; not caching the manifest etag so the next \ + sync retries the missing bundles" + ); + } + Ok(true) } @@ -215,7 +234,7 @@ impl BundlesManager { #[tracing::instrument(level = "debug", skip(services))] async fn fetch_manifest( services: &LauncherServices, - ) -> LauncherResult> { + ) -> LauncherResult> { let manifest_path = paths::bundles_dir()?.join("metadata.json"); let etag_path = paths::bundles_dir()?.join("metadata.json.etag"); @@ -244,11 +263,12 @@ impl BundlesManager { polyio::create_dir_all(parent).await?; } polyio::write(&manifest_path, &bytes).await?; - if let Some(etag) = server_etag { - let _ = polyio::write(&etag_path, etag.as_bytes()).await; - } - Ok(Some((manifest, true))) + Ok(Some(FetchedManifest { + manifest, + changed: true, + etag: server_etag, + })) } Err(err) if manifest_path.exists() => { tracing::debug!( @@ -281,16 +301,26 @@ impl Default for BundlesManager { } } +struct FetchedManifest { + manifest: RemoteBundleManifest, + changed: bool, + etag: Option, +} + async fn read_cached_manifest( manifest_path: &Path, changed: bool, -) -> LauncherResult> { +) -> LauncherResult> { if !manifest_path.exists() { return Ok(None); } let bytes = polyio::read(manifest_path).await?; let manifest: RemoteBundleManifest = serde_json::from_slice(&bytes)?; - Ok(Some((manifest, changed))) + Ok(Some(FetchedManifest { + manifest, + changed, + etag: None, + })) } #[tracing::instrument(level = "debug", skip(services))] diff --git a/packages/oneclient_core/src/bundles/mod.rs b/packages/oneclient_core/src/bundles/mod.rs index 36f15f18..11e02254 100644 --- a/packages/oneclient_core/src/bundles/mod.rs +++ b/packages/oneclient_core/src/bundles/mod.rs @@ -10,11 +10,12 @@ mod updates; pub use error::BundleError; pub use install::{ - enabled_bundle_bytes, extract_bundle_overrides_for_cluster, install_bundle, + effective_enabled, enabled_bundle_bytes, extract_bundle_overrides_for_cluster, install_bundle, install_cluster_bundles, install_enabled_bundle_files, install_package_from_bundle, list_cluster_bundle_overrides, on_user_disable_artifact, on_user_enable_artifact, on_user_remove_artifact, - remove_artifact_from_cluster, set_bundle_package_enabled, + remove_artifact_from_cluster, set_bundle_package_enabled, set_bundle_package_opt_in, + set_bundle_package_override, set_bundle_package_overrides, }; pub use manager::{Bundle, BundlesManager}; pub use manifest::BundleManifest as RemoteBundleManifest; diff --git a/packages/oneclient_core/src/bundles/updates.rs b/packages/oneclient_core/src/bundles/updates.rs index 8d90a8b4..ba11903b 100644 --- a/packages/oneclient_core/src/bundles/updates.rs +++ b/packages/oneclient_core/src/bundles/updates.rs @@ -80,20 +80,42 @@ async fn check_bundle_updates_inner( .filter(|name| !loaded_bundle_names.contains(name)) .collect(); + let overrides_map: HashMap<(String, String), OverrideType> = overrides + .iter() + .filter_map(|o| { + OverrideType::parse(&o.override_type) + .map(|t| ((o.bundle_name.clone(), o.package_id.clone()), t)) + }) + .collect(); + let mut bundle_versions: HashMap< String, HashMap, > = HashMap::new(); let mut hidden_dependency_keys_by_bundle: HashMap> = HashMap::new(); let mut hidden_explicit_keys_by_bundle: HashMap> = HashMap::new(); + let mut shipped_keys_by_bundle: HashMap> = HashMap::new(); for archive in &archives { let mut files_map = HashMap::new(); let mut hidden_dependency_keys = HashSet::new(); let mut hidden_explicit_keys = HashSet::new(); + let mut shipped_keys = HashSet::new(); for file in &archive.manifest.files { - if !file.enabled { + shipped_keys.insert(match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + .. + } => managed_bundle_key(*provider, project_id), + BundleFileKind::External(ext) => external_bundle_key(&ext.sha1), + }); + + let user_override = overrides_map + .get(&(archive.manifest.name.clone(), file.kind.package_id())) + .copied(); + if !crate::bundles::effective_enabled(file, user_override) { continue; } match &file.kind { @@ -126,6 +148,7 @@ async fn check_bundle_updates_inner( hidden_dependency_keys_by_bundle .insert(archive.manifest.name.clone(), hidden_dependency_keys); hidden_explicit_keys_by_bundle.insert(archive.manifest.name.clone(), hidden_explicit_keys); + shipped_keys_by_bundle.insert(archive.manifest.name.clone(), shipped_keys); bundle_versions.insert(archive.manifest.name.clone(), files_map); } @@ -166,14 +189,6 @@ async fn check_bundle_updates_inner( .map(|a| a.manifest.name.as_str()) .collect(); - let overrides_map: HashMap<(String, String), OverrideType> = overrides - .iter() - .filter_map(|o| { - OverrideType::parse(&o.override_type) - .map(|t| ((o.bundle_name.clone(), o.package_id.clone()), t)) - }) - .collect(); - let mut updates_available = Vec::new(); let mut removals_available = Vec::new(); @@ -237,6 +252,17 @@ async fn check_bundle_updates_inner( }); } } else { + let user_disabled = matches!( + overrides_map.get(&(bundle_name.clone(), pkg_id.clone())), + Some(OverrideType::Disabled) + ); + let still_shipped = shipped_keys_by_bundle + .get(bundle_name) + .is_some_and(|keys| keys.contains(&installed_key)); + if user_disabled && still_shipped { + continue; + } + removals_available.push(BundlePackageRemoval { cluster_id, hash: bundle_pkg.hash.clone(), @@ -266,7 +292,11 @@ async fn check_bundle_updates_inner( } for file in &archive.manifest.files { - if !file.enabled { + let file_id = file.kind.package_id(); + let user_override = overrides_map + .get(&(archive.manifest.name.clone(), file_id.clone())) + .copied(); + if !crate::bundles::effective_enabled(file, user_override) { continue; } @@ -282,14 +312,6 @@ async fn check_bundle_updates_inner( continue; } - let file_id = file.kind.package_id(); - if matches!( - overrides_map.get(&(archive.manifest.name.clone(), file_id.clone())), - Some(OverrideType::Removed) - ) { - continue; - } - additions_available.push(BundlePackageAddition { cluster_id, bundle_name: archive.manifest.name.clone(), diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 97dbffa3..93e2a613 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -257,3 +257,40 @@ pub async fn ephemeral_services() -> LauncherResult { packages: PackageProviderRegistry::new(), }) } + +pub async fn seed_bundle_archive( + state: &LauncherState, + manifest: crate::bundles::BundleManifest, +) -> LauncherResult<()> { + let disk_path = format!("bundles/{}.mrpack", manifest.name); + let loader = manifest.loader as i64; + + oneclient_db::dao::bundle::upsert_bundle( + &state.services.db, + oneclient_db::models::NewBundle { + remote_path: &disk_path, + mc_version: &manifest.mc_version, + mc_loader: loader, + file_name: &format!("{}.mrpack", manifest.name), + name: Some(&manifest.name), + version_id: Some(&manifest.version_id), + category: Some(&manifest.category), + loader_version: Some(&manifest.loader_version), + disk_path: &disk_path, + hidden: false, + etag: None, + synced_at: None, + }, + ) + .await?; + + let path = crate::paths::launcher_dir()?.join(&disk_path); + state + .bundles + .archive_cache + .write() + .await + .insert(path, manifest); + + Ok(()) +} diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index e10f5cb1..73b5a50e 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -38,9 +38,11 @@ pub mod version; pub mod versions; pub use bundles::{ - apply_bundle_updates, check_bundle_updates, install_bundle, install_cluster_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, - set_bundle_package_enabled, + 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, BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status, diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs new file mode 100644 index 00000000..c10f1ebf --- /dev/null +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -0,0 +1,197 @@ +use oneclient_core::bundles::{BundleFile, BundleFileKind, BundleManifest, check_bundle_updates}; +use oneclient_core::clusters::{ClusterManager, CreateClusterOptions}; +use oneclient_core::packages::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_core::LauncherState; +use oneclient_db::dao::{artifact as artifact_dao, cluster_bundle as bundle_dao}; +use oneclient_db::models::OverrideType; + +const BUNDLE: &str = "Test Bundle"; +const MC_VERSION: &str = "1.21.1"; +const PROJECT_ID: &str = "sodium"; +const VERSION_ID: &str = "v1"; +const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn managed_file(enabled: bool) -> BundleFile { + BundleFile { + enabled, + hidden: false, + path: "mods/sodium.jar".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: PROJECT_ID.to_string(), + version_id: VERSION_ID.to_string(), + sha1: HASH.to_string(), + }, + } +} + +fn manifest(files: Vec) -> BundleManifest { + BundleManifest { + name: BUNDLE.to_string(), + version_id: "1".to_string(), + category: "test".to_string(), + mc_version: MC_VERSION.to_string(), + loader: GameLoader::Fabric, + loader_version: "0.16.0".to_string(), + enabled: true, + files, + } +} + +async fn cluster_with_tracked_mod(state: &LauncherState) -> i64 { + let cluster = ClusterManager::create( + state, + CreateClusterOptions::new("Bundle Cluster", MC_VERSION, GameLoader::Fabric), + ) + .await + .unwrap(); + + artifact_dao::insert_artifact( + &state.services.db, + HASH, + ContentType::Mod as i64, + "artifacts/sodium.jar", + "sodium.jar", + Some(1), + ) + .await + .unwrap(); + + artifact_dao::link_cluster_artifact(&state.services.db, cluster.id, HASH, "sodium.jar") + .await + .unwrap(); + + artifact_dao::upsert_provider_release( + &state.services.db, + ProviderId::Modrinth as i64, + PROJECT_ID, + VERSION_ID, + HASH, + "Sodium", + "1.0.0", + None, + MC_VERSION, + "fabric", + ) + .await + .unwrap(); + + bundle_dao::track_bundle_artifact( + &state.services.db, + cluster.id, + HASH, + BUNDLE, + VERSION_ID, + PROJECT_ID, + ) + .await + .unwrap(); + + cluster.id +} + +#[tokio::test] +async fn mod_still_in_manifest_is_not_removed() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![managed_file(true)])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services) + .await + .unwrap(); + + assert!( + check.removals_available.is_empty(), + "an unchanged tracked mod was flagged for removal: {:?}", + check.removals_available + ); + assert!(check.updates_available.is_empty()); +} + +/// behaviour the disabled-mod case must not be allowed to imitate. +#[tokio::test] +async fn mod_dropped_from_manifest_is_removed() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services) + .await + .unwrap(); + + assert_eq!( + check.removals_available.len(), + 1, + "a mod the bundle no longer ships should be removed" + ); + assert_eq!(check.removals_available[0].package_id, PROJECT_ID); +} + +#[tokio::test] +async fn disabled_mod_dropped_from_manifest_is_still_removed() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services) + .await + .unwrap(); + + assert_eq!( + check.removals_available.len(), + 1, + "a disabled mod the bundle no longer ships should still be removed" + ); +} + +#[tokio::test] +async fn user_disabled_mod_is_not_treated_as_a_removal() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![managed_file(true)])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services) + .await + .unwrap(); + + assert!( + check.removals_available.is_empty(), + "a user-disabled mod is still shipped by the bundle and must not be removed: {:?}", + check.removals_available + ); +} diff --git a/packages/oneclient_db/src/dao/cluster_bundle.rs b/packages/oneclient_db/src/dao/cluster_bundle.rs index 10cf6f2b..b017c83c 100644 --- a/packages/oneclient_db/src/dao/cluster_bundle.rs +++ b/packages/oneclient_db/src/dao/cluster_bundle.rs @@ -142,6 +142,35 @@ pub async fn save_override( Ok(()) } +pub async fn save_overrides( + pool: &SqlitePool, + cluster_id: i64, + overrides: &[(String, String, OverrideType)], +) -> Result<(), sqlx::Error> { + if overrides.is_empty() { + return Ok(()); + } + + let mut tx = pool.begin().await?; + for (bundle_name, package_id, override_type) in overrides { + sqlx::query!( + r#" + INSERT INTO cluster_bundle_overrides (cluster_id, bundle_name, package_id, override_type) + VALUES (?, ?, ?, ?) + ON CONFLICT(cluster_id, bundle_name, package_id) DO UPDATE SET + override_type = excluded.override_type + "#, + cluster_id, + bundle_name, + package_id, + override_type.as_str() + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + pub async fn remove_override( pool: &SqlitePool, cluster_id: i64, diff --git a/packages/oneclient_db/src/models/cluster_bundle.rs b/packages/oneclient_db/src/models/cluster_bundle.rs index 7a826900..fdabe3f7 100644 --- a/packages/oneclient_db/src/models/cluster_bundle.rs +++ b/packages/oneclient_db/src/models/cluster_bundle.rs @@ -4,6 +4,8 @@ use sqlx::FromRow; pub enum OverrideType { Removed, Disabled, + /// Opt-in: install a file the bundle manifest ships with `enabled: false`. + Enabled, } impl OverrideType { @@ -11,6 +13,7 @@ impl OverrideType { match self { Self::Removed => "removed", Self::Disabled => "disabled", + Self::Enabled => "enabled", } } @@ -18,6 +21,7 @@ impl OverrideType { match value { "removed" => Some(Self::Removed), "disabled" => Some(Self::Disabled), + "enabled" => Some(Self::Enabled), _ => None, } }