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(), .collect(),
archive: archive("performance", true, files), archive: archive("performance", true, files),
has_updates: false, 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(); 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 rows = Vec::new();
let mut seen: HashSet<String> = HashSet::new(); let mut seen: HashSet<String> = HashSet::new();
@ -202,7 +202,8 @@ pub fn bundle_packages(
vec![category.clone()] 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( let mut row = make_row(
pid, pid,
Some(bundle_name.clone()), Some(bundle_name.clone()),
@ -711,7 +712,11 @@ mod tests {
.collect(), .collect(),
archive: archive(category, true, files), archive: archive(category, true, files),
has_updates: false, 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 /// For bundle files the cluster has not installed
/// installed ones go through [`set_artifact_enabled_to`] /// 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))] #[tracing::instrument(level = "debug", skip(ctx))]
pub async fn set_bundle_package_enabled( pub async fn set_bundle_package_enabled(
cluster_id: i64, cluster_id: i64,
@ -625,18 +623,12 @@ pub async fn set_bundle_package_enabled(
ctx: &ContentCtx, ctx: &ContentCtx,
) -> ContentResult<()> { ) -> ContentResult<()> {
let override_type = match (enabled, manifest_default) { let override_type = match (enabled, manifest_default) {
(true, true) | (false, false) => None, (true, _) => Some(OverrideType::Enabled),
(true, false) => Some(OverrideType::Enabled), (false, false) => None,
(false, true) => Some(OverrideType::Disabled), (false, true) => Some(OverrideType::Disabled),
}; };
set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await?; 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(())
} }
#[tracing::instrument(level = "debug", skip(overrides, ctx), fields(count = overrides.len()))] #[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 serde::{Deserialize, Serialize};
use crate::bundles::Bundle; use crate::bundles::Bundle;
@ -253,5 +255,5 @@ pub struct BundleWithUpdateStatus {
pub archive: BundleArchive, pub archive: BundleArchive,
pub files: Vec<(BundleFile, FileUpdateStatus)>, pub files: Vec<(BundleFile, FileUpdateStatus)>,
pub has_updates: bool, 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::{ use crate::bundles::install::{
BUNDLE_INSTALL_CONCURRENCY, bundle_cluster, disable_was_deliberate, external_ids_by_sha1, 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, remove_artifact_from_cluster, set_artifact_enabled_to,
}; };
use crate::bundles::manager::BundlesManager; use crate::bundles::manager::BundlesManager;
@ -32,7 +32,7 @@ use crate::ctx::ContentCtx;
use crate::error::{ContentError, ContentResult}; use crate::error::{ContentError, ContentResult};
use crate::packages::store::PackageStore; use crate::packages::store::PackageStore;
use crate::packages::types::LinkedArtifactInfo; 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(); 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( let addition_eligible_bundles = addition_eligible_bundles(
ctx, ctx,
&archives,
&bundle_packages, &bundle_packages,
&all_linked, &all_linked,
&candidate_keys_by_bundle,
overrides, overrides,
&overrides_map,
) )
.await?; .await?;
let mut additions_available = Vec::new(); let mut additions_available = Vec::new();
let mut optional_available: Vec<(String, BundleOptionalPackage)> = Vec::new(); let mut optional_available: Vec<(String, BundleOptionalPackage)> = Vec::new();
let mut planned_addition_keys = all_installed_managed_keys.clone(); let mut planned_addition_keys = all_installed_managed_keys.clone();
let tracked_package_by_hash: HashMap<&str, &str> = bundle_packages planned_addition_keys.extend(installed_external_keys(
.iter() &all_installed_external_hashes,
.filter_map(|bp| Some((bp.hash.as_str(), bp.package_id.as_deref()?))) &bundle_packages,
.collect(); ));
for hash in &all_installed_external_hashes { let installed_keys = planned_addition_keys.clone();
planned_addition_keys.insert(external_bundle_key(hash)); let opt_outs = TypeOptOuts::load(
if let Some(package_id) = tracked_package_by_hash.get(hash.as_str()) { cluster_id,
planned_addition_keys.insert(external_bundle_key(package_id)); &archives,
} &addition_eligible_bundles,
} overrides,
&installed_keys,
ctx,
)
.await?;
for archive in &archives { for archive in &archives {
if !addition_eligible_bundles.contains(&archive.manifest.name) { if !addition_eligible_bundles.contains(&archive.manifest.name) {
continue; continue;
} }
let suppressed_types = opt_outs.remember(archive, ctx).await?;
for file in &archive.manifest.files { for file in &archive.manifest.files {
let suppressed = suppressed_types.contains(&file.content_type());
let file_id = file.kind.package_id(); let file_id = file.kind.package_id();
let user_override = overrides_map let user_override = overrides_map
.get(&(archive.manifest.name.clone(), file_id.clone())) .get(&(archive.manifest.name.clone(), file_id.clone()))
@ -364,6 +371,10 @@ async fn check_bundle_updates_inner(
continue; continue;
} }
if suppressed && user_override.is_none() {
continue;
}
if !crate::bundles::effective_enabled(file, user_override) { if !crate::bundles::effective_enabled(file, user_override) {
if user_override.is_none() if user_override.is_none()
&& file.is_optional_offer() && file.is_optional_offer()
@ -773,7 +784,11 @@ async fn sync_cluster_loader_version_from_bundles(
let subscribed: HashSet<String> = bundle_packages let subscribed: HashSet<String> = bundle_packages
.iter() .iter()
.filter_map(|bp| bp.bundle_name.clone()) .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(); .collect();
let target = select_highest_loader_version( let target = select_highest_loader_version(
@ -844,13 +859,32 @@ pub async fn get_bundles_with_update_status(
.collect(); .collect();
// Same liveness the updater uses so an untracked older install is not hidden from the list while it still takes on new files // 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_bundles = addition_eligible_bundles(
let (live_managed_keys, _) = installed_bundle_keys(ctx, live).await?; ctx,
let mut live_bundles = live_bundle_names(&bundle_packages, &overrides);
live_bundles.extend(infer_subscribed_from_archives(
&archives, &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(); let mut results = Vec::new();
for archive in archives { for archive in archives {
@ -881,8 +915,21 @@ pub async fn get_bundles_with_update_status(
files.push((file.clone(), 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 { results.push(BundleWithUpdateStatus {
opted_in: live_bundles.contains(&archive.manifest.name), opted_in_types,
archive, archive,
files, files,
has_updates, 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 /// Stricter than subscription
/// emptying a bundle is how a user opts out and an absent override is not /// 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 /// consent for a file that did not exist back then
#[tracing::instrument(level = "debug", skip_all)] #[tracing::instrument(level = "debug", skip_all)]
async fn addition_eligible_bundles( async fn addition_eligible_bundles(
ctx: &ContentCtx, ctx: &ContentCtx,
archives: &[BundleArchive],
bundle_packages: &[BundleTrackedArtifactRow], bundle_packages: &[BundleTrackedArtifactRow],
all_linked: &[LinkedArtifactInfo], all_linked: &[LinkedArtifactInfo],
candidate_keys_by_bundle: &HashMap<String, HashSet<String>>,
overrides: &[ClusterBundleOverrideRow], overrides: &[ClusterBundleOverrideRow],
overrides_map: &HashMap<(String, String), OverrideType>,
) -> ContentResult<HashSet<String>> { ) -> ContentResult<HashSet<String>> {
let live: Vec<_> = all_linked.iter().filter(|item| item.enabled).collect(); let live: Vec<_> = all_linked.iter().filter(|item| item.enabled).collect();
let (live_managed_keys, _) = installed_bundle_keys(ctx, live).await?; let (live_managed_keys, _) = installed_bundle_keys(ctx, live).await?;
let mut eligible = live_bundle_names(bundle_packages, overrides); let mut eligible = live_bundle_names(bundle_packages, overrides);
eligible.extend(infer_bundle_names_from_unique_installed_keys( eligible.extend(infer_subscribed_from_archives(
candidate_keys_by_bundle, archives,
overrides_map,
&live_managed_keys, &live_managed_keys,
)); ));
@ -1019,13 +1236,17 @@ fn infer_bundle_names_from_unique_installed_keys(
fn infer_subscribed_from_archives( fn infer_subscribed_from_archives(
archives: &[BundleArchive], archives: &[BundleArchive],
overrides_map: &HashMap<(String, String), OverrideType>,
managed_keys: &HashSet<String>, managed_keys: &HashSet<String>,
) -> HashSet<String> { ) -> HashSet<String> {
let mut candidate_keys_by_bundle = HashMap::new(); let mut candidate_keys_by_bundle = HashMap::new();
for archive in archives { for archive in archives {
let mut keys = HashSet::new(); let mut keys = HashSet::new();
for file in &archive.manifest.files { 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; continue;
} }
if let BundleFileKind::Managed { if let BundleFileKind::Managed {

View file

@ -1,7 +1,7 @@
use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
use oneclient_content::bundles::{ use oneclient_content::bundles::{
BundleFile, BundleFileKind, BundleFileType, BundleManifest, check_bundle_updates, 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::LauncherState;
use oneclient_core::clusters::CreateClusterOptions; use oneclient_core::clusters::CreateClusterOptions;
@ -13,6 +13,9 @@ const MC_VERSION: &str = "1.21.1";
const PROJECT_ID: &str = "sodium"; const PROJECT_ID: &str = "sodium";
const VERSION_ID: &str = "v1"; const VERSION_ID: &str = "v1";
const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 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 { fn managed_file(enabled: bool) -> BundleFile {
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] #[tokio::test]
async fn untracked_older_install_counts_as_opted_in() { async fn untracked_older_install_counts_as_opted_in() {
let state = oneclient_core::dev::ephemeral_state().await.unwrap(); let state = oneclient_core::dev::ephemeral_state().await.unwrap();
@ -512,7 +912,384 @@ async fn untracked_older_install_counts_as_opted_in() {
.unwrap(); .unwrap();
assert!( 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" "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) .fetch_all(pool)
.await .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(())
}