Rewrite bundles page

Fixes #547, #576
This commit is contained in:
Julian Chang 2026-07-15 16:24:52 -04:00
parent 29d1f1c9d2
commit 668737fc6c
No known key found for this signature in database
25 changed files with 1740 additions and 940 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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()
}

View file

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

View file

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

View file

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

View 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()));
}
}

View 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,
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
);
}

View file

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

View file

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