From 76d929b82a7c41f18a16962514e9fd04194a0e99 Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:41:55 +0200 Subject: [PATCH 1/8] feat: added warning on clicking cosmetics button (#899) --- .../src/components/link_confirm.rs | 25 +++++++++++++++++-- .../src/components/navbar/app_navbar.rs | 6 +++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/oneclient_app/src/components/link_confirm.rs b/packages/oneclient_app/src/components/link_confirm.rs index eb5d64b6..9b882e61 100644 --- a/packages/oneclient_app/src/components/link_confirm.rs +++ b/packages/oneclient_app/src/components/link_confirm.rs @@ -18,6 +18,17 @@ impl Component for ConfirmLinkOverlay { }; let open_url = url.clone(); + let (title, body) = if is_first_party(&url) { + ( + "Leave the launcher?", + "This will open the page in your web browser.", + ) + } else { + ( + "Open external link?", + "This link was provided by a third party. Only open it if you trust the source.", + ) + }; OverlayPopup::new() .overlay_level(OVERLAY_BASE_LEVEL + 3) @@ -49,7 +60,7 @@ impl Component for ConfirmLinkOverlay { ) .child( label() - .text("Open external link?") + .text(title) .font_size(16.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), @@ -57,7 +68,7 @@ impl Component for ConfirmLinkOverlay { ) .child( label() - .text("This link was provided by a third party. Only open it if you trust the source.") + .text(body) .font_size(12.) .color(colors::fg_secondary()), ) @@ -105,3 +116,13 @@ impl Component for ConfirmLinkOverlay { .into_element() } } + +fn is_first_party(url: &str) -> bool { + let host = url + .split_once("://") + .map_or(url, |(_, rest)| rest) + .split(['/', '?', '#', ':']) + .next() + .unwrap_or_default(); + host == "polyfrost.org" || host.ends_with(".polyfrost.org") +} diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 4b1180a3..0b9e6746 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -9,7 +9,8 @@ use crate::{ components::{Avatar, Icon, IconType}, hooks::{ settled_or_loading, try_default_account, use_active_cluster_id, use_browser_type, - use_clusters, use_current_account, use_dispatch, use_notifications_snapshot, + use_clusters, use_current_account, use_dispatch, use_link_confirm, + use_notifications_snapshot, }, theme, utils::default_cluster, @@ -185,6 +186,7 @@ impl Component for NavLink { let mut hovering = use_state(|| false); let a11y_id = use_a11y(); let focused = use_focus(a11y_id); + let mut confirm_link = use_link_confirm(); let active = self.active; let target = self.target.clone(); @@ -229,7 +231,7 @@ impl Component for NavLink { NavTarget::Route(route) => { let _ = RouterContext::get().push(route.clone()); } - NavTarget::External(url) => crate::platform::open_url(url), + NavTarget::External(url) => confirm_link.set(Some((*url).to_string())), } }) .on_pointer_over(move |_| hovering.set(true)) From 959458abde0cddd70d83422708efe2ae2a81399c Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:12:53 +0200 Subject: [PATCH 2/8] chore: added enabling disabled bundled mods from browser (#900) --- packages/oneclient_app/src/components/mod.rs | 2 +- .../src/components/package_row.rs | 38 ++- .../src/view/app/browser/index/cards.rs | 55 ++-- .../src/view/app/browser/index/mod.rs | 16 +- .../oneclient_app/src/view/app/browser/mod.rs | 241 +++++++++++++++++- .../src/view/app/browser/package/mod.rs | 14 +- .../src/view/app/browser/package/panels.rs | 23 +- .../src/view/app/browser/package/sidebar.rs | 18 +- 8 files changed, 353 insertions(+), 54 deletions(-) diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 2317c6b6..64b1c8c5 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -98,7 +98,7 @@ pub use package_delete_dialog::use_shared_delete; pub(crate) use package_row::{ CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, GRID_GAP, GRID_MIN_W, badge, disable_warning_body, grid_card, icon_box, kebab_button, meta_size, meta_text, on_secondary, package_icon, pill, - remote_icon, toggle_action, + remote_icon, set_enabled_action, toggle_action, }; pub use package_row::{ CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge, diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 667ec14a..73d8642b 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -245,22 +245,38 @@ pub(crate) fn toggle_action( cluster_id: i64, enabled: bool, ) -> Option { - if let Some(hash) = &item.hash { + set_enabled_action( + cluster_id, + item.hash.as_deref(), + item.bundle_name.as_deref(), + &item.package_id, + item.manifest_default, + enabled, + ) +} + +pub(crate) fn set_enabled_action( + cluster_id: i64, + hash: Option<&str>, + bundle_name: Option<&str>, + package_id: &str, + manifest_default: bool, + enabled: bool, +) -> Option { + if let Some(hash) = hash { Some(ClusterAction::SetArtifactEnabled { cluster_id, - hash: hash.clone(), + hash: hash.to_string(), enabled, }) } else { - item.bundle_name - .clone() - .map(|bundle_name| ClusterAction::SetBundlePackageEnabled { - cluster_id, - bundle_name, - package_id: item.package_id.clone(), - enabled, - manifest_default: item.manifest_default, - }) + bundle_name.map(|bundle_name| ClusterAction::SetBundlePackageEnabled { + cluster_id, + bundle_name: bundle_name.to_string(), + package_id: package_id.to_string(), + enabled, + manifest_default, + }) } } diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index 609b817f..9108a197 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -9,8 +9,8 @@ use oneclient_core::clusters::ModpackSource; use crate::components::{Button, Icon, IconType}; use crate::hooks::{ - content_type_for_slug, use_browser_compat, use_cluster, use_dispatch, use_installs_snapshot, - use_package_versions_when, version_list, + ClusterAction, content_type_for_slug, use_browser_compat, use_cluster, use_dispatch, + use_installs_snapshot, use_package_versions_when, version_list, }; use crate::routes::Route; use crate::theme::colors; @@ -21,10 +21,25 @@ type InstalledMap = HashMap<(ProviderId, String), Installed>; /// Height of the install control, matched by the installed pill that replaces it const INSTALL_BUTTON_H: f32 = 28.; -fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option { +#[derive(Clone, PartialEq)] +struct CardInstalled { + source: InstallSource, + enable: Option, +} + +fn installed_for( + installed: &InstalledMap, + item: &ProjectSummary, + cluster_id: i64, +) -> Option { installed .get(&(item.provider, item.id.clone())) - .map(|installed| installed.source) + .map(|installed| CardInstalled { + source: installed.source, + enable: installed + .disabled_bundled() + .and_then(|version| version.enable_action(cluster_id)), + }) } pub(super) fn grid_row( @@ -36,9 +51,9 @@ pub(super) fn grid_row( ) -> impl IntoElement { let package_type = package_type.to_string(); let fill = cols.saturating_sub(row.len()); - let installed: Vec> = row + let installed: Vec> = row .iter() - .map(|item| installed_for(installed, item)) + .map(|item| installed_for(installed, item, cluster_id)) .collect(); rect() @@ -77,7 +92,7 @@ struct PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, } impl PackageCard { @@ -85,7 +100,7 @@ impl PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, ) -> Self { Self { item, @@ -202,7 +217,7 @@ impl Component for PackageCard { &self.item, self.cluster_id, &self.package_type, - self.installed, + self.installed.clone(), )), ) } @@ -215,7 +230,7 @@ pub(super) fn list_row( installed: &InstalledMap, ) -> impl IntoElement { ListRow { - installed: installed_for(installed, &item), + installed: installed_for(installed, &item, cluster_id), item, cluster_id, package_type: package_type.to_string(), @@ -227,7 +242,7 @@ struct ListRow { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, } impl Component for ListRow { @@ -308,7 +323,7 @@ impl Component for ListRow { &self.item, self.cluster_id, &self.package_type, - self.installed, + self.installed.clone(), )) } } @@ -320,7 +335,7 @@ struct InstallButton { cluster_id: i64, content_type: ContentType, /// Cluster already has this one, so the control becomes a static pill - installed: Option, + installed: Option, } impl InstallButton { @@ -328,7 +343,7 @@ impl InstallButton { item: &ProjectSummary, cluster_id: i64, package_type: &str, - installed: Option, + installed: Option, ) -> Self { Self { provider: item.provider, @@ -376,7 +391,17 @@ impl Component for InstallButton { let (installing, waiting) = use_installs_snapshot().package_busy(is_modpack, cluster_id, provider, &project_id); - if let Some(installed) = self.installed { + if let Some(installed) = &self.installed { + if let Some(action) = installed.enable.clone() { + return EnableButton { + action, + variant: EnableVariant::Card { + height: INSTALL_BUTTON_H, + }, + } + .into_element(); + } + let installed = installed.source; let color = installed.color(); return rect() .horizontal() 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 558d7235..c6ef0b51 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -19,11 +19,12 @@ use crate::components::{ }; use crate::hooks::use_cluster; use crate::hooks::{ - BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list, - cluster_content_items, content_type_for_slug, pick_version_metadata, search_items, - search_pending, search_total, settled_or_loading, use_browser_compat, use_browser_state_store, - use_browser_type, use_bundles_with_status, use_cluster_content, use_clusters, use_debounced, - use_package_categories, use_package_search, use_versions, use_view_state, versions_metadata, + BROWSE_PAGE_SIZE, BrowserUiState, bundle_overrides_map, bundles_with_status_items, + category_list, cluster_content_items, content_type_for_slug, pick_version_metadata, + search_items, search_pending, search_total, settled_or_loading, use_browser_compat, + use_browser_state_store, use_browser_type, use_bundle_overrides, use_bundles_with_status, + use_cluster_content, use_clusters, use_debounced, use_package_categories, use_package_search, + use_versions, use_view_state, versions_metadata, }; use crate::routes::Route; use crate::theme::colors; @@ -31,8 +32,8 @@ use crate::ui::grid_columns_for_width; use crate::view::app::cluster::supports_datapacks; use super::{ - InstallSource, Installed, PackageBanner, Thumbnail, WorldInstallPrompt, installed_map, - preferred_version, + EnableButton, EnableVariant, InstallSource, Installed, PackageBanner, Thumbnail, + WorldInstallPrompt, installed_map, preferred_version, }; use crate::utils::{abbreviate_number, sort_clusters_for_home}; @@ -250,6 +251,7 @@ impl Component for BrowserBody { let installed = installed_map( cluster_content_items(&use_cluster_content(cluster_id, content_type)), &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + &bundle_overrides_map(&use_bundle_overrides(cluster_id)), ); let installed = if matches!(content_type, ContentType::DataPack | ContentType::Modpack) { Default::default() diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 54c4830e..682ec09d 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -28,9 +28,12 @@ use std::collections::HashMap; use freya::prelude::*; use oneclient_content::packages::ProviderId; use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; +use oneclient_db::models::OverrideType; -use crate::components::{Icon, IconType}; -use crate::hooks::{loaded_image, use_cached_image}; +use crate::components::{Button, Icon, IconType, set_enabled_action}; +use crate::hooks::{ + ClusterAction, loaded_image, mutation_is_running, use_cached_image, use_cluster_mutation, +}; use crate::theme::colors; use crate::ui::{ImageFallbackExt, border_all_color}; @@ -59,15 +62,36 @@ impl InstallSource { } } +#[derive(Clone, PartialEq)] +pub(crate) struct BundlePin { + pub bundle_name: String, + pub package_id: String, + pub manifest_default: bool, +} + #[derive(Clone, PartialEq)] pub(crate) struct InstalledVersion { pub version_id: String, /// The artifact to remove /// `None` when a bundle names this version but nothing is linked pub hash: Option, - /// Only meaningful alongside a `hash` a bundle pin with nothing linked is neither on nor off pub enabled: bool, pub source: InstallSource, + pub bundle: Option, +} + +impl InstalledVersion { + pub fn enable_action(&self, cluster_id: i64) -> Option { + let pin = self.bundle.as_ref()?; + set_enabled_action( + cluster_id, + self.hash.as_deref(), + Some(&pin.bundle_name), + &pin.package_id, + pin.manifest_default, + true, + ) + } } /// Holds every version found rather than an arbitrary winner a cluster can end up with more than one @@ -91,12 +115,20 @@ impl Installed { pub fn is_duplicated(&self) -> bool { self.versions.iter().filter(|v| v.hash.is_some()).count() > 1 } + + pub fn disabled_bundled(&self) -> Option<&InstalledVersion> { + if self.versions.iter().any(|v| v.enabled) { + return None; + } + self.versions.iter().find(|v| v.bundle.is_some()) + } } /// Local files and a bundle's external files are left out they have no project id to match against pub(crate) fn installed_map( content: Vec, bundles: &[BundleWithUpdateStatus], + overrides: &HashMap<(String, String), String>, ) -> HashMap<(ProviderId, String), Installed> { let mut map: HashMap<(ProviderId, String), Installed> = HashMap::new(); @@ -117,6 +149,7 @@ pub(crate) fn installed_map( hash: Some(item.hash), enabled: item.enabled, source: InstallSource::Manual, + bundle: None, }); } } @@ -124,6 +157,7 @@ pub(crate) fn installed_map( // Bundle membership wins a bundle's files would otherwise read as hand-installed // The manifest pin is only a fallback for a missing linked version for bundle in bundles { + let bundle_name = &bundle.archive.manifest.name; for (file, _status) in &bundle.files { if let BundleFileKind::Managed { provider, @@ -132,18 +166,34 @@ pub(crate) fn installed_map( .. } = &file.kind { + let pin = BundlePin { + bundle_name: bundle_name.clone(), + package_id: file.kind.package_id(), + manifest_default: file.enabled, + }; match map.get_mut(&(*provider, project_id.clone())) { Some(installed) => { installed.source = InstallSource::Bundled; - if let Some(version) = installed + let only_copy = installed.versions.len() == 1; + match installed .versions .iter_mut() .find(|v| &v.version_id == version_id) { - version.source = InstallSource::Bundled; + Some(version) => { + version.source = InstallSource::Bundled; + version.bundle.get_or_insert(pin); + } + None if only_copy => { + installed.versions[0].bundle.get_or_insert(pin); + } + None => {} } } None => { + let user_override = overrides + .get(&(bundle_name.clone(), pin.package_id.clone())) + .and_then(|o| OverrideType::parse(o)); map.insert( (*provider, project_id.clone()), Installed { @@ -151,8 +201,9 @@ pub(crate) fn installed_map( versions: vec![InstalledVersion { version_id: version_id.clone(), hash: None, - enabled: false, + enabled: oneclient_core::effective_enabled(file, user_override), source: InstallSource::Bundled, + bundle: Some(pin), }], }, ); @@ -165,6 +216,68 @@ pub(crate) fn installed_map( map } +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum EnableVariant { + Sidebar, + Card { height: f32 }, + VersionRow, +} + +#[derive(PartialEq)] +pub(crate) struct EnableButton { + pub action: ClusterAction, + pub variant: EnableVariant, +} + +impl Component for EnableButton { + fn render(&self) -> impl IntoElement { + let mutation = use_cluster_mutation(); + let running = mutation_is_running(&mutation); + let action = self.action.clone(); + + let button = Button::new() + .enabled(!running) + .on_press(move |_| mutation.mutate(action.clone())); + + match self.variant { + EnableVariant::Sidebar => button + .primary() + .width(Size::fill()) + .child(Icon::new(IconType::CheckCircle).size(14.)) + .text(if running { "Enabling..." } else { "Enable" }) + .into_element(), + EnableVariant::Card { height } => rect() + .on_press(|e: Event| e.stop_propagation()) + .child( + button + .primary() + .small() + .height(Size::px(height)) + .padding(Gaps::new_symmetric(0., 11.)) + .child( + Icon::new(if running { + IconType::Loading02 + } else { + IconType::CheckCircle + }) + .size(12.) + .color(colors::fg_primary()), + ) + .child( + label() + .text(if running { "Enabling" } else { "Enable" }) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ), + ) + .into_element(), + EnableVariant::VersionRow => button.secondary().small().text("Enable").into_element(), + } + } +} + pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement { badge(installed, font_size, installed.color().with_a(38), None) } @@ -450,6 +563,30 @@ mod tests { }] } + fn installed_map( + content: Vec, + bundles: &[BundleWithUpdateStatus], + ) -> HashMap<(ProviderId, String), Installed> { + super::installed_map(content, bundles, &HashMap::new()) + } + + fn optional(project_id: &str, version_id: &str) -> BundleFile { + BundleFile { + enabled: false, + ..managed(project_id, version_id) + } + } + + fn overridden( + project_id: &str, + override_type: OverrideType, + ) -> HashMap<(String, String), String> { + HashMap::from([( + ("performance".to_string(), project_id.to_string()), + override_type.as_str().to_string(), + )]) + } + fn entry(map: &HashMap<(ProviderId, String), Installed>, project: &str) -> Installed { map.get(&(ProviderId::Modrinth, project.to_string())) .expect("project missing from the map") @@ -578,4 +715,96 @@ mod tests { ); assert_eq!(sodium.source, InstallSource::Manual); } + + #[test] + fn an_optional_mod_never_downloaded_offers_enable_through_the_bundle() { + let map = installed_map(Vec::new(), &bundles(vec![optional("sodium", "v1")])); + + let sodium = entry(&map, "sodium"); + let disabled = sodium.disabled_bundled().expect("offers enable"); + assert!(matches!( + disabled.enable_action(7), + Some(ClusterAction::SetBundlePackageEnabled { + cluster_id: 7, + enabled: true, + manifest_default: false, + .. + }) + )); + } + + #[test] + fn an_enabled_pin_waiting_for_sync_does_not_offer_enable() { + let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")])); + + assert!(entry(&map, "sodium").disabled_bundled().is_none()); + } + + #[test] + fn a_disabled_override_on_an_unlinked_pin_offers_enable() { + let map = super::installed_map( + Vec::new(), + &bundles(vec![managed("sodium", "v1")]), + &overridden("sodium", OverrideType::Disabled), + ); + + let sodium = entry(&map, "sodium"); + assert!(matches!( + sodium.disabled_bundled().and_then(|v| v.enable_action(7)), + Some(ClusterAction::SetBundlePackageEnabled { + manifest_default: true, + .. + }) + )); + } + + #[test] + fn a_disabled_linked_bundle_artifact_is_enabled_by_hash() { + let map = installed_map( + vec![disabled_linked("sodium", Some("v1"), "hash-1", false)], + &bundles(vec![managed("sodium", "v1")]), + ); + + let sodium = entry(&map, "sodium"); + assert!(matches!( + sodium.disabled_bundled().and_then(|v| v.enable_action(7)), + Some(ClusterAction::SetArtifactEnabled { ref hash, enabled: true, .. }) if hash == "hash-1" + )); + } + + #[test] + fn a_disabled_copy_from_an_older_bundle_pin_still_offers_enable() { + let map = installed_map( + vec![disabled_linked("sodium", Some("v1"), "hash-1", false)], + &bundles(vec![managed("sodium", "v2")]), + ); + + assert!(entry(&map, "sodium").disabled_bundled().is_some()); + } + + #[test] + fn another_enabled_copy_hides_enable() { + let map = installed_map( + vec![ + disabled_linked("sodium", Some("v1"), "hash-1", false), + linked("sodium", Some("v2"), "hash-2"), + ], + &bundles(vec![managed("sodium", "v1")]), + ); + + assert!( + entry(&map, "sodium").disabled_bundled().is_none(), + "enabling the bundle copy would load two" + ); + } + + #[test] + fn a_hand_installed_disabled_mod_is_not_offered_enable() { + let map = installed_map( + vec![disabled_linked("sodium", Some("v1"), "hash-1", false)], + &[], + ); + + assert!(entry(&map, "sodium").disabled_bundled().is_none()); + } } 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 48ed42cf..8b82534c 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -8,17 +8,18 @@ use oneclient_core::clusters::ModpackSource; use crate::components::{ScrollArea, use_shared_delete}; use crate::hooks::use_cluster; use crate::hooks::{ - bundles_with_status_items, cluster_content_items, content_type_for_slug, package_meta_batch, - project_detail, use_browser_compat, 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, version_list, versions_total, + bundle_overrides_map, bundles_with_status_items, 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, + version_list, versions_total, }; use crate::theme::colors; use crate::ui::border_all_color; use super::{ - Installed, InstalledVersion, PackageBanner, Thumbnail, WorldInstallPrompt, activity_badge, - installed_badge, installed_map, preferred_version, + EnableButton, EnableVariant, Installed, InstalledVersion, PackageBanner, Thumbnail, + WorldInstallPrompt, activity_badge, installed_badge, installed_map, preferred_version, }; use crate::utils::abbreviate_number; @@ -196,6 +197,7 @@ impl Component for BrowserPackage { let installed = installed_map( cluster_content_items(&use_cluster_content(cluster_id, content_type)), &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + &bundle_overrides_map(&use_bundle_overrides(cluster_id)), ) .remove(&(provider, project_id.clone())) .filter(|_| !is_datapack); diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index 38478964..95984ef4 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -8,7 +8,7 @@ use oneclient_content::packages::types::{ use crate::components::{ Button, Icon, IconType, Markdown, MarkdownStyle, Segment, SegmentedControl, }; -use crate::hooks::VERSIONS_PAGE_SIZE; +use crate::hooks::{ClusterAction, VERSIONS_PAGE_SIZE}; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::format_size; @@ -142,6 +142,11 @@ pub(super) fn versions_panel( let duplicated = installed .as_ref() .is_some_and(|installed| installed.is_duplicated()); + let enable = installed + .as_ref() + .and_then(Installed::disabled_bundled) + .filter(|disabled| disabled.version_id == v.version_id) + .and_then(|disabled| disabled.enable_action(installer.cluster_id)); version_row( v, &dependency_names, @@ -151,6 +156,7 @@ pub(super) fn versions_panel( tag, duplicated, installing, + enable, ) .into_element() })) @@ -226,6 +232,7 @@ fn version_row( // Saying which version is live only tells the user anything when there are several duplicated: bool, installing: bool, + enable: Option, ) -> impl IntoElement { let version_id = v.version_id.clone(); let mut chips: Vec = v.loaders.iter().map(|l| l.to_string()).collect(); @@ -300,9 +307,17 @@ fn version_row( .filter(|_| duplicated) .map(|installed| activity_badge(installed.enabled).into_element()), ) - .child(version_button( - installed, v.name, project_id, version_id, installer, on_remove, installing, - )) + .child(match enable { + Some(action) => EnableButton { + action, + variant: EnableVariant::VersionRow, + } + .into_element(), + None => version_button( + installed, v.name, project_id, version_id, installer, on_remove, installing, + ) + .into_element(), + }) } /// A bundle pin with nothing linked leaves nothing to press no artifact to remove and installing by hand would duplicate it diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 72b4a706..0eb26751 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -30,6 +30,10 @@ pub(super) fn sidebar( _ => false, }; let can_install = latest_version.is_some() && !have_latest && !installing && !waiting; + let enable = installed + .as_ref() + .and_then(Installed::disabled_bundled) + .and_then(|version| version.enable_action(installer.cluster_id)); rect() .vertical() @@ -101,8 +105,13 @@ pub(super) fn sidebar( ), ), ) - .child( - Button::new() + .child(match enable { + Some(action) => EnableButton { + action, + variant: EnableVariant::Sidebar, + } + .into_element(), + None => Button::new() .primary() .width(Size::fill()) .enabled(can_install) @@ -118,8 +127,9 @@ pub(super) fn sidebar( "Latest installed" } else { "Install latest" - }), - ) + }) + .into_element(), + }) .maybe( !project.members.is_empty() || !project.author.is_empty(), |el| el.child(authors_card(&project, confirm)), From 30f0e1843e374c302d189fa5328e69d21efe679a Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:42:25 +0200 Subject: [PATCH 3/8] chore: add JVM argument to improve vector operations on Java 21+ (#901) --- packages/oneclient_mc/src/arguments.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/oneclient_mc/src/arguments.rs b/packages/oneclient_mc/src/arguments.rs index 1e7936cb..5b91da4c 100644 --- a/packages/oneclient_mc/src/arguments.rs +++ b/packages/oneclient_mc/src/arguments.rs @@ -64,6 +64,10 @@ pub fn java_arguments( parsed.extend(performance_flags(java_major, java_arch, mem_max, &custom)); + if java_major >= 21 { + parsed.push("--add-modules=jdk.incubator.vector".to_string()); + } + parsed.push(format!("-Xmx{mem_max}M")); parsed.extend(custom); @@ -893,7 +897,7 @@ mod tests { let cp = classpaths(&dir, &libraries, &client, "x86", false).unwrap(); assert!( - !cp.contains("asm-all"), + !cp.contains("asm-all-4.1.jar"), "fabric aborts on two copies of org/objectweb/asm/ClassReader.class: {cp}" ); assert!(cp.contains("asm-9.10.1.jar"), "{cp}"); From 7bc9064469bc6caa131303b56a882e5dc9f0c40c Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:45:37 +0200 Subject: [PATCH 4/8] fix: libxdo crash in deb packages (#902) --- packages/oneclient_app/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 3d0ce73c..bb1ebf18 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -153,6 +153,7 @@ SectionEnd [package.metadata.packager.deb] desktop-template = "distribution/linux/org.polyfrost.oneclient.template" +depends = ["libxdo3"] [package.metadata.packager.appimage] # desktop entry generated from the deb template above via the linux config From 6f415d65e0603530155f1025f52af9ad6bd7038c Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:03:50 +0200 Subject: [PATCH 5/8] fix: graphics drivers issues on linux in appimage (#903) --- packages/oneclient_app/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index bb1ebf18..3b6d792c 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -158,6 +158,7 @@ depends = ["libxdo3"] [package.metadata.packager.appimage] # desktop entry generated from the deb template above via the linux config libs = ["libayatana-appindicator3.so.1"] +excluded-libraries = ["libwayland-*"] [package.metadata.generate-rpm] auto-req = "builtin" From e74eb60ba24fd1e5a7957099f02dc84817151a8e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:04:41 +0000 Subject: [PATCH 6/8] chore: bump to 2.6.0 --- Cargo.lock | 30 +++++++++++++++--------------- Cargo.toml | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ca01562..10f1a8cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4934,7 +4934,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneclient_app" -version = "2.5.1" +version = "2.6.0" dependencies = [ "anyhow", "arboard", @@ -4982,7 +4982,7 @@ dependencies = [ [[package]] name = "oneclient_auth" -version = "2.5.1" +version = "2.6.0" dependencies = [ "base64 0.23.1", "chrono", @@ -5006,7 +5006,7 @@ dependencies = [ [[package]] name = "oneclient_cluster" -version = "2.5.1" +version = "2.6.0" dependencies = [ "bytes 1.12.1", "chrono", @@ -5029,7 +5029,7 @@ dependencies = [ [[package]] name = "oneclient_common" -version = "2.5.1" +version = "2.6.0" dependencies = [ "directories", "interfrost", @@ -5042,7 +5042,7 @@ dependencies = [ [[package]] name = "oneclient_content" -version = "2.5.1" +version = "2.6.0" dependencies = [ "async-trait", "async_zip", @@ -5070,7 +5070,7 @@ dependencies = [ [[package]] name = "oneclient_core" -version = "2.5.1" +version = "2.6.0" dependencies = [ "async-compression", "async-stream", @@ -5135,7 +5135,7 @@ dependencies = [ [[package]] name = "oneclient_db" -version = "2.5.1" +version = "2.6.0" dependencies = [ "async-tempfile", "chrono", @@ -5150,7 +5150,7 @@ dependencies = [ [[package]] name = "oneclient_discord" -version = "2.5.1" +version = "2.6.0" dependencies = [ "chrono", "discord-rich-presence", @@ -5162,7 +5162,7 @@ dependencies = [ [[package]] name = "oneclient_events" -version = "2.5.1" +version = "2.6.0" dependencies = [ "thiserror 2.0.21", "tokio", @@ -5172,7 +5172,7 @@ dependencies = [ [[package]] name = "oneclient_java" -version = "2.5.1" +version = "2.6.0" dependencies = [ "astral-tokio-tar", "async-tempfile", @@ -5195,7 +5195,7 @@ dependencies = [ [[package]] name = "oneclient_macro" -version = "2.5.1" +version = "2.6.0" dependencies = [ "proc-macro2", "quote", @@ -5204,7 +5204,7 @@ dependencies = [ [[package]] name = "oneclient_mc" -version = "2.5.1" +version = "2.6.0" dependencies = [ "base64 0.23.1", "chrono", @@ -5226,7 +5226,7 @@ dependencies = [ [[package]] name = "oneclient_net" -version = "2.5.1" +version = "2.6.0" dependencies = [ "arc-swap", "async-trait", @@ -5248,7 +5248,7 @@ dependencies = [ [[package]] name = "oneclient_polyplus" -version = "2.5.1" +version = "2.6.0" dependencies = [ "async-tungstenite 0.35.0", "bytes 1.12.1", @@ -5591,7 +5591,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "polyio" -version = "2.5.1" +version = "2.6.0" dependencies = [ "astral-tokio-tar", "async-compression", diff --git a/Cargo.toml b/Cargo.toml index 9caa01a6..2f0eb6bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ license = "GPL-3.0-only" readme = "README.md" repository = "https://github.com/Polyfrost/OneLauncher" rust-version = "1.97" -version = "2.5.1" +version = "2.6.0" [workspace.dependencies] # core From 7402fb6e78d8f7ef15c1a1ff9238dca325897147 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Fri, 2 Oct 2026 00:07:44 -0400 Subject: [PATCH 7/8] exclude opted out bundles from package list (#879) * exclude opted out bundles from package list * count inferred bundle installs as opted in * let opted-in bundles own shared rows --- .../src/components/optional_mods_popup.rs | 1 + .../src/components/package_row.rs | 2 + .../oneclient_app/src/view/app/browser/mod.rs | 1 + .../view/app/cluster/package_manager/mod.rs | 72 ++++++++++++++++++- .../oneclient_content/src/bundles/types.rs | 1 + .../oneclient_content/src/bundles/updates.rs | 40 +++++++---- .../oneclient_core/tests/bundle_updates.rs | 29 ++++++++ 7 files changed, 131 insertions(+), 15 deletions(-) diff --git a/packages/oneclient_app/src/components/optional_mods_popup.rs b/packages/oneclient_app/src/components/optional_mods_popup.rs index c830623b..22ac00ef 100644 --- a/packages/oneclient_app/src/components/optional_mods_popup.rs +++ b/packages/oneclient_app/src/components/optional_mods_popup.rs @@ -126,6 +126,7 @@ fn entry_from_item( hash: None, manifest_default: false, hidden: false, + opted_in: true, advanced: false, update_available: false, // Offered, not installed, so there is no recency badge to show diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 73d8642b..e7da4bf3 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -60,6 +60,8 @@ pub struct PackageEntry { pub manifest_default: bool, /// Private bundle dependency only set for bundle rows pub hidden: bool, + /// False for uninstalled rows of bundles the cluster never took keeps them out of the All tab + pub opted_in: bool, pub advanced: bool, /// Only set for browser-installed content bundle packages use the bundle update flow pub update_available: bool, diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 682ec09d..fa233755 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -560,6 +560,7 @@ mod tests { .collect(), archive: archive("performance", true, files), has_updates: false, + opted_in: true, }] } 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 53df41f8..2848101e 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 @@ -144,10 +144,13 @@ pub fn bundle_packages( } } + let mut ordered: Vec<&BundleWithUpdateStatus> = bundles.iter().collect(); + ordered.sort_by_key(|b| !b.opted_in); + let mut rows = Vec::new(); let mut seen: HashSet = HashSet::new(); - for bundle in bundles { + for bundle in ordered { let bundle_name = &bundle.archive.manifest.name; let category = bundle.archive.manifest.category.clone(); for (file, status) in &bundle.files { @@ -199,6 +202,7 @@ pub fn bundle_packages( vec![category.clone()] }; + let opted_in = installed_info.is_some() || bundle.opted_in; let mut row = make_row( pid, Some(bundle_name.clone()), @@ -214,6 +218,7 @@ pub fn bundle_packages( false, // Flagged rather than dropped `HiddenFilter` filters on the row and the seen id stops the loose-content pass resurrecting it as a local file file.hidden, + opted_in, ); row.advanced = advanced; rows.push(row); @@ -246,6 +251,7 @@ pub fn bundle_packages( .unwrap_or_else(|| info.file_name.clone()), outdated, false, + true, )); } @@ -267,6 +273,7 @@ fn make_row( fallback_name: String, update_available: bool, hidden: bool, + opted_in: bool, ) -> PackageEntry { let name = m .map(|p| p.name.clone()) @@ -305,6 +312,7 @@ fn make_row( hash: installed_info.map(|i| i.hash.clone()), update_available, hidden, + opted_in, advanced: false, seen_status: installed_info.map(|i| i.seen_status).unwrap_or_default(), } @@ -330,7 +338,7 @@ impl Tab { pub(super) fn matches(&self, p: &PackageEntry) -> bool { match self { - Tab::All => true, + Tab::All => p.opted_in, Tab::Category(c) => p.categories.iter().any(|pc| pc == c), Tab::Browser => (p.is_remote() || p.github_hosted) && !p.in_bundle(), Tab::Local => !p.is_remote() && !p.github_hosted, @@ -687,3 +695,63 @@ impl Component for PackageManager { .maybe_child(delete_dialog) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::{archive, file}; + use oneclient_core::{BundleFile, FileUpdateStatus}; + + fn bundle(category: &str, opted_in: bool, files: Vec) -> BundleWithUpdateStatus { + BundleWithUpdateStatus { + files: files + .iter() + .cloned() + .map(|f| (f, FileUpdateStatus::NotInstalled)) + .collect(), + archive: archive(category, true, files), + has_updates: false, + opted_in, + } + } + + #[test] + fn all_tab_skips_bundles_not_opted_into() { + let bundles = [ + bundle( + "Declined", + false, + vec![ + file("only-declined", true, false), + file("shared", true, false), + ], + ), + bundle( + "Taken", + true, + vec![file("shared", true, false), file("only-taken", true, false)], + ), + ]; + let rows = bundle_packages( + Vec::new(), + &bundles, + &HashMap::new(), + &PackageMetaMap::new(), + &HashSet::new(), + ContentType::Mod, + ); + let all: Vec<&str> = rows + .iter() + .filter(|p| Tab::All.matches(p)) + .map(|p| p.package_id.as_str()) + .collect(); + assert_eq!(all, ["shared", "only-taken"]); + let shared = rows.iter().find(|p| p.package_id == "shared").unwrap(); + assert_eq!(shared.categories, ["Taken"]); + let declined = Tab::Category("Declined".into()); + assert!( + rows.iter() + .any(|p| p.package_id == "only-declined" && declined.matches(p)) + ); + } +} diff --git a/packages/oneclient_content/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs index 676385ee..28912c46 100644 --- a/packages/oneclient_content/src/bundles/types.rs +++ b/packages/oneclient_content/src/bundles/types.rs @@ -253,4 +253,5 @@ pub struct BundleWithUpdateStatus { pub archive: BundleArchive, pub files: Vec<(BundleFile, FileUpdateStatus)>, pub has_updates: bool, + pub opted_in: bool, } diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 120710b3..ea84a8b6 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -843,6 +843,14 @@ pub async fn get_bundles_with_update_status( }) .collect(); + // 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: Vec<_> = all_linked.iter().filter(|item| item.enabled).collect(); + let (live_managed_keys, _) = installed_bundle_keys(ctx, live).await?; + let mut live_bundles = live_bundle_names(&bundle_packages, &overrides); + live_bundles.extend(infer_subscribed_from_archives( + &archives, + &live_managed_keys, + )); let mut results = Vec::new(); for archive in archives { @@ -874,6 +882,7 @@ pub async fn get_bundles_with_update_status( } results.push(BundleWithUpdateStatus { + opted_in: live_bundles.contains(&archive.manifest.name), archive, files, has_updates, @@ -918,27 +927,32 @@ async fn addition_eligible_bundles( let live: Vec<_> = all_linked.iter().filter(|item| item.enabled).collect(); let (live_managed_keys, _) = installed_bundle_keys(ctx, live).await?; - let mut eligible: HashSet = bundle_packages - .iter() - .filter(|bp| bp.enabled != 0) - .filter_map(|bp| bp.bundle_name.clone()) - .collect(); - + let mut eligible = live_bundle_names(bundle_packages, overrides); eligible.extend(infer_bundle_names_from_unique_installed_keys( candidate_keys_by_bundle, &live_managed_keys, )); - eligible.extend( - overrides - .iter() - .filter(|o| OverrideType::parse(&o.override_type) == Some(OverrideType::Enabled)) - .map(|o| o.bundle_name.clone()), - ); - Ok(eligible) } +fn live_bundle_names( + bundle_packages: &[BundleTrackedArtifactRow], + overrides: &[ClusterBundleOverrideRow], +) -> HashSet { + bundle_packages + .iter() + .filter(|bp| bp.enabled != 0) + .filter_map(|bp| bp.bundle_name.clone()) + .chain( + overrides + .iter() + .filter(|o| OverrideType::parse(&o.override_type) == Some(OverrideType::Enabled)) + .map(|o| o.bundle_name.clone()), + ) + .collect() +} + #[tracing::instrument(level = "debug", skip_all)] async fn installed_bundle_keys<'a>( ctx: &ContentCtx, diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs index 7a616aa7..7fdf80f2 100644 --- a/packages/oneclient_core/tests/bundle_updates.rs +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -1,6 +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, }; use oneclient_core::LauncherState; use oneclient_core::clusters::CreateClusterOptions; @@ -487,3 +488,31 @@ async fn tracked_bundle_that_never_synced_is_not_removed() { check.removals_available ); } + +#[tokio::test] +async fn untracked_older_install_counts_as_opted_in() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + bundle_dao::clear_bundle_tracking(&state.services.db, cluster_id, HASH) + .await + .unwrap(); + + let bundles = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + bundles.iter().all(|b| b.opted_in), + "a bundle the updater infers from its installed mods must not have its files hidden from the All tab" + ); +} From 03c4412d154b2921777e261114d5e718a3b071e0 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Fri, 2 Oct 2026 00:48:27 -0400 Subject: [PATCH 8/8] bundle opt out per content type (#810) * exclude opted out bundles from package list * bundle opt out per content type * list only the content types a bundle is still opted into * count inferred bundle installs as opted in * remove comments from merge * key bundle opt-out by external id so outdated GitHub installs still count * fix: keep bundle type opt-out across catalog changes * fix: keep switched-on bundle files from being re-suppressed * fix: share bundle liveness and keep package list read-only --- ...834ea97776c57bb129a9d67007cdd979ae841.json | 12 + ...2e6466ef51af11c332a2e6089c90759b9871f.json | 38 + ...06bc40e846ad708975991346aab83870e0968.json | 12 + .../oneclient_app/src/view/app/browser/mod.rs | 2 +- .../view/app/cluster/package_manager/mod.rs | 11 +- .../oneclient_content/src/bundles/install.rs | 14 +- .../oneclient_content/src/bundles/types.rs | 4 +- .../oneclient_content/src/bundles/updates.rs | 269 +++++- .../oneclient_core/tests/bundle_updates.rs | 781 +++++++++++++++++- ...001120000_cluster_bundle_type_opt_outs.sql | 7 + .../oneclient_db/src/dao/cluster_bundle.rs | 55 ++ 11 files changed, 1163 insertions(+), 42 deletions(-) create mode 100644 .sqlx/query-59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841.json create mode 100644 .sqlx/query-5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f.json create mode 100644 .sqlx/query-dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968.json create mode 100644 packages/oneclient_db/migrations/20261001120000_cluster_bundle_type_opt_outs.sql diff --git a/.sqlx/query-59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841.json b/.sqlx/query-59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841.json new file mode 100644 index 00000000..e94fb0ea --- /dev/null +++ b/.sqlx/query-59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR IGNORE INTO cluster_bundle_type_opt_outs (cluster_id, bundle_name, content_type)\n VALUES (?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841" +} diff --git a/.sqlx/query-5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f.json b/.sqlx/query-5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f.json new file mode 100644 index 00000000..8c92362f --- /dev/null +++ b/.sqlx/query-5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT bundle_name, content_type\n FROM cluster_bundle_type_opt_outs\n WHERE cluster_id = ?\n ", + "describe": { + "columns": [ + { + "name": "bundle_name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_bundle_type_opt_outs", + "name": "bundle_name" + } + } + }, + { + "name": "content_type", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_bundle_type_opt_outs", + "name": "content_type" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f" +} diff --git a/.sqlx/query-dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968.json b/.sqlx/query-dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968.json new file mode 100644 index 00000000..37ec45cd --- /dev/null +++ b/.sqlx/query-dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM cluster_bundle_type_opt_outs\n WHERE cluster_id = ? AND bundle_name = ? AND content_type = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968" +} diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index fa233755..5dd118a2 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -560,7 +560,7 @@ mod tests { .collect(), archive: archive("performance", true, files), has_updates: false, - opted_in: true, + opted_in_types: [ContentType::Mod].into(), }] } 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 2848101e..b46ba532 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 @@ -145,7 +145,7 @@ pub fn bundle_packages( } let mut ordered: Vec<&BundleWithUpdateStatus> = bundles.iter().collect(); - ordered.sort_by_key(|b| !b.opted_in); + ordered.sort_by_key(|b| !b.opted_in_types.contains(&content_type)); let mut rows = Vec::new(); let mut seen: HashSet = HashSet::new(); @@ -202,7 +202,8 @@ pub fn bundle_packages( vec![category.clone()] }; - let opted_in = installed_info.is_some() || bundle.opted_in; + let opted_in = + installed_info.is_some() || bundle.opted_in_types.contains(&content_type); let mut row = make_row( pid, Some(bundle_name.clone()), @@ -711,7 +712,11 @@ mod tests { .collect(), archive: archive(category, true, files), has_updates: false, - opted_in, + opted_in_types: if opted_in { + [ContentType::Mod].into() + } else { + HashSet::new() + }, } } diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index 42549abf..9c8ce0c7 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -613,8 +613,6 @@ pub async fn set_bundle_package_override( /// For bundle files the cluster has not installed /// installed ones go through [`set_artifact_enabled_to`] -/// Matching the manifest default clears the override -/// switching *on* also drops objections filed under other bundles #[tracing::instrument(level = "debug", skip(ctx))] pub async fn set_bundle_package_enabled( cluster_id: i64, @@ -625,18 +623,12 @@ pub async fn set_bundle_package_enabled( ctx: &ContentCtx, ) -> ContentResult<()> { let override_type = match (enabled, manifest_default) { - (true, true) | (false, false) => None, - (true, false) => Some(OverrideType::Enabled), + (true, _) => Some(OverrideType::Enabled), + (false, false) => None, (false, true) => Some(OverrideType::Disabled), }; - set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await?; - - if enabled { - clear_suppressing_overrides(cluster_id, package_id, ctx).await?; - } - - Ok(()) + set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await } #[tracing::instrument(level = "debug", skip(overrides, ctx), fields(count = overrides.len()))] diff --git a/packages/oneclient_content/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs index 28912c46..ac01ae72 100644 --- a/packages/oneclient_content/src/bundles/types.rs +++ b/packages/oneclient_content/src/bundles/types.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use serde::{Deserialize, Serialize}; use crate::bundles::Bundle; @@ -253,5 +255,5 @@ pub struct BundleWithUpdateStatus { pub archive: BundleArchive, pub files: Vec<(BundleFile, FileUpdateStatus)>, pub has_updates: bool, - pub opted_in: bool, + pub opted_in_types: HashSet, } diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index ea84a8b6..8047673e 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -18,7 +18,7 @@ use futures_util::StreamExt; use crate::bundles::install::{ BUNDLE_INSTALL_CONCURRENCY, bundle_cluster, disable_was_deliberate, external_ids_by_sha1, - find_user_suppression, heal_bundle_activity, install_package_from_bundle, + find_override, find_user_suppression, heal_bundle_activity, install_package_from_bundle, remove_artifact_from_cluster, set_artifact_enabled_to, }; use crate::bundles::manager::BundlesManager; @@ -32,7 +32,7 @@ use crate::ctx::ContentCtx; use crate::error::{ContentError, ContentResult}; use crate::packages::store::PackageStore; use crate::packages::types::LinkedArtifactInfo; -use oneclient_common::domain::{GameLoader, ProviderId}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; static CLUSTER_UPDATE_LOCKS: OnceLock>>>> = OnceLock::new(); @@ -322,33 +322,40 @@ async fn check_bundle_updates_inner( let addition_eligible_bundles = addition_eligible_bundles( ctx, + &archives, &bundle_packages, &all_linked, - &candidate_keys_by_bundle, overrides, + &overrides_map, ) .await?; let mut additions_available = Vec::new(); let mut optional_available: Vec<(String, BundleOptionalPackage)> = Vec::new(); let mut planned_addition_keys = all_installed_managed_keys.clone(); - let tracked_package_by_hash: HashMap<&str, &str> = bundle_packages - .iter() - .filter_map(|bp| Some((bp.hash.as_str(), bp.package_id.as_deref()?))) - .collect(); - for hash in &all_installed_external_hashes { - planned_addition_keys.insert(external_bundle_key(hash)); - if let Some(package_id) = tracked_package_by_hash.get(hash.as_str()) { - planned_addition_keys.insert(external_bundle_key(package_id)); - } - } + planned_addition_keys.extend(installed_external_keys( + &all_installed_external_hashes, + &bundle_packages, + )); + let installed_keys = planned_addition_keys.clone(); + let opt_outs = TypeOptOuts::load( + cluster_id, + &archives, + &addition_eligible_bundles, + overrides, + &installed_keys, + ctx, + ) + .await?; for archive in &archives { if !addition_eligible_bundles.contains(&archive.manifest.name) { continue; } + let suppressed_types = opt_outs.remember(archive, ctx).await?; for file in &archive.manifest.files { + let suppressed = suppressed_types.contains(&file.content_type()); let file_id = file.kind.package_id(); let user_override = overrides_map .get(&(archive.manifest.name.clone(), file_id.clone())) @@ -364,6 +371,10 @@ async fn check_bundle_updates_inner( continue; } + if suppressed && user_override.is_none() { + continue; + } + if !crate::bundles::effective_enabled(file, user_override) { if user_override.is_none() && file.is_optional_offer() @@ -773,7 +784,11 @@ async fn sync_cluster_loader_version_from_bundles( let subscribed: HashSet = bundle_packages .iter() .filter_map(|bp| bp.bundle_name.clone()) - .chain(infer_subscribed_from_archives(&archives, &managed_keys)) + .chain(infer_subscribed_from_archives( + &archives, + &HashMap::new(), + &managed_keys, + )) .collect(); let target = select_highest_loader_version( @@ -844,13 +859,32 @@ pub async fn get_bundles_with_update_status( .collect(); // 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: Vec<_> = all_linked.iter().filter(|item| item.enabled).collect(); - let (live_managed_keys, _) = installed_bundle_keys(ctx, live).await?; - let mut live_bundles = live_bundle_names(&bundle_packages, &overrides); - live_bundles.extend(infer_subscribed_from_archives( + let live_bundles = addition_eligible_bundles( + ctx, &archives, - &live_managed_keys, + &bundle_packages, + &all_linked, + &overrides, + &overrides_map, + ) + .await?; + + let (installed_managed_keys, installed_external_hashes) = + installed_bundle_keys(ctx, &all_linked).await?; + let mut installed_keys = installed_managed_keys; + installed_keys.extend(installed_external_keys( + &installed_external_hashes, + &bundle_packages, )); + let opt_outs = TypeOptOuts::load( + cluster_id, + &archives, + &live_bundles, + &overrides, + &installed_keys, + ctx, + ) + .await?; let mut results = Vec::new(); for archive in archives { @@ -881,8 +915,21 @@ pub async fn get_bundles_with_update_status( files.push((file.clone(), status)); } + let opted_in_types = if live_bundles.contains(&archive.manifest.name) { + let suppressed = opt_outs.suppressed(&archive); + archive + .manifest + .files + .iter() + .map(|f| f.content_type()) + .filter(|ct| !suppressed.contains(ct)) + .collect() + } else { + HashSet::new() + }; + results.push(BundleWithUpdateStatus { - opted_in: live_bundles.contains(&archive.manifest.name), + opted_in_types, archive, files, has_updates, @@ -913,23 +960,193 @@ fn bundle_package_key( } } +/// Keys both the exact file and the id it was tracked under so a GitHub-hosted file still counts as installed once the bundle moves to a newer release +fn installed_external_keys( + hashes: &HashSet, + bundle_packages: &[BundleTrackedArtifactRow], +) -> HashSet { + let tracked_package_by_hash: HashMap<&str, &str> = bundle_packages + .iter() + .filter_map(|bp| Some((bp.hash.as_str(), bp.package_id.as_deref()?))) + .collect(); + hashes + .iter() + .flat_map(|hash| { + std::iter::once(hash.as_str()) + .chain(tracked_package_by_hash.get(hash.as_str()).copied()) + }) + .map(external_bundle_key) + .collect() +} + +fn keys_shipped_by_several_bundles<'a>( + archives: impl IntoIterator, +) -> HashSet { + let mut counts: HashMap = HashMap::new(); + + for archive in archives { + let mut seen: HashSet = HashSet::new(); + for file in &archive.manifest.files { + let key = file.kind.bundle_key(); + if seen.insert(key.clone()) { + *counts.entry(key).or_default() += 1; + } + } + } + + counts + .into_iter() + .filter_map(|(key, count)| (count > 1).then_some(key)) + .collect() +} + +struct TypeOptOuts<'a> { + cluster_id: i64, + overrides: &'a [ClusterBundleOverrideRow], + stray_overrides: Vec, + installed_keys: &'a HashSet, + shared_keys: HashSet, + remembered: HashMap>, +} + +impl<'a> TypeOptOuts<'a> { + async fn load( + cluster_id: i64, + archives: &[BundleArchive], + live: &HashSet, + overrides: &'a [ClusterBundleOverrideRow], + installed_keys: &'a HashSet, + ctx: &ContentCtx, + ) -> ContentResult { + let shipped: HashSet<(&str, String)> = archives + .iter() + .flat_map(|a| { + a.manifest + .files + .iter() + .map(|f| (a.manifest.name.as_str(), f.kind.package_id())) + }) + .collect(); + let stray_overrides = overrides + .iter() + .filter(|o| !shipped.contains(&(o.bundle_name.as_str(), o.package_id.clone()))) + .cloned() + .collect(); + + let mut remembered: HashMap> = HashMap::new(); + for (bundle_name, content_type) in + bundle_dao::list_type_opt_outs(&ctx.db, cluster_id).await? + { + if let Some(ct) = ContentType::from_repr(content_type as u8) { + remembered.entry(bundle_name).or_default().insert(ct); + } + } + + Ok(Self { + cluster_id, + overrides, + stray_overrides, + installed_keys, + shared_keys: keys_shipped_by_several_bundles( + archives.iter().filter(|a| live.contains(&a.manifest.name)), + ), + remembered, + }) + } + + fn signals(&self, archive: &BundleArchive) -> (HashSet, HashSet) { + let bundle_name = &archive.manifest.name; + let mut all_suppressed: HashMap = HashMap::new(); + + for file in archive.manifest.files.iter().filter(|f| !f.hidden) { + let package_id = file.kind.package_id(); + let override_type = find_override(self.overrides, bundle_name, &package_id) + .or_else(|| find_user_suppression(&self.stray_overrides, &package_id)); + let key = file.kind.bundle_key(); + let installed = self.installed_keys.contains(&key) + || matches!(&file.kind, BundleFileKind::External { file: ext, .. } + if self.installed_keys.contains(&external_bundle_key(&ext.sha1))); + if override_type.is_none() && !installed { + continue; + } + if self.shared_keys.contains(&key) { + continue; + } + + let suppressed = matches!( + override_type, + Some(OverrideType::Disabled | OverrideType::Removed) + ); + let entry = all_suppressed.entry(file.content_type()).or_insert(true); + *entry = *entry && suppressed; + } + + let seen = all_suppressed.keys().copied().collect(); + let suppressed = all_suppressed + .into_iter() + .filter_map(|(ct, all)| all.then_some(ct)) + .collect(); + (seen, suppressed) + } + + fn suppressed(&self, archive: &BundleArchive) -> HashSet { + let (seen, mut suppressed) = self.signals(archive); + suppressed.extend( + self.remembered + .get(&archive.manifest.name) + .into_iter() + .flatten() + .filter(|ct| !seen.contains(ct)), + ); + suppressed + } + + async fn remember( + &self, + archive: &BundleArchive, + ctx: &ContentCtx, + ) -> ContentResult> { + let bundle_name = &archive.manifest.name; + let (seen, suppressed) = self.signals(archive); + let remembered = self.remembered.get(bundle_name); + + for ct in &seen { + let opted_out = suppressed.contains(ct); + if opted_out != remembered.is_some_and(|r| r.contains(ct)) { + bundle_dao::set_type_opt_out( + &ctx.db, + self.cluster_id, + bundle_name, + *ct as i64, + opted_out, + ) + .await?; + } + } + + Ok(self.suppressed(archive)) + } +} + /// Stricter than subscription /// emptying a bundle is how a user opts out and an absent override is not /// consent for a file that did not exist back then #[tracing::instrument(level = "debug", skip_all)] async fn addition_eligible_bundles( ctx: &ContentCtx, + archives: &[BundleArchive], bundle_packages: &[BundleTrackedArtifactRow], all_linked: &[LinkedArtifactInfo], - candidate_keys_by_bundle: &HashMap>, overrides: &[ClusterBundleOverrideRow], + overrides_map: &HashMap<(String, String), OverrideType>, ) -> ContentResult> { let live: Vec<_> = all_linked.iter().filter(|item| item.enabled).collect(); let (live_managed_keys, _) = installed_bundle_keys(ctx, live).await?; let mut eligible = live_bundle_names(bundle_packages, overrides); - eligible.extend(infer_bundle_names_from_unique_installed_keys( - candidate_keys_by_bundle, + eligible.extend(infer_subscribed_from_archives( + archives, + overrides_map, &live_managed_keys, )); @@ -1019,13 +1236,17 @@ fn infer_bundle_names_from_unique_installed_keys( fn infer_subscribed_from_archives( archives: &[BundleArchive], + overrides_map: &HashMap<(String, String), OverrideType>, managed_keys: &HashSet, ) -> HashSet { let mut candidate_keys_by_bundle = HashMap::new(); for archive in archives { let mut keys = HashSet::new(); for file in &archive.manifest.files { - if !file.enabled { + let user_override = overrides_map + .get(&(archive.manifest.name.clone(), file.kind.package_id())) + .copied(); + if !crate::bundles::effective_enabled(file, user_override) { continue; } if let BundleFileKind::Managed { diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs index 7fdf80f2..f7957b06 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, + get_bundles_with_update_status, set_bundle_package_enabled, }; use oneclient_core::LauncherState; use oneclient_core::clusters::CreateClusterOptions; @@ -13,6 +13,9 @@ const MC_VERSION: &str = "1.21.1"; const PROJECT_ID: &str = "sodium"; const VERSION_ID: &str = "v1"; const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SHARED_BUNDLE: &str = "Shared Bundle"; +const FABRIC_API_HASH: &str = "dddddddddddddddddddddddddddddddddddddddd"; +const HIDDEN_DEP_HASH: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; fn managed_file(enabled: bool) -> BundleFile { BundleFile { @@ -489,6 +492,403 @@ async fn tracked_bundle_that_never_synced_is_not_removed() { ); } +fn resource_pack_file() -> BundleFile { + BundleFile { + enabled: true, + hidden: false, + file_type: BundleFileType::Normal, + path: "resourcepacks/looks.zip".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: "looks".to_string(), + version_id: "v1".to_string(), + sha1: "cccccccccccccccccccccccccccccccccccccccc".to_string(), + }, + } +} + +#[tokio::test] +async fn disabling_every_mod_stops_new_mods_while_a_resource_pack_stays_on() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![ + managed_file(true), + newly_shipped_file(), + resource_pack_file(), + ]), + ) + .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(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + "looks", + OverrideType::Enabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + let added: Vec = check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect(); + + assert!( + !added.iter().any(|id| id == "newcomer"), + "mods were switched off for this bundle so a new mod must not arrive: {added:?}" + ); + assert!( + added.iter().any(|id| id == "looks"), + "the resource pack side of the bundle is still live: {added:?}" + ); + assert!( + !check + .optional_available + .iter() + .any(|o| o.package_id == "newcomer"), + "mods were switched off for this bundle so a new mod must not be offered either" + ); + + let status = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + let types = &status + .iter() + .find(|b| b.archive.manifest.name == BUNDLE) + .unwrap() + .opted_in_types; + assert!( + !types.contains(&ContentType::Mod) && types.contains(&ContentType::ResourcePack), + "the package list must agree with the update path on which side is opted out: {types:?}" + ); +} + +fn fabric_api_file() -> BundleFile { + BundleFile { + enabled: true, + hidden: false, + file_type: BundleFileType::Normal, + path: "mods/fabric-api.jar".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: "fabric-api".to_string(), + version_id: "v1".to_string(), + sha1: FABRIC_API_HASH.to_string(), + }, + } +} + +fn named_manifest(name: &str, files: Vec) -> BundleManifest { + let mut m = manifest(files); + m.name = name.to_string(); + m +} + +async fn install_fabric_api(state: &LauncherState, cluster_id: i64) { + artifact_dao::insert_artifact( + &state.services.db, + FABRIC_API_HASH, + ContentType::Mod as i64, + "artifacts/fabric-api.jar", + "fabric-api.jar", + Some(1), + ) + .await + .unwrap(); + artifact_dao::link_cluster_artifact( + &state.services.db, + cluster_id, + FABRIC_API_HASH, + "fabric-api.jar", + ) + .await + .unwrap(); + artifact_dao::upsert_provider_release( + &state.services.db, + ProviderId::Modrinth as i64, + "fabric-api", + "v1", + FABRIC_API_HASH, + "Fabric API", + "1.0.0", + None, + MC_VERSION, + "fabric", + ) + .await + .unwrap(); + bundle_dao::track_bundle_artifact( + &state.services.db, + cluster_id, + FABRIC_API_HASH, + SHARED_BUNDLE, + "v1", + "fabric-api", + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn a_mod_every_bundle_ships_does_not_keep_a_disabled_bundle_taking_new_mods() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest( + BUNDLE, + vec![managed_file(true), newly_shipped_file(), fabric_api_file()], + ), + ) + .await + .unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest(SHARED_BUNDLE, vec![fabric_api_file()]), + ) + .await + .unwrap(); + + let cluster_id = cluster_with_tracked_mod(&state).await; + install_fabric_api(&state, cluster_id).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.content(), + ) + .await + .unwrap(); + + let added: Vec = check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect(); + + assert!( + !added.iter().any(|id| id == "newcomer"), + "a mod shared with another bundle must not hold this bundle open: {added:?}" + ); +} + +fn hidden_dependency_file() -> BundleFile { + BundleFile { + enabled: true, + hidden: true, + file_type: BundleFileType::Normal, + path: "mods/sodium-extra-lib.jar".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: "sodium-extra-lib".to_string(), + version_id: "v1".to_string(), + sha1: HIDDEN_DEP_HASH.to_string(), + }, + } +} + +#[tokio::test] +async fn a_hidden_dependency_only_this_bundle_ships_does_not_hold_it_open() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![ + managed_file(true), + newly_shipped_file(), + hidden_dependency_file(), + ]), + ) + .await + .unwrap(); + + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::insert_artifact( + &state.services.db, + HIDDEN_DEP_HASH, + ContentType::Mod as i64, + "artifacts/sodium-extra-lib.jar", + "sodium-extra-lib.jar", + Some(1), + ) + .await + .unwrap(); + artifact_dao::link_cluster_artifact( + &state.services.db, + cluster_id, + HIDDEN_DEP_HASH, + "sodium-extra-lib.jar", + ) + .await + .unwrap(); + artifact_dao::upsert_provider_release( + &state.services.db, + ProviderId::Modrinth as i64, + "sodium-extra-lib", + "v1", + HIDDEN_DEP_HASH, + "Sodium Extra Lib", + "1.0.0", + None, + MC_VERSION, + "fabric", + ) + .await + .unwrap(); + bundle_dao::track_bundle_artifact( + &state.services.db, + cluster_id, + HIDDEN_DEP_HASH, + BUNDLE, + "v1", + "sodium-extra-lib", + ) + .await + .unwrap(); + + 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.content(), + ) + .await + .unwrap(); + + let added: Vec = check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect(); + + assert!( + !added.iter().any(|id| id == "newcomer"), + "a dependency the user was never shown must not read as wanting the bundle: {added:?}" + ); +} + +#[tokio::test] +async fn an_unrelated_catalog_bundle_sharing_a_mod_does_not_undo_the_opt_out() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest( + BUNDLE, + vec![ + managed_file(true), + newly_shipped_file(), + resource_pack_file(), + ], + ), + ) + .await + .unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest("Unrelated Bundle", 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(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + "looks", + OverrideType::Enabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + let added: Vec = check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect(); + + assert!( + !added.iter().any(|id| id == "newcomer"), + "a bundle the user never installed must not speak for this one: {added:?}" + ); +} + #[tokio::test] async fn untracked_older_install_counts_as_opted_in() { let state = oneclient_core::dev::ephemeral_state().await.unwrap(); @@ -512,7 +912,384 @@ async fn untracked_older_install_counts_as_opted_in() { .unwrap(); assert!( - bundles.iter().all(|b| b.opted_in), + bundles + .iter() + .all(|b| b.opted_in_types.contains(&ContentType::Mod)), "a bundle the updater infers from its installed mods must not have its files hidden from the All tab" ); } + +#[tokio::test] +async fn package_list_and_updater_infer_the_same_bundle_through_overrides() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest(BUNDLE, vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest(SHARED_BUNDLE, vec![managed_file(true)]), + ) + .await + .unwrap(); + + let cluster_id = cluster_with_tracked_mod(&state).await; + bundle_dao::clear_bundle_tracking(&state.services.db, cluster_id, HASH) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + SHARED_BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + assert!( + check + .additions_available + .iter() + .any(|a| a.new_file.kind.package_id() == "newcomer"), + "the updater infers this bundle from its uniquely installed mod" + ); + + let status = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + let types = &status + .iter() + .find(|b| b.archive.manifest.name == BUNDLE) + .unwrap() + .opted_in_types; + assert!( + types.contains(&ContentType::Mod), + "the package list must infer the same bundle the updater does: {types:?}" + ); +} + +const GITHUB_MOD_ID: &str = "github-mod"; +const GITHUB_OLD_HASH: &str = "1111111111111111111111111111111111111111"; + +/// The catalog has moved on to a newer release than the one installed +fn github_mod_file() -> BundleFile { + BundleFile { + enabled: true, + hidden: false, + file_type: BundleFileType::Normal, + path: "mods/github-mod.jar".to_string(), + size: 1, + kind: BundleFileKind::External { + file: oneclient_content::packages::types::ExternalFile { + name: "github-mod.jar".to_string(), + url: "https://github.com/example/github-mod/releases/download/v2/github-mod.jar" + .to_string(), + sha1: "2222222222222222222222222222222222222222".to_string(), + size: 1, + content_type: ContentType::Mod, + }, + id: Some(GITHUB_MOD_ID.to_string()), + meta: None, + }, + } +} + +#[tokio::test] +async fn an_outdated_github_mod_left_on_keeps_the_bundle_taking_mods() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![ + managed_file(true), + github_mod_file(), + newly_shipped_file(), + ]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + let db = &state.services.db; + artifact_dao::update_cluster_artifact(db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override(db, cluster_id, BUNDLE, PROJECT_ID, OverrideType::Disabled) + .await + .unwrap(); + artifact_dao::insert_artifact( + db, + GITHUB_OLD_HASH, + ContentType::Mod as i64, + "artifacts/github-mod.jar", + "github-mod.jar", + Some(1), + ) + .await + .unwrap(); + artifact_dao::link_cluster_artifact(db, cluster_id, GITHUB_OLD_HASH, "github-mod.jar") + .await + .unwrap(); + bundle_dao::track_bundle_artifact( + db, + cluster_id, + GITHUB_OLD_HASH, + BUNDLE, + GITHUB_OLD_HASH, + GITHUB_MOD_ID, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + assert!( + check + .additions_available + .iter() + .any(|a| a.new_file.kind.package_id() == "newcomer"), + "the GitHub mod is still on even though its installed release is older than the catalog's" + ); + + let status = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + let types = &status + .iter() + .find(|b| b.archive.manifest.name == BUNDLE) + .unwrap() + .opted_in_types; + assert!(types.contains(&ContentType::Mod), "{types:?}"); +} + +async fn cluster_with_mods_opted_out(state: &LauncherState, disabled_under: &str) -> i64 { + 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, + disabled_under, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + "looks", + OverrideType::Enabled, + ) + .await + .unwrap(); + cluster_id +} + +async fn added_ids(state: &LauncherState, cluster_id: i64) -> Vec { + check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap() + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect() +} + +#[tokio::test] +async fn a_disable_filed_under_another_bundle_still_opts_the_mods_out() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![ + managed_file(true), + newly_shipped_file(), + resource_pack_file(), + ]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_mods_opted_out(&state, "Old Bundle").await; + + let added = added_ids(&state, cluster_id).await; + assert!( + !added.iter().any(|id| id == "newcomer"), + "the user switched sodium off while it was filed under another bundle: {added:?}" + ); +} + +#[tokio::test] +async fn the_catalog_dropping_every_switched_off_mod_keeps_the_opt_out() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), resource_pack_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_mods_opted_out(&state, BUNDLE).await; + added_ids(&state, cluster_id).await; + + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![newly_shipped_file(), resource_pack_file()]), + ) + .await + .unwrap(); + + let added = added_ids(&state, cluster_id).await; + assert!( + !added.iter().any(|id| id == "newcomer"), + "mods stay opted out after the catalog replaces them: {added:?}" + ); + let status = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + let types = &status + .iter() + .find(|b| b.archive.manifest.name == BUNDLE) + .unwrap() + .opted_in_types; + assert!( + !types.contains(&ContentType::Mod), + "the package list must remember the opt-out too: {types:?}" + ); + + set_bundle_package_enabled( + cluster_id, + BUNDLE, + "looks", + true, + true, + &state.services.content(), + ) + .await + .unwrap(); + let added = added_ids(&state, cluster_id).await; + assert!( + !added.iter().any(|id| id == "newcomer"), + "switching a resource pack on must not opt the mods back in: {added:?}" + ); + + set_bundle_package_enabled( + cluster_id, + BUNDLE, + "newcomer", + true, + true, + &state.services.content(), + ) + .await + .unwrap(); + let added = added_ids(&state, cluster_id).await; + assert!( + added.iter().any(|id| id == "newcomer"), + "switching a mod back on opts the mods back in: {added:?}" + ); +} + +#[tokio::test] +async fn switching_a_new_mod_on_beside_switched_off_ones_installs_it() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![ + managed_file(true), + newly_shipped_file(), + resource_pack_file(), + ]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_mods_opted_out(&state, BUNDLE).await; + added_ids(&state, cluster_id).await; + + set_bundle_package_enabled( + cluster_id, + BUNDLE, + "newcomer", + true, + true, + &state.services.content(), + ) + .await + .unwrap(); + let added = added_ids(&state, cluster_id).await; + assert!( + added.iter().any(|id| id == "newcomer"), + "sodium staying off must not swallow the user switching newcomer on: {added:?}" + ); +} + +#[tokio::test] +async fn the_package_list_does_not_save_the_opt_out() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), resource_pack_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_mods_opted_out(&state, BUNDLE).await; + + let status = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + let types = &status + .iter() + .find(|b| b.archive.manifest.name == BUNDLE) + .unwrap() + .opted_in_types; + assert!(!types.contains(&ContentType::Mod), "{types:?}"); + assert!( + bundle_dao::list_type_opt_outs(&state.services.db, cluster_id) + .await + .unwrap() + .is_empty(), + "only the updater saves the opt-out" + ); + + added_ids(&state, cluster_id).await; + assert!( + !bundle_dao::list_type_opt_outs(&state.services.db, cluster_id) + .await + .unwrap() + .is_empty() + ); +} diff --git a/packages/oneclient_db/migrations/20261001120000_cluster_bundle_type_opt_outs.sql b/packages/oneclient_db/migrations/20261001120000_cluster_bundle_type_opt_outs.sql new file mode 100644 index 00000000..9abaa910 --- /dev/null +++ b/packages/oneclient_db/migrations/20261001120000_cluster_bundle_type_opt_outs.sql @@ -0,0 +1,7 @@ +CREATE TABLE cluster_bundle_type_opt_outs ( + cluster_id INTEGER NOT NULL, + bundle_name TEXT NOT NULL, + content_type INTEGER NOT NULL, + PRIMARY KEY (cluster_id, bundle_name, content_type), + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE +); diff --git a/packages/oneclient_db/src/dao/cluster_bundle.rs b/packages/oneclient_db/src/dao/cluster_bundle.rs index 615d3aab..12309acd 100644 --- a/packages/oneclient_db/src/dao/cluster_bundle.rs +++ b/packages/oneclient_db/src/dao/cluster_bundle.rs @@ -243,3 +243,58 @@ pub async fn list_overrides( .fetch_all(pool) .await } + +pub async fn list_type_opt_outs( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#" + SELECT bundle_name, content_type + FROM cluster_bundle_type_opt_outs + WHERE cluster_id = ? + "#, + cluster_id + ) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| (r.bundle_name, r.content_type)) + .collect()) +} + +pub async fn set_type_opt_out( + pool: &SqlitePool, + cluster_id: i64, + bundle_name: &str, + content_type: i64, + opted_out: bool, +) -> Result<(), sqlx::Error> { + if opted_out { + sqlx::query!( + r#" + INSERT OR IGNORE INTO cluster_bundle_type_opt_outs (cluster_id, bundle_name, content_type) + VALUES (?, ?, ?) + "#, + cluster_id, + bundle_name, + content_type + ) + .execute(pool) + .await?; + } else { + sqlx::query!( + r#" + DELETE FROM cluster_bundle_type_opt_outs + WHERE cluster_id = ? AND bundle_name = ? AND content_type = ? + "#, + cluster_id, + bundle_name, + content_type + ) + .execute(pool) + .await?; + } + Ok(()) +}