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
This commit is contained in:
Julian Chang 2026-10-02 00:48:27 -04:00 • committed by GitHub
parent 7402fb6e78
commit 03c4412d15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1163 additions and 42 deletions

View file

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

View file

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

View file

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

View file

@ -560,7 +560,7 @@ mod tests {
.collect(),
archive: archive("performance", true, files),
has_updates: false,
opted_in: true,
opted_in_types: [ContentType::Mod].into(),
}]
}

View file

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

View file

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

View file

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

View file

@ -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<Mutex<HashMap<i64, Arc<AsyncMutex<()>>>>> = 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<String> = 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<String>,
bundle_packages: &[BundleTrackedArtifactRow],
) -> HashSet<String> {
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<Item = &'a BundleArchive>,
) -> HashSet<String> {
let mut counts: HashMap<String, usize> = HashMap::new();
for archive in archives {
let mut seen: HashSet<String> = 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<ClusterBundleOverrideRow>,
installed_keys: &'a HashSet<String>,
shared_keys: HashSet<String>,
remembered: HashMap<String, HashSet<ContentType>>,
}
impl<'a> TypeOptOuts<'a> {
async fn load(
cluster_id: i64,
archives: &[BundleArchive],
live: &HashSet<String>,
overrides: &'a [ClusterBundleOverrideRow],
installed_keys: &'a HashSet<String>,
ctx: &ContentCtx,
) -> ContentResult<Self> {
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<String, HashSet<ContentType>> = 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<ContentType>, HashSet<ContentType>) {
let bundle_name = &archive.manifest.name;
let mut all_suppressed: HashMap<ContentType, bool> = 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<ContentType> {
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<HashSet<ContentType>> {
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<String, HashSet<String>>,
overrides: &[ClusterBundleOverrideRow],
overrides_map: &HashMap<(String, String), OverrideType>,
) -> ContentResult<HashSet<String>> {
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<String>,
) -> HashSet<String> {
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 {

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,
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<String> = 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<BundleFile>) -> 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<String> = 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<String> = 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<String> = 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<String> {
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()
);
}

View file

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

View file

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