fix opt-in bundles not being asked for new mc versions (#939)

This commit is contained in:
Julian Chang 2026-10-05 17:19:49 -04:00 • committed by GitHub
parent 7683651ad4
commit 1ab294f293
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 795 additions and 98 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<std::collections::HashSet<String>>) {
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<oneclient_core::LauncherState>,
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<String> = 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<oneclient_core::LauncherState>,
@ -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;

View file

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

View file

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

View file

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

View file

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

View file

@ -243,8 +243,8 @@ fn main() {
.and_then(|v| v.parse::<usize>().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() {

View file

@ -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<PendingPromptView>,
pub cluster_update: Option<Vec<ClusterUpdateSummary>>,
pub optional_mods: Option<Vec<OptionalModsGroup>>,
pub bundle_choices: Option<BundleChoices>,
pub package_updates: Option<Vec<PackageUpdateGroup>>,
pub blocked_downloads: Option<BlockedDownloads>,
pub open_cluster: Option<ClusterId>,
@ -274,6 +281,7 @@ pub struct NotificationState {
pending_timers: Vec<ToastDismissTimer>,
cluster_update: Option<Vec<ClusterUpdateSummary>>,
optional_mods: Option<Vec<OptionalModsGroup>>,
bundle_choices: Option<BundleChoices>,
package_updates: Option<Vec<PackageUpdateGroup>>,
blocked_downloads: Option<BlockedDownloads>,
open_cluster: Option<ClusterId>,
@ -283,6 +291,7 @@ pub struct NotificationState {
/// because every way the modal can end goes through this state
package_updates_done: Option<oneshot::Sender<Vec<BrowserPackageUpdate>>>,
optional_mods_done: Option<oneshot::Sender<OptionalModsOutcome>>,
bundle_choices_done: Option<oneshot::Sender<Option<HashSet<String>>>>,
}
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<Option<HashSet<String>>>,
) {
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<HashSet<String>>) {
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]

View file

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

View file

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

View file

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

View file

@ -101,7 +101,12 @@ pub fn shell(parts: Shell) -> Element {
.into_element()
}
fn header(eyebrow: Option<String>, title: String, subtitle: String, on_close: EventHandler<()>) -> Element {
fn header(
eyebrow: Option<String>,
title: String,
subtitle: String,
on_close: EventHandler<()>,
) -> Element {
rect()
.horizontal()
.width(Size::fill())

View file

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

View file

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

View file

@ -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<String>,
) -> bool {
archive.manifest.enabled || accepted.contains(&archive.manifest.name)
}
fn wants_file(file: &BundleFile, user_override: Option<OverrideType>, 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<std::collections::HashSet<String>> {
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<std::collections::HashSet<String>> {
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<Vec<BundleArchive>> {
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<String> =
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<OverrideType>) -> 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<Vec<String>> {
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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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::<Vec<_>>()
};
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"
);
}

View file

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

View file

@ -298,3 +298,65 @@ pub async fn set_type_opt_out(
}
Ok(())
}
pub async fn list_bundle_choices(
pool: &SqlitePool,
cluster_id: i64,
) -> Result<Vec<(String, bool)>, 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(())
}