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:
parent
7402fb6e78
commit
03c4412d15
11 changed files with 1163 additions and 42 deletions
12
.sqlx/query-59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841.json
generated
Normal file
12
.sqlx/query-59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841.json
generated
Normal 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"
|
||||||
|
}
|
||||||
38
.sqlx/query-5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f.json
generated
Normal file
38
.sqlx/query-5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f.json
generated
Normal 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"
|
||||||
|
}
|
||||||
12
.sqlx/query-dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968.json
generated
Normal file
12
.sqlx/query-dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()))]
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue