diff --git a/.changeset/opt_in_bundles_are_asked_on_new_versions.md b/.changeset/opt_in_bundles_are_asked_on_new_versions.md new file mode 100644 index 00000000..eaa1b9e1 --- /dev/null +++ b/.changeset/opt_in_bundles_are_asked_on_new_versions.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +# Optional bundles are now offered before the first launch of every new version instead of being installed or skipped silently diff --git a/.sqlx/query-001a994c740f7a015152319eb16fc113954af25ebd2a44d55e95833adda53268.json b/.sqlx/query-001a994c740f7a015152319eb16fc113954af25ebd2a44d55e95833adda53268.json new file mode 100644 index 00000000..d0962fc6 --- /dev/null +++ b/.sqlx/query-001a994c740f7a015152319eb16fc113954af25ebd2a44d55e95833adda53268.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR IGNORE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted)\n SELECT ?, bundle_name, accepted\n FROM cluster_bundle_choices\n WHERE cluster_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "001a994c740f7a015152319eb16fc113954af25ebd2a44d55e95833adda53268" +} diff --git a/.sqlx/query-171c6771d06656c2fe375f6a0da889f828f400e950f103f0ed44f5f98a48dc50.json b/.sqlx/query-171c6771d06656c2fe375f6a0da889f828f400e950f103f0ed44f5f98a48dc50.json new file mode 100644 index 00000000..aa121266 --- /dev/null +++ b/.sqlx/query-171c6771d06656c2fe375f6a0da889f828f400e950f103f0ed44f5f98a48dc50.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT bundle_name, accepted\n FROM cluster_bundle_choices\n WHERE cluster_id = ?\n ", + "describe": { + "columns": [ + { + "name": "bundle_name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_bundle_choices", + "name": "bundle_name" + } + } + }, + { + "name": "accepted", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_bundle_choices", + "name": "accepted" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "171c6771d06656c2fe375f6a0da889f828f400e950f103f0ed44f5f98a48dc50" +} diff --git a/.sqlx/query-c06af5530a824d31e6e18a3a52cd57639b74e7e4d2ae30e29a6c49fda9521b4d.json b/.sqlx/query-c06af5530a824d31e6e18a3a52cd57639b74e7e4d2ae30e29a6c49fda9521b4d.json new file mode 100644 index 00000000..6dac2b17 --- /dev/null +++ b/.sqlx/query-c06af5530a824d31e6e18a3a52cd57639b74e7e4d2ae30e29a6c49fda9521b4d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR REPLACE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted)\n VALUES (?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "c06af5530a824d31e6e18a3a52cd57639b74e7e4d2ae30e29a6c49fda9521b4d" +} diff --git a/packages/oneclient_app/src/components/bundle_choices_popup.rs b/packages/oneclient_app/src/components/bundle_choices_popup.rs new file mode 100644 index 00000000..83a1f8f2 --- /dev/null +++ b/packages/oneclient_app/src/components/bundle_choices_popup.rs @@ -0,0 +1,140 @@ +use std::collections::HashSet; + +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType, OverlayPopup, toggle_controlled}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 460.; +const LIST_MAX_H: f32 = 306.; + +#[derive(PartialEq)] +pub struct BundleChoicesPopup; + +impl Component for BundleChoicesPopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let mut chosen = use_state(HashSet::::new); + + let Some(choices) = snapshot.bundle_choices.clone() else { + return rect().into_element(); + }; + + let mut list = rect().vertical().width(Size::fill()).spacing(6.); + for (name, title) in &choices.bundles { + let key = name.clone(); + let on_toggle: EventHandler<()> = (move |()| { + let mut next = chosen.read().clone(); + if !next.remove(&key) { + next.insert(key.clone()); + } + chosen.set(next); + }) + .into(); + list = list.child( + rect() + .key(name.clone()) + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(8., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .child( + label() + .text(title.clone()) + .font_size(13.) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child(toggle_controlled(chosen.read().contains(name), on_toggle)), + ); + } + + let later = dispatch.clone(); + let close = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| { + chosen.set(HashSet::new()); + close.close_bundle_choices(None); + }) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::Inner) + .max_width(Size::window_percent(95.)) + .max_height(Size::window_percent(85.)) + .padding(Gaps::new_all(22.)) + .spacing(14.) + .corner_radius(CornerRadius::new_all(16.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Choose your bundles") + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{} has optional bundles you have not chosen yet. Turn on the ones you want.", + choices.cluster_name + )) + .font_size(12.5) + .max_lines(4) + .color(colors::fg_secondary()), + ) + .child( + ScrollView::new() + .width(Size::fill()) + .height(Size::Inner) + .max_height(Size::px(LIST_MAX_H)) + .child(list), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| { + chosen.set(HashSet::new()); + later.close_bundle_choices(None); + }) + .text("Ask me later"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + let picked = chosen.read().clone(); + chosen.set(HashSet::new()); + dispatch.close_bundle_choices(Some(picked)); + }) + .child(Icon::new(IconType::Check).size(15.)) + .text("Confirm & launch"), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/essential_confirm.rs b/packages/oneclient_app/src/components/essential_confirm.rs index 05393208..9a4f6a17 100644 --- a/packages/oneclient_app/src/components/essential_confirm.rs +++ b/packages/oneclient_app/src/components/essential_confirm.rs @@ -125,23 +125,21 @@ impl Component for WarningBody { .corner_radius(CornerRadius::new_all(8.)) .background(colors::component_bg()) .border(border_all_color(1., colors::component_border())) - .child( - Markdown::new(self.body.clone()) - .width(Size::fill()) - .style(MarkdownStyle { - color: Color::WHITE, - color_link: colors::code_info(), - color_code: Color::WHITE, - background_code: CARD_BG, - background_blockquote: CARD_BG, - border_blockquote: colors::component_border(), - background_divider: colors::component_border(), - headings: [16., 14., 13., 12., 12., 12.], - paragraph_size: 12., - code_font_size: 11., - ..MarkdownStyle::default() - }), - ), + .child(Markdown::new(self.body.clone()).width(Size::fill()).style( + MarkdownStyle { + color: Color::WHITE, + color_link: colors::code_info(), + color_code: Color::WHITE, + background_code: CARD_BG, + background_blockquote: CARD_BG, + border_blockquote: colors::component_border(), + background_divider: colors::component_border(), + headings: [16., 14., 13., 12., 12., 12.], + paragraph_size: 12., + code_font_size: 11., + ..MarkdownStyle::default() + }, + )), ), ) } diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 9f85d407..0467163e 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -3,6 +3,7 @@ mod active_cluster_panel; mod asset_image; mod avatar; mod blocked_downloads_popup; +mod bundle_choices_popup; mod button; mod charts; mod checkbox; @@ -63,6 +64,7 @@ pub use active_cluster_panel::ActiveClusterPanel; pub use asset_image::AssetImage; pub use avatar::Avatar; pub use blocked_downloads_popup::BlockedDownloadsPopup; +pub use bundle_choices_popup::BundleChoicesPopup; pub use button::{Button, ButtonSize, ButtonVariant, copy_button, link_button, open_folder_button}; pub use charts::{BarChart, PieChart, ValueUnit, slice_color}; pub use checkbox::checkbox_labeled; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 0b9e6746..f2c2b036 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -85,14 +85,16 @@ fn navbar_left(show_logo: bool) -> impl IntoElement { Size::auto() }) .cross_align(Alignment::Center) - .spacing(NAV_LINK_SPACING_PX / 2.) + .spacing(NAV_LINK_SPACING_PX / 2.) .maybe(!show_logo, |el| { el.padding(Gaps::new(0., NAV_LINK_SPACING_PX, 0., 0.)) }) - .child(Icon::new(IconType::IconLogo) + .child( + Icon::new(IconType::IconLogo) .size(COMPACT_LOGO_PX) - .into_element()) - .maybe(show_logo, |rect| rect.child(NavbarLogo.into_element())) + .into_element(), + ) + .maybe(show_logo, |rect| rect.child(NavbarLogo.into_element())) } #[derive(PartialEq)] @@ -134,7 +136,7 @@ fn navbar_center(is_small: bool) -> impl IntoElement { target: NavTarget::Route(Route::Clusters {}), nav_label: "Versions", }) - .child(NavLink { + .child(NavLink { active: matches!( route, Route::Browser { @@ -203,7 +205,7 @@ impl Component for NavLink { } else if hovering() || focused().is_focused() { theme::colors::ghost_overlay_hover() } else { - Color::TRANSPARENT + Color::TRANSPARENT }; rect() diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index ffbd51a2..94095444 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -6,9 +6,8 @@ use oneclient_core::SeenStatus; use crate::components::{ContextMenu, Icon, IconType, toggle_controlled}; use crate::essential::EssentialPackage; use crate::hooks::{ - ClusterAction, EssentialGuardKind, PendingEssential, disable_warnings, - loaded_image, use_cached_image, use_cluster_mutation, use_disable_warnings, - use_essential_guard, + ClusterAction, EssentialGuardKind, PendingEssential, disable_warnings, loaded_image, + use_cached_image, use_cluster_mutation, use_disable_warnings, use_essential_guard, }; use crate::routes::Route; use crate::theme::colors; @@ -198,7 +197,14 @@ impl Component for PackageRow { let (card, radius) = match layout { CardLayout::List => ( - list_card(&item, package_type, cluster_id, icon, on_toggle, on_context.clone()), + list_card( + &item, + package_type, + cluster_id, + icon, + on_toggle, + on_context.clone(), + ), 8., ), CardLayout::Grid => ( @@ -291,7 +297,9 @@ pub(crate) fn disable_warning_body( match warnings { Some(warnings) if bundled => warnings.body_for(&item.package_id).map(str::to_string), - _ => item.essential.map(|package| package.disable_body.to_string()), + _ => item + .essential + .map(|package| package.disable_body.to_string()), } } @@ -405,16 +413,12 @@ pub(crate) fn grid_card( let hovering = *hovered.read(); let bg = match (enabled, hovering) { - (_, true) => colors::component_bg_hover(), + (_, true) => colors::component_bg_hover(), (true, false) => colors::component_bg(), - (false, false) => colors::component_bg_disabled(), + (false, false) => colors::component_bg_disabled(), }; - let alpha = if enabled { - 255u8 - } else { - 115u8 - }; + let alpha = if enabled { 255u8 } else { 115u8 }; let border = if !hovering { colors::component_border() @@ -428,11 +432,7 @@ pub(crate) fn grid_card( // .cross_align(Alignment::Center) .spacing(11.) .content(Content::Flex) - .child( - rect() - .opacity(alpha as f32 / 255.) - .child(icon) - ) + .child(rect().opacity(alpha as f32 / 255.).child(icon)) .child( rect() .vertical() diff --git a/packages/oneclient_app/src/components/release_migration_popup.rs b/packages/oneclient_app/src/components/release_migration_popup.rs index f65c43a4..7f2ae794 100644 --- a/packages/oneclient_app/src/components/release_migration_popup.rs +++ b/packages/oneclient_app/src/components/release_migration_popup.rs @@ -167,6 +167,7 @@ impl Component for ReleaseMigrationPopup { let other_popup = snapshot.cluster_update.is_some() || snapshot.optional_mods.is_some() + || snapshot.bundle_choices.is_some() || snapshot.package_updates.is_some() || snapshot.pending_prompt.is_some(); if blocked_by_route(&route) || other_popup { diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index f290194b..1da72690 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -31,8 +31,8 @@ use tokio::sync::mpsc; use crate::components::IconType; use crate::launcher::off_ui; use crate::notifications::{ - ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup, - OptionalModsOutcome, PackageUpdateGroup, PendingPrompt, + BundleChoices, ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, + OptionalModsGroup, OptionalModsOutcome, PackageUpdateGroup, PendingPrompt, }; use crate::state::{AppChannel, AppState, AsyncStatus, FlaggedInstallPrompt, RelocationState}; use crate::{invalidate_java_queries, launcher}; @@ -756,6 +756,10 @@ impl Actions { self.with_engine(move |state| state.notifications.open_optional_mods(groups, done)); } + pub fn close_bundle_choices(&self, chosen: Option>) { + self.with_engine(move |state| state.notifications.finish_bundle_choices(chosen)); + } + pub fn close_optional_mods(&self, outcome: OptionalModsOutcome) { self.with_engine(move |state| state.notifications.finish_optional_mods(outcome)); } @@ -1796,6 +1800,73 @@ impl Actions { } } + async fn resolve_bundle_choices_before_launch( + &self, + state: &Arc, + cluster_id: ClusterId, + ) { + let bundles = match off_ui({ + let state = state.clone(); + async move { + oneclient_content::bundles::pending_bundle_choices( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + } + }) + .await + { + Ok(bundles) if !bundles.is_empty() => bundles, + Ok(_) => return, + Err(err) => { + tracing::warn!(cluster_id, error = %err, "could not read the bundles to ask about, launching anyway"); + return; + } + }; + + let choices_for: Vec = bundles.iter().map(|a| a.manifest.name.clone()).collect(); + let choices = BundleChoices { + cluster_name: crate::install::cluster_display_name(cluster_id, &state.services).await, + bundles: bundles + .iter() + .map(|a| { + ( + a.manifest.name.clone(), + crate::utils::bundle_display_name(a), + ) + }) + .collect(), + }; + let (done, wait) = tokio::sync::oneshot::channel(); + self.with_engine(move |state| { + state.notifications.open_bundle_choices(choices, done); + state.center_open = false; + }); + let Ok(Some(chosen)) = wait.await else { return }; + + let choices: Vec<_> = choices_for + .iter() + .map(|name| (name.clone(), chosen.contains(name))) + .collect(); + if let Err(err) = off_ui({ + let state = state.clone(); + async move { + oneclient_content::bundles::set_bundle_choices( + cluster_id, + &choices, + &state.services.content(), + ) + .await + } + }) + .await + { + tracing::warn!(cluster_id, error = %err, "could not record the bundle choices"); + } + } + async fn resolve_optional_mods_before_launch( &self, state: &Arc, @@ -2002,6 +2073,9 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) { // Before the game process never after Minecraft reads its mods once at // startup + actions + .resolve_bundle_choices_before_launch(&state, cluster_id) + .await; actions .resolve_bundle_updates_before_launch(&state, cluster_id) .await; diff --git a/packages/oneclient_app/src/hooks/actions/release_migration.rs b/packages/oneclient_app/src/hooks/actions/release_migration.rs index 11b9fc38..2e5d5f64 100644 --- a/packages/oneclient_app/src/hooks/actions/release_migration.rs +++ b/packages/oneclient_app/src/hooks/actions/release_migration.rs @@ -684,6 +684,22 @@ impl Actions { } } + if let Some(source) = prompt.source() { + let (source_id, target_id) = (source.id, prompt.target.id); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = oneclient_content::bundles::inherit_bundle_choices( + source_id, + target_id, + &state.services.content(), + ) + .await + { + tracing::warn!(error = %err, "could not carry the bundle choices over to the target"); + } + }); + } + if packages.is_empty() { return; } diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index 93d3c183..700bb1d6 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -353,6 +353,21 @@ impl MutationCapability for ClusterMutation { { tracing::warn!(cluster_id = cluster.id, error = %err, "failed to record the bundle choices for the new instance"); } + let choices: Vec<_> = archives + .iter() + .filter(|archive| !archive.manifest.enabled) + .map(|archive| { + let name = &archive.manifest.name; + (name.clone(), selected.contains(name)) + }) + .collect(); + if let Err(err) = oneclient_content::bundles::set_bundle_choices( + cluster.id, &choices, content, + ) + .await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to record the bundle choices for the new instance"); + } } let session = oneclient_events::GroupedProgressSession::start( diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index f1264ef6..4b821fff 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -112,10 +112,7 @@ impl Component for ClusterShell { let game = use_game_snapshot(); let launcher = use_launcher(); let cluster = use_cluster(cluster_id); - let syncing = launch_syncing( - &launcher, - cluster.as_ref().is_none_or(|c| c.uses_bundles()), - ); + let syncing = launch_syncing(&launcher, cluster.as_ref().is_none_or(|c| c.uses_bundles())); let show_game_log = game.is_active(cluster_id); let show_datapacks = cluster diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index b5d8bf47..1a42a8c1 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,10 +2,11 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, BlockedDownloadsPopup, ClusterUpdatePopup, ControlCenter, FlaggedInstallPopup, - GenericPromptOverlay, JavaPromptOverlay, MicrosoftJavaPromptOverlay, ModpackConfirmPopup, - NotificationCenter, OptionalModsPopup, PackageUpdatePopup, ReleaseMigrationPopup, - SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay, use_provide_tooltips, + AccountSwitcher, BlockedDownloadsPopup, BundleChoicesPopup, ClusterUpdatePopup, ControlCenter, + FlaggedInstallPopup, GenericPromptOverlay, JavaPromptOverlay, MicrosoftJavaPromptOverlay, + ModpackConfirmPopup, NotificationCenter, OptionalModsPopup, PackageUpdatePopup, + ReleaseMigrationPopup, SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay, + use_provide_tooltips, }; #[cfg(not(target_os = "macos"))] use crate::hooks::use_start_maximized; @@ -52,6 +53,7 @@ impl Component for RootLayout { .child(GenericPromptOverlay) .child(ClusterUpdatePopup) .child(OptionalModsPopup) + .child(BundleChoicesPopup) .child(BlockedDownloadsPopup) .child(ModpackConfirmPopup) .child(PackageUpdatePopup) diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index b94fff7d..43b67b67 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -243,8 +243,8 @@ fn main() { .and_then(|v| v.parse::().ok()) .unwrap_or(96 * 1024 * 1024), ) - .with_plugin(freya::borderless::BorderlessPlugin::new()) - .with_plugin(freya::metrics::MetricsPlugin::default()) + .with_plugin(freya::borderless::BorderlessPlugin::new()) + .with_plugin(freya::metrics::MetricsPlugin::default()) .with_default_font(theme::DEFAULT_FONT); if show_tray_icon && platform::tray::available() { diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 6b00310e..95424f79 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -103,6 +103,12 @@ impl BlockedDownloads { } } +#[derive(Clone, Debug, PartialEq)] +pub struct BundleChoices { + pub cluster_name: String, + pub bundles: Vec<(String, String)>, +} + #[derive(Clone, Debug, PartialEq)] pub struct OptionalModsGroup { pub cluster_id: ClusterId, @@ -251,6 +257,7 @@ pub struct NotificationSnapshot { pub pending_prompt: Option, pub cluster_update: Option>, pub optional_mods: Option>, + pub bundle_choices: Option, pub package_updates: Option>, pub blocked_downloads: Option, pub open_cluster: Option, @@ -274,6 +281,7 @@ pub struct NotificationState { pending_timers: Vec, cluster_update: Option>, optional_mods: Option>, + bundle_choices: Option, package_updates: Option>, blocked_downloads: Option, open_cluster: Option, @@ -283,6 +291,7 @@ pub struct NotificationState { /// because every way the modal can end goes through this state package_updates_done: Option>>, optional_mods_done: Option>, + bundle_choices_done: Option>>>, } const CATEGORY_ORDER: [TaskCategory; 7] = [ @@ -390,6 +399,7 @@ impl NotificationState { pending_prompt, cluster_update: self.cluster_update.clone(), optional_mods: self.optional_mods.clone(), + bundle_choices: self.bundle_choices.clone(), package_updates: self.package_updates.clone(), blocked_downloads: self.blocked_downloads.clone(), open_cluster: self.open_cluster, @@ -470,6 +480,23 @@ impl NotificationState { } } + pub fn open_bundle_choices( + &mut self, + choices: BundleChoices, + done: oneshot::Sender>>, + ) { + self.finish_bundle_choices(None); + self.bundle_choices = Some(choices); + self.bundle_choices_done = Some(done); + } + + pub fn finish_bundle_choices(&mut self, chosen: Option>) { + self.bundle_choices = None; + if let Some(done) = self.bundle_choices_done.take() { + let _ = done.send(chosen); + } + } + pub fn hide_optional_mods(&mut self) { self.optional_mods = None; } @@ -1272,7 +1299,10 @@ mod package_update_tests { let id = state.push_custom(&mut inbox, spec("kept")); state.expire_toast(&mut inbox, id); - assert!(inbox.iter().any(|e| e.id == id), "the inbox entry must stay"); + assert!( + inbox.iter().any(|e| e.id == id), + "the inbox entry must stay" + ); } #[test] diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 73e0ccdd..24667f97 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -84,7 +84,12 @@ pub(crate) fn browsable_type(package_type: &str, cluster: &Cluster) -> String { if cluster.lacks_mod_loader() { let unsupported = LOADER_SLUGS.contains(&package_type) || (package_type == DATAPACK_SLUG && !supports_datapacks(&cluster.mc_version)); - return if unsupported { TEXTURE_SLUG } else { package_type }.to_string(); + return if unsupported { + TEXTURE_SLUG + } else { + package_type + } + .to_string(); } if package_type == DATAPACK_SLUG && !supports_datapacks(&cluster.mc_version) { BROWSE_TYPES[0].0.to_string() diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index 4fbacbb1..d31c4990 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -12,8 +12,7 @@ use crate::hooks::{ cluster_content_items, content_type_for_slug, package_meta_batch, project_detail, use_browser_compat, use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_dispatch, use_installs_snapshot, use_link_confirm, use_package_meta_batch, - use_package_project, use_package_versions_when, - version_list, versions_total, + use_package_project, use_package_versions_when, version_list, versions_total, }; use crate::theme::colors; use crate::ui::border_all_color; diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index f3f28747..0bb690a0 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -479,7 +479,9 @@ impl Component for PackageManager { let shares_content = cluster .as_ref() .map(|cluster| cluster.shares_content(content_type)); - let uses_bundles = cluster.as_ref().is_none_or(|cluster| cluster.uses_bundles()); + let uses_bundles = cluster + .as_ref() + .is_none_or(|cluster| cluster.uses_bundles()); let active = use_state(|| 0usize); let search = use_state(String::new); diff --git a/packages/oneclient_app/src/view/app/clusters/instance/shell.rs b/packages/oneclient_app/src/view/app/clusters/instance/shell.rs index 18a19a94..de78930d 100644 --- a/packages/oneclient_app/src/view/app/clusters/instance/shell.rs +++ b/packages/oneclient_app/src/view/app/clusters/instance/shell.rs @@ -101,7 +101,12 @@ pub fn shell(parts: Shell) -> Element { .into_element() } -fn header(eyebrow: Option, title: String, subtitle: String, on_close: EventHandler<()>) -> Element { +fn header( + eyebrow: Option, + title: String, + subtitle: String, + on_close: EventHandler<()>, +) -> Element { rect() .horizontal() .width(Size::fill()) diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index 03991810..5ed23b43 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -87,7 +87,7 @@ impl Component for AddRow { .notify("Java runtimes refreshed") .body("The installed runtime list is up to date") .info() - .toast_only() + .toast_only() .send(); }); }; @@ -396,4 +396,4 @@ fn remove_button( .color(colors::danger()), ) .into_element() -} \ No newline at end of file +} diff --git a/packages/oneclient_app/src/view/onboarding/summary/mod.rs b/packages/oneclient_app/src/view/onboarding/summary/mod.rs index c018688e..ea3f341d 100644 --- a/packages/oneclient_app/src/view/onboarding/summary/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/summary/mod.rs @@ -23,6 +23,7 @@ struct ClusterPlan { cluster_id: i64, /// `(bundle_name, package_id, override)` for every file whose fate differs from the manifest default overrides: Vec<(String, String, OverrideType)>, + choices: Vec<(String, bool)>, } #[derive(PartialEq)] @@ -173,9 +174,19 @@ fn build_plans( for archive in &cb.archives { overrides.extend(archive_overrides(cb.cluster.id, archive, selected, &kept)); } + let choices = cb + .archives + .iter() + .filter(|archive| !archive.manifest.enabled) + .map(|archive| { + let taken = bundle_taken(cb.cluster.id, archive, selected); + (archive.manifest.name.clone(), taken) + }) + .collect(); ClusterPlan { cluster_id: cb.cluster.id, overrides, + choices, } }) .collect() @@ -284,7 +295,7 @@ fn finish_setup( mut failure: State>, ) { spawn(async move { - for plan in plans.iter().filter(|p| !p.overrides.is_empty()) { + for plan in &plans { if let Err(err) = apply_overrides(plan).await { // Expected failures (a busy or unwritable database) are not crashes warn! is a breadcrumb error! reports to Sentry if err.is_sentry_excluded() { @@ -378,6 +389,12 @@ async fn apply_overrides(plan: &ClusterPlan) -> oneclient_core::LauncherResult<( &state.services.content(), ) .await?; + oneclient_content::bundles::set_bundle_choices( + plan.cluster_id, + &plan.choices, + &state.services.content(), + ) + .await?; Ok(()) } diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index 9c8ce0c7..b8d7ffd3 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -22,6 +22,93 @@ fn is_base62(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) } +pub(crate) fn takes_bundle( + archive: &BundleArchive, + accepted: &std::collections::HashSet, +) -> bool { + archive.manifest.enabled || accepted.contains(&archive.manifest.name) +} + +fn wants_file(file: &BundleFile, user_override: Option, taken: bool) -> bool { + if taken { + effective_enabled(file, user_override) + } else { + user_override == Some(OverrideType::Enabled) + } +} + +pub(crate) async fn accepted_bundles( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + Ok(bundle_dao::list_bundle_choices(&ctx.db, cluster_id) + .await? + .into_iter() + .filter_map(|(name, accepted)| accepted.then_some(name)) + .collect()) +} + +pub async fn taken_bundle_names( + cluster_id: i64, + archives: &[BundleArchive], + ctx: &ContentCtx, +) -> ContentResult> { + let accepted = accepted_bundles(cluster_id, ctx).await?; + Ok(archives + .iter() + .filter(|archive| takes_bundle(archive, &accepted)) + .map(|archive| archive.manifest.name.clone()) + .collect()) +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn pending_bundle_choices( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult> { + let Some(cluster) = bundle_cluster(cluster_id, ctx).await? else { + return Ok(Vec::new()); + }; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + let decided: std::collections::HashSet = + bundle_dao::list_bundle_choices(&ctx.db, cluster_id) + .await? + .into_iter() + .map(|(name, _)| name) + .collect(); + Ok(bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await? + .into_iter() + .filter(|archive| { + !archive.manifest.enabled + && !archive.bundle.hidden + && !decided.contains(&archive.manifest.name) + }) + .collect()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn set_bundle_choices( + cluster_id: i64, + choices: &[(String, bool)], + ctx: &ContentCtx, +) -> ContentResult<()> { + bundle_dao::save_bundle_choices(&ctx.db, cluster_id, choices).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn inherit_bundle_choices( + source_cluster_id: i64, + target_cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult<()> { + bundle_dao::copy_bundle_choices(&ctx.db, source_cluster_id, target_cluster_id).await?; + Ok(()) +} + pub fn effective_enabled(file: &BundleFile, user_override: Option) -> bool { match user_override { Some(OverrideType::Removed | OverrideType::Disabled) => false, @@ -190,7 +277,7 @@ pub async fn install_bundle( .find(|a| a.manifest.name == bundle_name) .ok_or(BundleError::NotFound(bundle_name.to_string()))?; - install_enabled_bundle_files(&archive, cluster_id, skip_compatibility, None, ctx).await + install_enabled_bundle_files(&archive, cluster_id, true, skip_compatibility, None, ctx).await } /// `suppression` comes from [`find_user_suppression`] so a choice filed under a @@ -393,11 +480,14 @@ pub async fn heal_bundle_activity( pub async fn install_enabled_bundle_files( archive: &BundleArchive, cluster_id: i64, + taken: bool, skip_compatibility: bool, progress: Option<&GroupedProgressSession>, ctx: &ContentCtx, ) -> ContentResult> { - extract_bundle_overrides_for_cluster(archive, cluster_id, ctx).await?; + if taken { + extract_bundle_overrides_for_cluster(archive, cluster_id, ctx).await?; + } heal_bundle_activity(cluster_id, std::slice::from_ref(archive), ctx).await?; let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; @@ -415,8 +505,11 @@ pub async fn install_enabled_bundle_files( .iter() .filter(|file| { let package_id = file.kind.package_id(); - effective_enabled(file, find_override(&overrides, &bundle_name, &package_id)) - && !present.contains(file) + wants_file( + file, + find_override(&overrides, &bundle_name, &package_id), + taken, + ) && !present.contains(file) }) .cloned() .collect(); @@ -562,15 +655,20 @@ pub async fn enabled_bundle_bytes( .archives_for(ctx, &cluster.mc_version, loader) .await?; let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let accepted = accepted_bundles(cluster_id, ctx).await?; let present = PresentContent::load(cluster_id, ctx).await?; let mut total = 0u64; for archive in &archives { let bundle_name = &archive.manifest.name; + let taken = takes_bundle(archive, &accepted); for file in &archive.manifest.files { let package_id = file.kind.package_id(); - if !effective_enabled(file, find_override(&overrides, bundle_name, &package_id)) - || present.contains(file) + if !wants_file( + file, + find_override(&overrides, bundle_name, &package_id), + taken, + ) || present.contains(file) { continue; } @@ -693,15 +791,21 @@ pub async fn enabled_bundle_projects( .archives_for(ctx, &cluster.mc_version, loader) .await?; let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let accepted = accepted_bundles(cluster_id, ctx).await?; let mut projects = std::collections::HashSet::new(); for archive in &archives { let bundle_name = &archive.manifest.name; + let taken = takes_bundle(archive, &accepted); for file in &archive.manifest.files { let BundleFileKind::Managed { project_id, .. } = &file.kind else { continue; }; - if effective_enabled(file, find_override(&overrides, bundle_name, project_id)) { + if wants_file( + file, + find_override(&overrides, bundle_name, project_id), + taken, + ) { projects.insert(project_id.clone()); } } @@ -746,9 +850,11 @@ pub async fn install_cluster_bundles( bundles = archives.len(), "installing enabled bundle content" ); + let accepted = accepted_bundles(cluster_id, ctx).await?; for archive in &archives { + let taken = takes_bundle(archive, &accepted); let installed = - install_enabled_bundle_files(archive, cluster_id, true, progress, ctx).await?; + install_enabled_bundle_files(archive, cluster_id, taken, true, progress, ctx).await?; tracing::info!( cluster_id, bundle = %archive.manifest.name, @@ -881,8 +987,7 @@ pub async fn remove_artifact_from_cluster( // Best-effort folder cleanup failure here is not an error let deferred = match (target, link) { (Some(content_type), Some(link)) => { - try_unlink_materialized(&cluster, content_type, &link.cluster_file_name, &ctx.db) - .await + try_unlink_materialized(&cluster, content_type, &link.cluster_file_name, &ctx.db).await == LiveSync::Deferred } _ => false, diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs index 0d315a77..f4f88a0c 100644 --- a/packages/oneclient_content/src/bundles/mod.rs +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -12,11 +12,12 @@ pub use error::BundleError; pub use install::{ clusters_sharing_artifact, delete_artifact, effective_enabled, enabled_bundle_bytes, enabled_bundle_projects, extract_bundle_overrides_for_cluster, heal_bundle_activity, - install_bundle, install_cluster_bundles, install_enabled_bundle_files, + inherit_bundle_choices, 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_artifact_enabled_to, set_bundle_package_enabled, set_bundle_package_opt_in, - set_bundle_package_override, set_bundle_package_overrides, + on_user_enable_artifact, on_user_remove_artifact, pending_bundle_choices, + remove_artifact_from_cluster, set_artifact_enabled_to, set_bundle_choices, + set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, + set_bundle_package_overrides, taken_bundle_names, }; pub use manager::{Bundle, BundlesManager}; pub use manifest::BundleManifest as RemoteBundleManifest; diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 8047673e..2a8187c0 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -17,9 +17,10 @@ use tokio::sync::Mutex as AsyncMutex; use futures_util::StreamExt; use crate::bundles::install::{ - BUNDLE_INSTALL_CONCURRENCY, bundle_cluster, disable_was_deliberate, external_ids_by_sha1, - find_override, find_user_suppression, heal_bundle_activity, install_package_from_bundle, - remove_artifact_from_cluster, set_artifact_enabled_to, + BUNDLE_INSTALL_CONCURRENCY, accepted_bundles, bundle_cluster, disable_was_deliberate, + external_ids_by_sha1, find_override, find_user_suppression, heal_bundle_activity, + install_package_from_bundle, remove_artifact_from_cluster, set_artifact_enabled_to, + takes_bundle, }; use crate::bundles::manager::BundlesManager; use crate::bundles::overrides; @@ -322,6 +323,7 @@ async fn check_bundle_updates_inner( let addition_eligible_bundles = addition_eligible_bundles( ctx, + cluster_id, &archives, &bundle_packages, &all_linked, @@ -621,7 +623,8 @@ pub async fn apply_bundle_updates_with( let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); if let Ok(archives) = bundles.archives_for(ctx, &cluster.mc_version, loader).await { - for archive in archives { + let accepted = accepted_bundles(cluster_id, ctx).await?; + for archive in archives.iter().filter(|a| takes_bundle(a, &accepted)) { if let Err(err) = overrides::sync_bundle_overrides( &archive.bundle.path, &archive.manifest.name, @@ -861,6 +864,7 @@ pub async fn get_bundles_with_update_status( // Same liveness the updater uses so an untracked older install is not hidden from the list while it still takes on new files let live_bundles = addition_eligible_bundles( ctx, + cluster_id, &archives, &bundle_packages, &all_linked, @@ -1134,6 +1138,7 @@ impl<'a> TypeOptOuts<'a> { #[tracing::instrument(level = "debug", skip_all)] async fn addition_eligible_bundles( ctx: &ContentCtx, + cluster_id: i64, archives: &[BundleArchive], bundle_packages: &[BundleTrackedArtifactRow], all_linked: &[LinkedArtifactInfo], @@ -1149,6 +1154,12 @@ async fn addition_eligible_bundles( overrides_map, &live_managed_keys, )); + let accepted = accepted_bundles(cluster_id, ctx).await?; + eligible.retain(|name| { + !archives + .iter() + .any(|archive| archive.manifest.name == *name && !takes_bundle(archive, &accepted)) + }); Ok(eligible) } diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index ffdf9e34..0b95d690 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -366,13 +366,8 @@ impl PackageStore { let live = if enabled { link::try_link_materialized(&cluster, &artifact, &file_name).await } else { - link::try_unlink_materialized( - &cluster, - content_type, - &link.cluster_file_name, - &ctx.db, - ) - .await; + link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name, &ctx.db) + .await; if link.cluster_file_name != file_name { link::try_unlink_materialized(&cluster, content_type, &file_name, &ctx.db).await; } diff --git a/packages/oneclient_core/src/clusters/java_override.rs b/packages/oneclient_core/src/clusters/java_override.rs index dfefa612..6b10e40e 100644 --- a/packages/oneclient_core/src/clusters/java_override.rs +++ b/packages/oneclient_core/src/clusters/java_override.rs @@ -40,12 +40,21 @@ pub async fn apply_bundle_java_override( return Ok(()); } - let mut overrides = archives.iter().filter_map(|archive| { - archive - .manifest - .java_version_override - .map(|major| (archive.manifest.name.as_str(), major)) - }); + let taken = oneclient_content::bundles::taken_bundle_names( + cluster_id, + &archives, + &state.services.content(), + ) + .await?; + let mut overrides = archives + .iter() + .filter(|archive| taken.contains(&archive.manifest.name)) + .filter_map(|archive| { + archive + .manifest + .java_version_override + .map(|major| (archive.manifest.name.as_str(), major)) + }); let Some((bundle, major)) = overrides.next() else { return Ok(()); diff --git a/packages/oneclient_core/src/logger.rs b/packages/oneclient_core/src/logger.rs index 752ff407..c858b177 100644 --- a/packages/oneclient_core/src/logger.rs +++ b/packages/oneclient_core/src/logger.rs @@ -69,7 +69,7 @@ const UI_FLOOD_TARGETS: &[&str] = &[ "freya_core::runner", "freya_core::tree", "freya_winit::renderer", - "freya::metrics", + "freya::metrics", "ragnarok::executor", "ragnarok::nodes_state", "torin::torin", diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 4a6011ec..34334df4 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -236,10 +236,9 @@ async fn adopt_cluster( let profile = create_profile_from_global(&state.services.db, &global, folder_name, None, None).await?; - let tags = recorded.as_ref().map_or_else( - || "[]".to_string(), - |identity| encode_tags(&identity.tags), - ); + let tags = recorded + .as_ref() + .map_or_else(|| "[]".to_string(), |identity| encode_tags(&identity.tags)); let row = cluster_dao::insert( &state.services.db, diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs index f7957b06..de1a3600 100644 --- a/packages/oneclient_core/tests/bundle_updates.rs +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -1,7 +1,7 @@ use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use oneclient_content::bundles::{ BundleFile, BundleFileKind, BundleFileType, BundleManifest, check_bundle_updates, - get_bundles_with_update_status, set_bundle_package_enabled, + get_bundles_with_update_status, install_cluster_bundles, set_bundle_package_enabled, }; use oneclient_core::LauncherState; use oneclient_core::clusters::CreateClusterOptions; @@ -1293,3 +1293,127 @@ async fn the_package_list_does_not_save_the_opt_out() { .is_empty() ); } + +async fn seed_opt_in_bundle(state: &LauncherState, name: &str) { + let mut m = named_manifest(name, vec![managed_file(true)]); + m.enabled = false; + seed_bundle_with_pack(state, m).await; +} + +async fn seed_bundle_with_pack(state: &LauncherState, m: BundleManifest) { + let name = &m.name.clone(); + oneclient_core::dev::seed_bundle_archive(state, m) + .await + .unwrap(); + + let dir = oneclient_common::paths::data_dir().unwrap().join("bundles"); + std::fs::create_dir_all(&dir).unwrap(); + let mut empty_zip = b"PK\x05\x06".to_vec(); + empty_zip.extend([0u8; 18]); + std::fs::write(dir.join(format!("{name}.mrpack")), empty_zip).unwrap(); +} + +#[tokio::test] +async fn taking_a_bundle_at_the_prompt_records_the_choice() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + seed_opt_in_bundle(&state, "Opt In").await; + let cluster_id = cluster_with_tracked_mod(&state).await; + let ctx = state.services.content(); + let pending = || async { + oneclient_content::bundles::pending_bundle_choices(cluster_id, state.bundles.as_ref(), &ctx) + .await + .unwrap() + .into_iter() + .map(|archive| archive.manifest.name) + .collect::>() + }; + + assert_eq!(pending().await, ["Opt In".to_string()]); + + oneclient_content::bundles::set_bundle_choices( + cluster_id, + &[("Opt In".to_string(), false)], + &ctx, + ) + .await + .unwrap(); + assert!(pending().await.is_empty()); +} + +#[tokio::test] +async fn an_opt_in_bundle_switched_off_by_accident_is_repaired_at_install() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + seed_opt_in_bundle(&state, BUNDLE).await; + 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(); + + install_cluster_bundles( + cluster_id, + state.bundles.as_ref(), + None, + &state.services.content(), + ) + .await + .unwrap(); + + let tracked = bundle_dao::list_bundle_tracked(&state.services.db, cluster_id) + .await + .unwrap(); + assert!( + tracked.iter().all(|row| row.enabled != 0), + "nothing recorded the disable so the install should undo it" + ); +} + +#[tokio::test] +async fn an_opt_in_bundle_counts_as_taken_only_on_a_recorded_choice() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + seed_opt_in_bundle(&state, "Opt In").await; + let cluster_id = cluster_with_tracked_mod(&state).await; + let ctx = state.services.content(); + let taken = || async { + let archives = state + .bundles + .archives_for(&ctx, MC_VERSION, GameLoader::Fabric) + .await + .unwrap(); + oneclient_content::bundles::taken_bundle_names(cluster_id, &archives, &ctx) + .await + .unwrap() + }; + + assert!(!taken().await.contains("Opt In")); + + oneclient_content::bundles::set_bundle_choices( + cluster_id, + &[("Opt In".to_string(), true)], + &ctx, + ) + .await + .unwrap(); + assert!(taken().await.contains("Opt In")); +} + +#[tokio::test] +async fn holding_one_mod_of_an_opt_in_bundle_does_not_bring_the_rest() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let mut m = named_manifest("Opt In", vec![managed_file(true), fabric_api_file()]); + m.enabled = false; + seed_bundle_with_pack(&state, m).await; + let cluster_id = cluster_with_tracked_mod(&state).await; + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + check.additions_available.is_empty(), + "an opt-in bundle nobody took should add nothing" + ); +} diff --git a/packages/oneclient_db/migrations/20261005120000_cluster_bundle_choices.sql b/packages/oneclient_db/migrations/20261005120000_cluster_bundle_choices.sql new file mode 100644 index 00000000..75e5a7c0 --- /dev/null +++ b/packages/oneclient_db/migrations/20261005120000_cluster_bundle_choices.sql @@ -0,0 +1,19 @@ +CREATE TABLE cluster_bundle_choices ( + cluster_id INTEGER NOT NULL, + bundle_name TEXT NOT NULL, + accepted INTEGER NOT NULL, + PRIMARY KEY (cluster_id, bundle_name), + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE +); + +-- Opt-in bundles used to install without asking, so whatever a cluster already holds counts as taken +INSERT OR IGNORE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted) +SELECT DISTINCT cluster_id, bundle_name, 1 +FROM cluster_artifacts +WHERE bundle_name IS NOT NULL; + +-- A bundle setup recorded removals for and that installed nothing was turned down at setup +INSERT OR IGNORE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted) +SELECT DISTINCT cluster_id, bundle_name, 0 +FROM cluster_bundle_overrides +WHERE override_type = 'removed'; diff --git a/packages/oneclient_db/src/dao/cluster_bundle.rs b/packages/oneclient_db/src/dao/cluster_bundle.rs index 12309acd..b5b12771 100644 --- a/packages/oneclient_db/src/dao/cluster_bundle.rs +++ b/packages/oneclient_db/src/dao/cluster_bundle.rs @@ -298,3 +298,65 @@ pub async fn set_type_opt_out( } Ok(()) } + +pub async fn list_bundle_choices( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#" + SELECT bundle_name, accepted + FROM cluster_bundle_choices + WHERE cluster_id = ? + "#, + cluster_id + ) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| (r.bundle_name, r.accepted != 0)) + .collect()) +} + +pub async fn save_bundle_choices( + pool: &SqlitePool, + cluster_id: i64, + choices: &[(String, bool)], +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + for (bundle_name, accepted) in choices { + sqlx::query!( + r#" + INSERT OR REPLACE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted) + VALUES (?, ?, ?) + "#, + cluster_id, + bundle_name, + accepted + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +pub async fn copy_bundle_choices( + pool: &SqlitePool, + source_cluster_id: i64, + target_cluster_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT OR IGNORE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted) + SELECT ?, bundle_name, accepted + FROM cluster_bundle_choices + WHERE cluster_id = ? + "#, + target_cluster_id, + source_cluster_id + ) + .execute(pool) + .await?; + Ok(()) +}