parent
29d1f1c9d2
commit
668737fc6c
25 changed files with 1740 additions and 940 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ pub struct PackageEntry {
|
|||
pub enabled: bool,
|
||||
pub installed: bool,
|
||||
pub hash: Option<String>,
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -70,7 +70,13 @@ pub fn use_browser_state_store() -> State<HashMap<String, BrowserUiState>> {
|
|||
#[derive(Clone)]
|
||||
pub struct OnboardingSelectionState {
|
||||
pub selected: State<HashSet<String>>,
|
||||
pub seeded: State<bool>,
|
||||
/// 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<bool>,
|
||||
/// 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<Option<Vec<String>>>,
|
||||
pub language: State<String>,
|
||||
pub reduce_motion: State<bool>,
|
||||
pub predownload: State<bool>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::<Vec<String>>);
|
||||
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(|| {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Cluster>,
|
||||
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<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
on_open.call(());
|
||||
})
|
||||
.child(
|
||||
Icon::new(IconType::DotsVertical)
|
||||
.size(18.)
|
||||
.color(colors::fg_primary()),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, oneclient_core::packages::CachedPackageMeta>;
|
||||
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<UnifiedMember>,
|
||||
members: Vec<(Cluster, BundleArchive)>,
|
||||
}
|
||||
|
||||
impl UnifiedBundle {
|
||||
impl OptInBundle {
|
||||
fn chosen(&self, selected: &HashSet<String>) -> Vec<i64> {
|
||||
self.members
|
||||
.iter()
|
||||
.filter(|(cluster, archive)| archive_selected(cluster.id, archive, selected))
|
||||
.map(|(cluster, _)| cluster.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn wanted(&self, selected: &HashSet<String>) -> 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<UnifiedBundle> {
|
||||
fn opt_in_bundles(clusters: &[ClusterBundles]) -> Vec<OptInBundle> {
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
let mut map: HashMap<String, UnifiedBundle> = HashMap::new();
|
||||
let mut map: HashMap<String, OptInBundle> = 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<String>) -> 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<HashSet<String>>, 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<String> {
|
||||
self.locations
|
||||
.iter()
|
||||
.map(|(cluster_id, bundle)| pkg_key(*cluster_id, bundle, &self.package_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn selected(&self, selected: &HashSet<String>) -> bool {
|
||||
self.keys().iter().any(|k| selected.contains(k))
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_mods(clusters: &[ClusterBundles]) -> Vec<OptionalMod> {
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
let mut map: HashMap<String, OptionalMod> = 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<HashSet<String>>,
|
||||
mut user_touched: State<bool>,
|
||||
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<HashSet<String>>, key: String) {
|
||||
fn toggle_optional_mod(
|
||||
mut selected: State<HashSet<String>>,
|
||||
mut user_touched: State<bool>,
|
||||
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::<String>);
|
||||
|
||||
use_side_effect(move || {
|
||||
if *seeded.peek() {
|
||||
return;
|
||||
}
|
||||
let Some(items) = onboarding_bundles_items(&bundles_query) else {
|
||||
return;
|
||||
};
|
||||
let mut defaults: HashSet<String> = 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<Element> = 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<Element> = 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<String>,
|
||||
selected: State<HashSet<String>>,
|
||||
user_touched: State<bool>,
|
||||
) -> impl IntoElement {
|
||||
let name = bundle.display_name.clone();
|
||||
let wanted = bundle.wanted(selected_set);
|
||||
let chosen = bundle.chosen(selected_set);
|
||||
let all_ids: Vec<i64> = 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<Element> = 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<String>,
|
||||
selected: State<HashSet<String>>,
|
||||
user_touched: State<bool>,
|
||||
mr_meta: &MetaMap,
|
||||
cf_meta: &MetaMap,
|
||||
) -> Element {
|
||||
let cards: Vec<Element> = 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<Element> = 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<String>, Vec<String>) {
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HashSet<String>>,
|
||||
pub(super) open: State<Option<String>>,
|
||||
}
|
||||
|
||||
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<TabItem> = 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<Element> = Vec::new();
|
||||
let mut selected_count = 0usize;
|
||||
|
||||
if let Some(member) = member {
|
||||
for ct in SECTION_ORDER {
|
||||
let rows: Vec<Element> = 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<String>, Vec<String>) {
|
||||
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<Element>) -> 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<String>,
|
||||
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()
|
||||
}
|
||||
171
packages/oneclient_app/src/view/onboarding/bundles/row.rs
Normal file
171
packages/oneclient_app/src/view/onboarding/bundles/row.rs
Normal file
|
|
@ -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<String>,
|
||||
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()
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
) -> 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<String> =
|
||||
archive_overrides(cb.cluster.id, archive, selected)
|
||||
.into_iter()
|
||||
.filter(|(_, _, ty)| *ty == OverrideType::Removed)
|
||||
.map(|(_, pid, _)| pid)
|
||||
.collect();
|
||||
let opted_in: std::collections::HashSet<String> =
|
||||
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<BundleArchive>) -> Vec<ClusterBundles> {
|
||||
vec![ClusterBundles {
|
||||
cluster: cluster(1),
|
||||
archives,
|
||||
}]
|
||||
}
|
||||
|
||||
fn keys(archive: &BundleArchive, pids: &[&str]) -> HashSet<String> {
|
||||
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<String> =
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
let mut categories: Vec<String> = 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 {
|
||||
|
|
|
|||
|
|
@ -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<bool>) -> 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()
|
||||
|
|
|
|||
220
packages/oneclient_app/src/view/onboarding/selection.rs
Normal file
220
packages/oneclient_app/src/view/onboarding/selection.rs
Normal file
|
|
@ -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<String> {
|
||||
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<String>,
|
||||
) -> 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<String>,
|
||||
) {
|
||||
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<ClusterBundles> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
67
packages/oneclient_app/src/view/onboarding/test_support.rs
Normal file
67
packages/oneclient_app/src/view/onboarding/test_support.rs
Normal file
|
|
@ -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<BundleFile>) -> 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<OverrideType>) -> 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<OverrideType> {
|
||||
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<OverrideType>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ impl Bundle {
|
|||
|
||||
pub struct BundlesManager {
|
||||
manifest: RwLock<RemoteBundleManifest>,
|
||||
archive_cache: RwLock<HashMap<PathBuf, crate::bundles::types::BundleManifest>>,
|
||||
pub(crate) archive_cache: RwLock<HashMap<PathBuf, crate::bundles::types::BundleManifest>>,
|
||||
}
|
||||
|
||||
impl BundlesManager {
|
||||
|
|
@ -74,12 +74,17 @@ impl BundlesManager {
|
|||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn sync(&self, services: &LauncherServices) -> LauncherResult<bool> {
|
||||
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<Option<(RemoteBundleManifest, bool)>> {
|
||||
) -> LauncherResult<Option<FetchedManifest>> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
async fn read_cached_manifest(
|
||||
manifest_path: &Path,
|
||||
changed: bool,
|
||||
) -> LauncherResult<Option<(RemoteBundleManifest, bool)>> {
|
||||
) -> LauncherResult<Option<FetchedManifest>> {
|
||||
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))]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String, (String, crate::bundles::types::BundleFile)>,
|
||||
> = HashMap::new();
|
||||
let mut hidden_dependency_keys_by_bundle: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
let mut hidden_explicit_keys_by_bundle: HashMap<String, HashSet<String>> = HashMap::new();
|
||||
let mut shipped_keys_by_bundle: HashMap<String, HashSet<String>> = 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(),
|
||||
|
|
|
|||
|
|
@ -257,3 +257,40 @@ pub async fn ephemeral_services() -> LauncherResult<LauncherServices> {
|
|||
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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
197
packages/oneclient_core/tests/bundle_updates.rs
Normal file
197
packages/oneclient_core/tests/bundle_updates.rs
Normal file
|
|
@ -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<BundleFile>) -> 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue