fix: incorrect database and filesystem structure of mods after updates (#834)
Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
This commit is contained in:
parent
ebb476029f
commit
c3a45371dd
10 changed files with 716 additions and 195 deletions
|
|
@ -794,6 +794,7 @@ impl Actions {
|
|||
state.bundles.as_ref(),
|
||||
&content,
|
||||
Some(&session),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -1219,6 +1220,7 @@ impl Actions {
|
|||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -1235,7 +1237,9 @@ impl Actions {
|
|||
}
|
||||
};
|
||||
|
||||
actions.record_bundle_checks([cluster_id]);
|
||||
if result.settled() {
|
||||
actions.record_bundle_checks([cluster_id]);
|
||||
}
|
||||
|
||||
super::invalidate_cluster_queries().await;
|
||||
if let Some(spec) =
|
||||
|
|
@ -1361,7 +1365,18 @@ impl Actions {
|
|||
if synced
|
||||
&& let Ok(clusters) = state.clusters.list().await
|
||||
{
|
||||
actions.record_bundle_checks(clusters.iter().map(|cluster| cluster.id));
|
||||
let unsettled: std::collections::HashSet<i64> = changed
|
||||
.iter()
|
||||
.filter(|(_, result)| !result.settled())
|
||||
.map(|(cluster_id, _)| *cluster_id)
|
||||
.collect();
|
||||
|
||||
actions.record_bundle_checks(
|
||||
clusters
|
||||
.iter()
|
||||
.map(|cluster| cluster.id)
|
||||
.filter(|cluster_id| !unsettled.contains(cluster_id)),
|
||||
);
|
||||
}
|
||||
|
||||
actions
|
||||
|
|
@ -1441,23 +1456,14 @@ impl Actions {
|
|||
return;
|
||||
}
|
||||
|
||||
let applied = match tokio::time::timeout(
|
||||
BUNDLE_APPLY_BUDGET,
|
||||
oneclient_core::apply_bundle_updates(cluster_id, state.bundles.as_ref(), &content),
|
||||
let result = match oneclient_core::apply_bundle_updates(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&content,
|
||||
Some(Instant::now() + BUNDLE_APPLY_BUDGET),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(applied) => applied,
|
||||
Err(_elapsed) => {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
"bundle updates exceeded their launch budget, launching anyway"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let result = match applied {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
|
|
@ -1469,7 +1475,14 @@ impl Actions {
|
|||
}
|
||||
};
|
||||
|
||||
if synced {
|
||||
if result.stopped_early {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
"bundle updates ran out of their launch budget; the rest go at the next launch"
|
||||
);
|
||||
}
|
||||
|
||||
if synced && result.settled() {
|
||||
self.record_bundle_checks([cluster_id]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,17 @@ pub struct ApplyBundleUpdatesResult {
|
|||
pub updates_failed: Vec<String>,
|
||||
pub removals_failed: Vec<String>,
|
||||
pub additions_failed: Vec<String>,
|
||||
pub stopped_early: bool,
|
||||
}
|
||||
|
||||
impl ApplyBundleUpdatesResult {
|
||||
#[must_use]
|
||||
pub fn settled(&self) -> bool {
|
||||
!self.stopped_early
|
||||
&& self.updates_failed.is_empty()
|
||||
&& self.removals_failed.is_empty()
|
||||
&& self.additions_failed.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Instant;
|
||||
|
||||
use oneclient_db::dao::artifact as artifact_dao;
|
||||
use oneclient_db::dao::cluster as cluster_dao;
|
||||
|
|
@ -418,16 +419,21 @@ pub async fn apply_bundle_updates(
|
|||
cluster_id: i64,
|
||||
bundles: &BundlesManager,
|
||||
ctx: &ContentCtx,
|
||||
deadline: Option<Instant>,
|
||||
) -> ContentResult<ApplyBundleUpdatesResult> {
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&ctx.events,
|
||||
"Updating bundle content",
|
||||
);
|
||||
let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session)).await;
|
||||
let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session), deadline).await;
|
||||
session.finish();
|
||||
result
|
||||
}
|
||||
|
||||
fn past(deadline: Option<Instant>) -> bool {
|
||||
deadline.is_some_and(|deadline| Instant::now() >= deadline)
|
||||
}
|
||||
|
||||
/// The caller owns `session`'s lifetime
|
||||
/// sharing one across clusters makes a batch surface as a single notification
|
||||
#[tracing::instrument(skip(bundles, ctx, session))]
|
||||
|
|
@ -436,6 +442,7 @@ pub async fn apply_bundle_updates_with(
|
|||
bundles: &BundlesManager,
|
||||
ctx: &ContentCtx,
|
||||
session: Option<&oneclient_events::GroupedProgressSession>,
|
||||
deadline: Option<Instant>,
|
||||
) -> ContentResult<ApplyBundleUpdatesResult> {
|
||||
let lock = cluster_lock(cluster_id);
|
||||
let _guard = lock.lock().await;
|
||||
|
|
@ -465,6 +472,11 @@ pub async fn apply_bundle_updates_with(
|
|||
let mut result = ApplyBundleUpdatesResult::default();
|
||||
|
||||
for removal in check.removals_available {
|
||||
if past(deadline) {
|
||||
result.stopped_early = true;
|
||||
break;
|
||||
}
|
||||
|
||||
match remove_artifact_from_cluster(cluster_id, &removal.hash, false, ctx).await {
|
||||
Ok(()) => result.removals_applied.push(removal),
|
||||
Err(err) => result
|
||||
|
|
@ -486,11 +498,13 @@ pub async fn apply_bundle_updates_with(
|
|||
s.expect(oneclient_events::TaskCategory::Packages, count, bytes);
|
||||
}
|
||||
|
||||
// Fetch concurrently reconcile in order
|
||||
// splitting the two keeps the fan-out safe since no artifact is unlinked
|
||||
// while another download still needs it
|
||||
let overrides_ref = &overrides;
|
||||
let fetched_updates = futures_util::stream::iter(check.updates_available.into_iter().map(
|
||||
|update| async move {
|
||||
if past(deadline) {
|
||||
return (update, None);
|
||||
}
|
||||
|
||||
let child = session.map(|s| {
|
||||
let c = s.child(
|
||||
update.new_file.display_name(),
|
||||
|
|
@ -509,29 +523,37 @@ pub async fn apply_bundle_updates_with(
|
|||
ctx,
|
||||
)
|
||||
.await;
|
||||
if let Some(child) = child {
|
||||
if let Some(child) = &child {
|
||||
child.set_phase(oneclient_events::TaskPhase::Installing);
|
||||
}
|
||||
let applied = match installed {
|
||||
Ok(hash) => reconcile_update(&update, &hash, overrides_ref, ctx).await,
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
if let Some(child) = child {
|
||||
child.finish();
|
||||
}
|
||||
(update, installed)
|
||||
(update, Some(applied))
|
||||
},
|
||||
))
|
||||
.buffer_unordered(BUNDLE_INSTALL_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
for (update, installed) in fetched_updates {
|
||||
match installed {
|
||||
Ok(hash) => match reconcile_update(&update, &hash, &overrides, ctx).await {
|
||||
Ok(()) => result.updates_applied.push(update),
|
||||
Err(err) => result.updates_failed.push(err.to_string()),
|
||||
},
|
||||
Err(err) => result.updates_failed.push(err.to_string()),
|
||||
for (update, applied) in fetched_updates {
|
||||
match applied {
|
||||
Some(Ok(())) => result.updates_applied.push(update),
|
||||
Some(Err(err)) => result.updates_failed.push(err.to_string()),
|
||||
None => result.stopped_early = true,
|
||||
}
|
||||
}
|
||||
|
||||
let fetched_additions = futures_util::stream::iter(check.additions_available.into_iter().map(
|
||||
|addition| async move {
|
||||
if past(deadline) {
|
||||
return (addition, None);
|
||||
}
|
||||
|
||||
let child = session.map(|s| {
|
||||
let c = s.child(
|
||||
addition.new_file.display_name(),
|
||||
|
|
@ -550,27 +572,31 @@ pub async fn apply_bundle_updates_with(
|
|||
ctx,
|
||||
)
|
||||
.await;
|
||||
if let Some(child) = child {
|
||||
if let Some(child) = &child {
|
||||
child.set_phase(oneclient_events::TaskPhase::Installing);
|
||||
}
|
||||
let applied = match installed {
|
||||
Ok(hash) => reconcile_addition(&addition, &hash, overrides_ref, ctx).await,
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
if let Some(child) = child {
|
||||
child.finish();
|
||||
}
|
||||
(addition, installed)
|
||||
(addition, Some(applied))
|
||||
},
|
||||
))
|
||||
.buffer_unordered(BUNDLE_INSTALL_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
for (addition, installed) in fetched_additions {
|
||||
let file_id = addition.new_file.kind.package_id();
|
||||
match installed {
|
||||
Ok(hash) => {
|
||||
match reconcile_addition(&addition, &hash, &overrides, ctx).await {
|
||||
Ok(()) => result.additions_applied.push(addition),
|
||||
Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")),
|
||||
}
|
||||
for (addition, applied) in fetched_additions {
|
||||
match applied {
|
||||
Some(Ok(())) => result.additions_applied.push(addition),
|
||||
Some(Err(err)) => {
|
||||
let file_id = addition.new_file.kind.package_id();
|
||||
result.additions_failed.push(format!("{file_id}: {err:#}"));
|
||||
}
|
||||
Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")),
|
||||
None => result.stopped_early = true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -884,7 +910,7 @@ pub async fn apply_bundle_updates_for_all_clusters(
|
|||
) -> ContentResult<Vec<(i64, ApplyBundleUpdatesResult)>> {
|
||||
let mut changed = Vec::new();
|
||||
for cluster in cluster_dao::list_all(&ctx.db).await? {
|
||||
match apply_bundle_updates_with(cluster.id, bundles, ctx, session).await {
|
||||
match apply_bundle_updates_with(cluster.id, bundles, ctx, session, None).await {
|
||||
Ok(result) => {
|
||||
if !result.updates_applied.is_empty()
|
||||
|| !result.additions_applied.is_empty()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use oneclient_common::domain::ProviderId;
|
||||
use oneclient_common::domain::{ContentType, ProviderId};
|
||||
use oneclient_db::dao::applied_migration as migration_dao;
|
||||
use oneclient_db::dao::artifact as artifact_dao;
|
||||
|
||||
use crate::ctx::ContentCtx;
|
||||
use crate::error::ContentResult;
|
||||
|
|
@ -89,6 +91,91 @@ fn group_duplicates(
|
|||
by_project.into_iter().collect()
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn disable_foreign_game_versions(
|
||||
cluster_id: i64,
|
||||
mc_version: &str,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<Vec<String>> {
|
||||
if mc_version.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?;
|
||||
let mut switched_off = Vec::new();
|
||||
|
||||
for info in linked
|
||||
.iter()
|
||||
.filter(|info| info.enabled && info.content_type == ContentType::Mod)
|
||||
{
|
||||
match switch_off_if_foreign(cluster_id, info, mc_version, ctx).await {
|
||||
Ok(true) => switched_off.push(
|
||||
info.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| info.file_name.clone()),
|
||||
),
|
||||
Ok(false) => {}
|
||||
Err(err) => tracing::warn!(
|
||||
cluster_id,
|
||||
hash = %info.hash,
|
||||
%err,
|
||||
"could not switch off a mod built for another game version"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(switched_off)
|
||||
}
|
||||
|
||||
const FOREIGN_VERSION_REPAIR: &str = "disable-foreign-game-version";
|
||||
|
||||
async fn switch_off_if_foreign(
|
||||
cluster_id: i64,
|
||||
info: &LinkedArtifactInfo,
|
||||
mc_version: &str,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<bool> {
|
||||
let repair_id = format!("{FOREIGN_VERSION_REPAIR}:{cluster_id}:{mc_version}:{}", info.hash);
|
||||
if migration_dao::is_applied(&ctx.db, &repair_id).await? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let stated = artifact_dao::list_release_game_versions(&ctx.db, &info.hash).await?;
|
||||
if !built_for_another_game_version(&stated, mc_version) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
hash = %info.hash,
|
||||
built_for = ?stated,
|
||||
"switching off a mod built for another game version"
|
||||
);
|
||||
|
||||
PackageStore::set_artifact_enabled_to(cluster_id, &info.hash, false, ctx).await?;
|
||||
migration_dao::mark_applied(&ctx.db, &repair_id).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn built_for_another_game_version(stated: &[String], mc_version: &str) -> bool {
|
||||
!stated.is_empty()
|
||||
&& !stated.iter().any(|versions| {
|
||||
let built_for: Vec<String> = serde_json::from_str(versions).unwrap_or_default();
|
||||
covers_game_version(&built_for, mc_version)
|
||||
})
|
||||
}
|
||||
|
||||
fn covers_game_version(stated: &[String], mc_version: &str) -> bool {
|
||||
stated.is_empty()
|
||||
|| stated.iter().any(|version| {
|
||||
version == mc_version
|
||||
|| mc_version
|
||||
.strip_prefix(version.as_str())
|
||||
.is_some_and(|rest| rest.starts_with('.'))
|
||||
})
|
||||
}
|
||||
|
||||
/// `published_at` is RFC 3339 so string order matches time order
|
||||
/// undated copies lose to dated ones and an all-undated group still picks one
|
||||
fn newest<'a>(copies: impl IntoIterator<Item = &'a Copy>) -> Option<String> {
|
||||
|
|
@ -165,6 +252,41 @@ mod tests {
|
|||
assert!(picked.is_some(), "a group with no dates must not go unresolved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_stated_mismatch_switches_a_mod_off() {
|
||||
let foreign = |rows: &[&str]| {
|
||||
built_for_another_game_version(
|
||||
&rows.iter().map(|r| (*r).to_string()).collect::<Vec<_>>(),
|
||||
"26.1.2",
|
||||
)
|
||||
};
|
||||
|
||||
assert!(!foreign(&[]), "a jar no provider ever described is left alone");
|
||||
assert!(!foreign(&["[]"]), "an empty list is the provider saying nothing");
|
||||
assert!(!foreign(&["[\"1.21.11"]), "a list that will not parse says nothing");
|
||||
assert!(!foreign(&["[\"26.1.2\"]"]));
|
||||
assert!(
|
||||
!foreign(&["[\"1.21.11\",\"26.1.2\"]"]),
|
||||
"a multi-version build stays"
|
||||
);
|
||||
assert!(
|
||||
!foreign(&["[\"1.21.11\"]", "[\"26.1.2\"]"]),
|
||||
"one release row out of two is enough to keep it"
|
||||
);
|
||||
assert!(
|
||||
foreign(&["[\"1.21.11\"]"]),
|
||||
"the jars the old stash dragged in have to go"
|
||||
);
|
||||
assert!(
|
||||
!foreign(&["[\"26.1\"]"]),
|
||||
"a build for the release this cluster was migrated from stays"
|
||||
);
|
||||
assert!(
|
||||
foreign(&["[\"1.21.1\"]"]),
|
||||
"a shorter version that is not a component prefix is still foreign"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_projects_with_several_copies_are_reconciled() {
|
||||
let groups = group_duplicates(&[
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ pub use metadata_cache::{
|
|||
CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached,
|
||||
read_cached_package_meta,
|
||||
};
|
||||
pub use activity::reconcile_duplicate_activity;
|
||||
pub use activity::{disable_foreign_game_versions, reconcile_duplicate_activity};
|
||||
pub use dependencies::{
|
||||
DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ pub use link::{
|
|||
pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path};
|
||||
|
||||
use oneclient_db::dao::{
|
||||
artifact as artifact_dao, cluster as cluster_dao, package_metadata as meta_dao,
|
||||
artifact as artifact_dao, cluster as cluster_dao, cluster_bundle as bundle_dao,
|
||||
package_metadata as meta_dao,
|
||||
};
|
||||
use oneclient_db::models::{ArtifactRow, ClusterRow, SeenStatus};
|
||||
|
||||
|
|
@ -121,6 +122,9 @@ impl PackageStore {
|
|||
|
||||
let enabled = Self::link_artifact(&artifact, &cluster, None, ctx).await?;
|
||||
|
||||
Self::replace_other_versions(cluster_id, provider_id, &project.id, &artifact.hash, ctx)
|
||||
.await?;
|
||||
|
||||
let live = if enabled {
|
||||
link::try_link_materialized(&cluster, &artifact, &artifact.file_name).await
|
||||
} else {
|
||||
|
|
@ -130,6 +134,80 @@ impl PackageStore {
|
|||
Ok((artifact, live))
|
||||
}
|
||||
|
||||
/// Bundle-owned copies are stepped over
|
||||
/// unlinking one here would have the next bundle sync put it straight back
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn replace_other_versions(
|
||||
cluster_id: i64,
|
||||
provider: ProviderId,
|
||||
project_id: &str,
|
||||
keep_hash: &str,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
let others = artifact_dao::list_cluster_artifacts_for_project(
|
||||
&ctx.db,
|
||||
cluster_id,
|
||||
provider as i64,
|
||||
project_id,
|
||||
keep_hash,
|
||||
)
|
||||
.await?;
|
||||
|
||||
for link in others {
|
||||
if bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, &link.hash)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
tracing::debug!(
|
||||
hash = %link.hash,
|
||||
"leaving a bundle-owned version of the project in place"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
project_id,
|
||||
hash = %link.hash,
|
||||
"dropping a superseded version of the project"
|
||||
);
|
||||
Self::unlink_superseded(cluster_id, &link.hash, ctx).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Not `bundles::remove_artifact_from_cluster`
|
||||
/// `packages` must not depend on `bundles` and its override reconciliation
|
||||
/// does not apply here
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
async fn unlink_superseded(
|
||||
cluster_id: i64,
|
||||
hash: &str,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
let cluster = Self::get_cluster(cluster_id, ctx).await?;
|
||||
let content_type = artifact_dao::get_artifact_by_hash(&ctx.db, hash)
|
||||
.await?
|
||||
.and_then(|artifact| ContentType::from_repr(artifact.content_type as u8));
|
||||
let link = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, hash).await?;
|
||||
|
||||
artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?;
|
||||
|
||||
if let (Some(content_type), Some(link)) = (content_type, link)
|
||||
&& link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await
|
||||
== LiveSync::Deferred
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Err(err) = gc::evict_if_unused(hash, ctx).await {
|
||||
tracing::warn!(hash, error = %err, "failed to evict the superseded artifact");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(artifact, cluster, ctx))]
|
||||
pub async fn link_artifact(
|
||||
artifact: &ArtifactRow,
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ use oneclient_events::GroupedProgressChild;
|
|||
|
||||
use crate::ctx::ContentCtx;
|
||||
use crate::error::{ContentError, ContentResult};
|
||||
use crate::packages::dependencies::pick_version;
|
||||
use crate::packages::store::{LiveSync, PackageStore, evict_if_unused, try_unlink_materialized};
|
||||
use crate::packages::dependencies::{pick_version, resolve_required, resolves_dependencies};
|
||||
use crate::packages::store::PackageStore;
|
||||
use crate::packages::types::LinkedArtifactInfo;
|
||||
|
||||
/// Matches the bundle installer's fan-out
|
||||
|
|
@ -345,6 +345,12 @@ pub async fn apply_browser_package_update(
|
|||
.get_version(&update.project_id, &update.latest_version_id, ctx)
|
||||
.await?;
|
||||
|
||||
let enabled_before = artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash)
|
||||
.await?
|
||||
.map(|link| link.enabled != 0);
|
||||
|
||||
install_new_dependencies(update, &project, &version, ctx).await;
|
||||
|
||||
// Compatibility is not re-checked
|
||||
// a provider disagreeing at install time would strand the user on a build
|
||||
// they cannot move off
|
||||
|
|
@ -360,21 +366,6 @@ pub async fn apply_browser_package_update(
|
|||
)
|
||||
.await?;
|
||||
|
||||
let enabled_before = artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash)
|
||||
.await?
|
||||
.map(|link| link.enabled != 0);
|
||||
|
||||
// Everything else of this project goes not just the recorded hash which can
|
||||
// be stale if the user installed a version by hand since the check ran
|
||||
unlink_other_versions(
|
||||
update.cluster_id,
|
||||
update.provider,
|
||||
&update.project_id,
|
||||
&installed.hash,
|
||||
ctx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Provider handed back the already-linked file
|
||||
// leave the link untouched
|
||||
if installed.hash == update.hash {
|
||||
|
|
@ -401,76 +392,65 @@ pub async fn apply_browser_package_update(
|
|||
Ok(installed.hash)
|
||||
}
|
||||
|
||||
/// Bundle-owned copies are stepped over
|
||||
/// unlinking one here would have the next bundle sync put it straight back
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
async fn unlink_other_versions(
|
||||
cluster_id: i64,
|
||||
provider: ProviderId,
|
||||
project_id: &str,
|
||||
keep_hash: &str,
|
||||
#[tracing::instrument(level = "debug", skip(update, project, version, ctx), fields(cluster_id = update.cluster_id))]
|
||||
async fn install_new_dependencies(
|
||||
update: &BrowserPackageUpdate,
|
||||
project: &crate::packages::types::ProjectDetail,
|
||||
version: &crate::packages::types::VersionDetail,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<()> {
|
||||
let others = artifact_dao::list_cluster_artifacts_for_project(
|
||||
&ctx.db,
|
||||
cluster_id,
|
||||
provider as i64,
|
||||
project_id,
|
||||
keep_hash,
|
||||
)
|
||||
.await?;
|
||||
) {
|
||||
if !resolves_dependencies(project.content_type) {
|
||||
return;
|
||||
}
|
||||
|
||||
for link in others {
|
||||
if bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, &link.hash)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
tracing::debug!(
|
||||
hash = %link.hash,
|
||||
"leaving a bundle-owned version of the project in place"
|
||||
);
|
||||
continue;
|
||||
let resolution = match resolve_required(update.provider, version, update.cluster_id, ctx).await {
|
||||
Ok(resolution) => resolution,
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "dependency resolution failed, updating the package alone");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
project_id,
|
||||
hash = %link.hash,
|
||||
"dropping a superseded version of the project"
|
||||
);
|
||||
unlink_superseded(cluster_id, &link.hash, ctx).await?;
|
||||
for missing in &resolution.unresolved {
|
||||
tracing::warn!(dependency = %missing, "no compatible version for a new dependency");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Not `bundles::remove_artifact_from_cluster`
|
||||
/// `packages` must not depend on `bundles` and its override reconciliation
|
||||
/// does not apply here
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
async fn unlink_superseded(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> {
|
||||
let cluster = PackageStore::get_cluster(cluster_id, ctx).await?;
|
||||
let content_type = artifact_dao::get_artifact_by_hash(&ctx.db, hash)
|
||||
.await?
|
||||
.and_then(|artifact| {
|
||||
oneclient_common::domain::ContentType::from_repr(artifact.content_type as u8)
|
||||
});
|
||||
let link = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, hash).await?;
|
||||
|
||||
artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?;
|
||||
|
||||
if let (Some(content_type), Some(link)) = (content_type, link)
|
||||
&& try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await
|
||||
== LiveSync::Deferred
|
||||
{
|
||||
return Ok(());
|
||||
for dependency in &resolution.install {
|
||||
match PackageStore::install_to_cluster(
|
||||
update.provider,
|
||||
&dependency.project,
|
||||
&dependency.version,
|
||||
update.cluster_id,
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((artifact, _)) => {
|
||||
tracing::info!(
|
||||
dependency = %dependency.project.name,
|
||||
"installed a dependency the newer version needs"
|
||||
);
|
||||
if let Err(err) = artifact_dao::set_seen_status(
|
||||
&ctx.db,
|
||||
update.cluster_id,
|
||||
&artifact.hash,
|
||||
SeenStatus::New,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(%err, "could not mark a new dependency as new");
|
||||
}
|
||||
}
|
||||
Err(err) => tracing::warn!(
|
||||
dependency = %dependency.project.name,
|
||||
%err,
|
||||
"failed to install a dependency the newer version needs"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = evict_if_unused(hash, ctx).await {
|
||||
tracing::warn!(hash, error = %err, "failed to evict the superseded artifact");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ pub fn uses_cluster_mods_folder(
|
|||
loader_version: Option<&str>,
|
||||
custom_args: &str,
|
||||
) -> bool {
|
||||
if loader != GameLoader::Fabric {
|
||||
if !matches!(loader, GameLoader::Fabric | GameLoader::Ornithe) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +77,11 @@ mod tests {
|
|||
Some("0.16.5"),
|
||||
""
|
||||
));
|
||||
assert!(uses_cluster_mods_folder(
|
||||
GameLoader::Ornithe,
|
||||
Some("0.16.5"),
|
||||
""
|
||||
));
|
||||
assert!(!uses_cluster_mods_folder(
|
||||
GameLoader::Fabric,
|
||||
Some("0.11.0"),
|
||||
|
|
@ -90,7 +95,6 @@ mod tests {
|
|||
GameLoader::Forge,
|
||||
GameLoader::NeoForge,
|
||||
GameLoader::Quilt,
|
||||
GameLoader::Ornithe,
|
||||
] {
|
||||
assert!(!uses_cluster_mods_folder(loader, Some("0.16.5"), ""));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ use std::collections::HashSet;
|
|||
use std::fs::FileType;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use oneclient_db::dao::applied_migration as migration_dao;
|
||||
use oneclient_db::dao::artifact as artifact_dao;
|
||||
use oneclient_db::dao::cluster as cluster_dao;
|
||||
|
||||
use crate::LauncherResult;
|
||||
use crate::clusters::Cluster;
|
||||
use oneclient_cluster::remove_mods_link;
|
||||
use oneclient_common::domain::ContentType;
|
||||
use oneclient_common::domain::{ContentType, ProviderId};
|
||||
use oneclient_common::paths;
|
||||
use oneclient_content::packages::store::manifest::{
|
||||
self, ManifestEntry, MaterializedManifest,
|
||||
|
|
@ -101,23 +102,6 @@ pub async fn materialize_content(
|
|||
stash_content_files(&from, &into, ContentType::Mod, &ours).await;
|
||||
}
|
||||
|
||||
if mods_in_cluster {
|
||||
let mods_dir = cluster_dir.join(ContentType::Mod.folder_name());
|
||||
let disabled = disable_hand_removed(
|
||||
services,
|
||||
cluster,
|
||||
&mods_dir,
|
||||
ContentType::Mod,
|
||||
previous_mods.as_ref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if !disabled.is_empty() {
|
||||
let (title, body) = removal_notice(&disabled, Some(&cluster.name));
|
||||
services.events.notify(title).body(body).send();
|
||||
}
|
||||
}
|
||||
|
||||
for content_type in GLOBAL_TYPES {
|
||||
let dir = global_root.join(content_type.folder_name());
|
||||
let disabled = disable_hand_removed(
|
||||
|
|
@ -130,12 +114,17 @@ pub async fn materialize_content(
|
|||
.await;
|
||||
|
||||
if !disabled.is_empty() {
|
||||
let (title, body) = removal_notice(&disabled, None);
|
||||
let (title, body) = removal_notice(&disabled);
|
||||
services.events.notify(title).body(body).send();
|
||||
}
|
||||
}
|
||||
|
||||
import_manual_content_with(services, cluster, game_dir, mods_in_cluster).await;
|
||||
if mods_in_cluster {
|
||||
clear_unlinked_mods_once(services, cluster, &cluster_dir.join(ContentType::Mod.folder_name()))
|
||||
.await;
|
||||
}
|
||||
|
||||
import_manual_content_with(services, cluster, game_dir, mods_in_cluster, true).await;
|
||||
|
||||
if let Err(err) = oneclient_content::packages::reconcile_duplicate_activity(
|
||||
cluster.id,
|
||||
|
|
@ -147,13 +136,50 @@ pub async fn materialize_content(
|
|||
tracing::warn!(cluster_id = cluster.id, %err, "failed to resolve duplicate package versions");
|
||||
}
|
||||
|
||||
let (mods, rest): (Vec<Desired>, Vec<Desired>) = desired_mods(services, cluster)
|
||||
.await?
|
||||
.into_iter()
|
||||
.partition(|_| mods_in_cluster);
|
||||
match oneclient_content::packages::disable_foreign_game_versions(
|
||||
cluster.id,
|
||||
&cluster.mc_version,
|
||||
&services.content(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(disabled) if !disabled.is_empty() => {
|
||||
let names = removal_summary(&disabled);
|
||||
let body = if disabled.len() == 1 {
|
||||
format!("{names} is built for a different Minecraft version, so it has been switched off in {}.", cluster.name)
|
||||
} else {
|
||||
format!("{names} are built for a different Minecraft version, so they have been switched off in {}.", cluster.name)
|
||||
};
|
||||
services.events.notify("Incompatible mods switched off").body(body).send();
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(cluster_id = cluster.id, %err, "failed to switch off mods built for another game version");
|
||||
}
|
||||
}
|
||||
|
||||
let active_mods_dir = if mods_in_cluster {
|
||||
cluster_dir.as_path()
|
||||
} else {
|
||||
game_dir
|
||||
}
|
||||
.join(ContentType::Mod.folder_name());
|
||||
clear_disabled_mod_files(services, cluster, &active_mods_dir).await;
|
||||
|
||||
let mut unrestored = Vec::new();
|
||||
let (mods, rest): (Vec<Desired>, Vec<Desired>) =
|
||||
desired_mods(services, cluster, &mut unrestored)
|
||||
.await?
|
||||
.into_iter()
|
||||
.partition(|_| mods_in_cluster);
|
||||
|
||||
// read across every cluster rather than this one so a pack installed anywhere is present here too
|
||||
let packs = desired_global(services).await?;
|
||||
let packs = desired_global(services, &mut unrestored).await?;
|
||||
|
||||
if !unrestored.is_empty() {
|
||||
let (title, body) = unrestored_notice(&unrestored);
|
||||
services.events.notify(title).body(body).send();
|
||||
}
|
||||
|
||||
// Held from the database snapshot through the save so a package removed
|
||||
// mid-launch is not resurrected by our own write; the two calls above take
|
||||
|
|
@ -193,6 +219,12 @@ pub async fn materialize_content(
|
|||
|
||||
if mods_in_cluster {
|
||||
let mod_entries = link_desired(&cluster_dir, &mods).await;
|
||||
drop_unmaterialized(
|
||||
&cluster_dir.join(ContentType::Mod.folder_name()),
|
||||
ContentType::Mod,
|
||||
&mod_entries,
|
||||
)
|
||||
.await;
|
||||
manifest::save(
|
||||
&cluster_dir,
|
||||
manifest::MODS_MANIFEST_NAME,
|
||||
|
|
@ -223,7 +255,10 @@ pub async fn materialize_content(
|
|||
}
|
||||
|
||||
// every enabled pack across every cluster
|
||||
async fn desired_global(services: &LauncherServices) -> LauncherResult<Vec<Desired>> {
|
||||
async fn desired_global(
|
||||
services: &LauncherServices,
|
||||
unrestored: &mut Vec<String>,
|
||||
) -> LauncherResult<Vec<Desired>> {
|
||||
let mut desired = Vec::new();
|
||||
|
||||
for content_type in GLOBAL_TYPES {
|
||||
|
|
@ -237,11 +272,10 @@ async fn desired_global(services: &LauncherServices) -> LauncherResult<Vec<Desir
|
|||
continue;
|
||||
};
|
||||
|
||||
let src = artifact_absolute_path(&artifact.path)?;
|
||||
if !polyio::try_exists(&src).await.unwrap_or(false) {
|
||||
tracing::warn!(hash = %row.hash, "cached artifact missing; skipping");
|
||||
let Some(src) = cached_file(services, &row.hash, &artifact.path).await else {
|
||||
unrestored.push(row.file_name);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
desired.push(Desired {
|
||||
content_type,
|
||||
|
|
@ -481,26 +515,15 @@ fn removal_summary(disabled: &[String]) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn removal_notice(disabled: &[String], cluster_name: Option<&str>) -> (&'static str, String) {
|
||||
fn removal_notice(disabled: &[String]) -> (&'static str, String) {
|
||||
let names = removal_summary(disabled);
|
||||
|
||||
let folder = match cluster_name {
|
||||
Some(name) => format!("{name}'s folder"),
|
||||
None => "your shared folder".to_string(),
|
||||
};
|
||||
|
||||
let scope = if cluster_name.is_some() {
|
||||
""
|
||||
} else {
|
||||
" on every cluster"
|
||||
};
|
||||
|
||||
if disabled.len() == 1 {
|
||||
return (
|
||||
"Content disabled",
|
||||
format!(
|
||||
"{names} is gone from {folder}, so it has been switched off{scope}. \
|
||||
Turn it back on in OneClient to restore it."
|
||||
"{names} is gone from your shared folder, so it has been switched off on every \
|
||||
cluster. Turn it back on in OneClient to restore it."
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -508,8 +531,30 @@ fn removal_notice(disabled: &[String], cluster_name: Option<&str>) -> (&'static
|
|||
(
|
||||
"Content disabled",
|
||||
format!(
|
||||
"{names} are gone from {folder}, so they have been switched off{scope}. \
|
||||
Turn them back on in OneClient to restore them."
|
||||
"{names} are gone from your shared folder, so they have been switched off on every \
|
||||
cluster. Turn them back on in OneClient to restore them."
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn unrestored_notice(unrestored: &[String]) -> (&'static str, String) {
|
||||
let names = removal_summary(unrestored);
|
||||
|
||||
if unrestored.len() == 1 {
|
||||
return (
|
||||
"Missing from the game",
|
||||
format!(
|
||||
"{names} could not be downloaded again, so the game starts without it. \
|
||||
Reinstall it in OneClient once you are back online."
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(
|
||||
"Missing from the game",
|
||||
format!(
|
||||
"{names} could not be downloaded again, so the game starts without them. \
|
||||
Reinstall them in OneClient once you are back online."
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -569,6 +614,7 @@ pub async fn dematerialize_content(
|
|||
async fn desired_mods(
|
||||
services: &LauncherServices,
|
||||
cluster: &Cluster,
|
||||
unrestored: &mut Vec<String>,
|
||||
) -> LauncherResult<Vec<Desired>> {
|
||||
let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()).await?;
|
||||
let mut desired = Vec::with_capacity(linked.len());
|
||||
|
|
@ -583,11 +629,10 @@ async fn desired_mods(
|
|||
continue;
|
||||
};
|
||||
|
||||
let src = artifact_absolute_path(&artifact.path)?;
|
||||
if !polyio::try_exists(&src).await.unwrap_or(false) {
|
||||
tracing::warn!(hash = %link.hash, "cached artifact missing; skipping");
|
||||
let Some(src) = cached_file(services, &link.hash, &artifact.path).await else {
|
||||
unrestored.push(link.cluster_file_name);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
desired.push(Desired {
|
||||
content_type: link.content_type,
|
||||
|
|
@ -600,6 +645,39 @@ async fn desired_mods(
|
|||
Ok(desired)
|
||||
}
|
||||
|
||||
async fn cached_file(
|
||||
services: &LauncherServices,
|
||||
hash: &str,
|
||||
stored_path: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let src = artifact_absolute_path(stored_path).ok()?;
|
||||
if polyio::try_exists(&src).await.unwrap_or(false) {
|
||||
return Some(src);
|
||||
}
|
||||
|
||||
let release = artifact_dao::get_release_by_hash(&services.db, hash)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let provider = ProviderId::from_repr(release.provider as u8)?;
|
||||
|
||||
tracing::info!(hash, "cached package file is gone; fetching it again");
|
||||
|
||||
let restored = PackageStore::resolve_or_download(
|
||||
provider,
|
||||
&release.project_id,
|
||||
&release.version_id,
|
||||
&services.content(),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|err| tracing::warn!(hash, error = %err, "could not restore a cached package"))
|
||||
.ok()
|
||||
.filter(|artifact| artifact.hash == hash)?;
|
||||
|
||||
let path = artifact_absolute_path(&restored.path).ok()?;
|
||||
polyio::try_exists(&path).await.unwrap_or(false).then_some(path)
|
||||
}
|
||||
|
||||
async fn link_desired(root: &Path, desired: &[Desired]) -> Vec<ManifestEntry> {
|
||||
let mut entries = Vec::with_capacity(desired.len());
|
||||
|
||||
|
|
@ -624,6 +702,95 @@ async fn link_desired(root: &Path, desired: &[Desired]) -> Vec<ManifestEntry> {
|
|||
entries
|
||||
}
|
||||
|
||||
const UNLINKED_MODS_REPAIR: &str = "clear-unlinked-mods";
|
||||
|
||||
#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")]
|
||||
async fn clear_unlinked_mods_once(services: &LauncherServices, cluster: &Cluster, mods_dir: &Path) {
|
||||
let id = format!("{UNLINKED_MODS_REPAIR}:{}", cluster.id);
|
||||
match migration_dao::is_applied(&services.db, &id).await {
|
||||
Ok(true) => return,
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "cannot read the repair log; leaving the mods folder alone");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await {
|
||||
Ok(linked) => linked,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "cannot list links; leaving the mods folder alone");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ours = names_linked_here(&linked, ContentType::Mod);
|
||||
let Ok(mut entries) = polyio::read_dir(mods_dir).await else {
|
||||
mark_repair_applied(services, &id).await;
|
||||
return;
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if name.starts_with('.')
|
||||
|| is_note(name)
|
||||
|| !has_content_extension(ContentType::Mod, name)
|
||||
|| ours.contains(name)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!(file = name, "clearing a mod this cluster never linked");
|
||||
if let Err(err) = remove_entry(&path).await {
|
||||
tracing::warn!(file = name, error = %err, "failed to clear an unlinked mod");
|
||||
}
|
||||
}
|
||||
|
||||
mark_repair_applied(services, &id).await;
|
||||
}
|
||||
|
||||
async fn mark_repair_applied(services: &LauncherServices, id: &str) {
|
||||
if let Err(err) = migration_dao::mark_applied(&services.db, id).await {
|
||||
tracing::warn!(repair = id, error = %err, "failed to record a one-time repair");
|
||||
}
|
||||
}
|
||||
|
||||
async fn drop_unmaterialized(dir: &Path, content_type: ContentType, entries: &[ManifestEntry]) {
|
||||
let kept: HashSet<&str> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.path.rsplit('/').next())
|
||||
.collect();
|
||||
|
||||
let Ok(mut read) = polyio::read_dir(dir).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = read.next_entry().await {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if name.starts_with('.') || is_note(name) || !has_content_extension(content_type, name) {
|
||||
continue;
|
||||
}
|
||||
if kept.contains(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
file = name,
|
||||
dir = %dir.display(),
|
||||
"clearing content the launcher no longer lists for this cluster"
|
||||
);
|
||||
if let Err(err) = remove_entry(&path).await {
|
||||
tracing::warn!(file = name, error = %err, "failed to clear stale content");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries are keyed by path so a package whose file name is unchanged is left
|
||||
/// in place rather than being deleted and relinked on every launch
|
||||
async fn prune_previous(
|
||||
|
|
@ -651,6 +818,53 @@ async fn prune_previous(
|
|||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")]
|
||||
async fn clear_disabled_mod_files(services: &LauncherServices, cluster: &Cluster, mods_dir: &Path) {
|
||||
let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await {
|
||||
Ok(linked) => linked,
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "cannot list links; leaving the mods folder alone");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for name in disabled_mod_names(&linked) {
|
||||
let path = mods_dir.join(&name);
|
||||
if polyio::symlink_metadata(&path).await.is_err() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match remove_entry(&path).await {
|
||||
Ok(()) => tracing::info!(file = %name, "cleared a switched-off mod from the folder"),
|
||||
Err(err) => {
|
||||
tracing::warn!(file = %name, error = %err, "failed to clear a switched-off mod")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_mod_names(
|
||||
linked: &[oneclient_content::packages::LinkedArtifactInfo],
|
||||
) -> HashSet<String> {
|
||||
let mods = || {
|
||||
linked
|
||||
.iter()
|
||||
.filter(|link| link.content_type == ContentType::Mod)
|
||||
};
|
||||
|
||||
let live: HashSet<&str> = mods()
|
||||
.filter(|link| link.enabled)
|
||||
.map(|link| link.cluster_file_name.as_str())
|
||||
.collect();
|
||||
|
||||
mods()
|
||||
.filter(|link| !link.enabled)
|
||||
.map(|link| link.cluster_file_name.as_str())
|
||||
.filter(|name| !live.contains(name))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Names in one folder that are the launcher's not the user's what we
|
||||
/// materialized last plus what the database tracks (covering files just
|
||||
/// adopted by [`import_manual_content`])
|
||||
|
|
@ -690,7 +904,7 @@ pub async fn import_manual_content(
|
|||
Err(_) => false,
|
||||
};
|
||||
|
||||
import_manual_content_with(services, cluster, game_dir, mods_in_cluster).await;
|
||||
import_manual_content_with(services, cluster, game_dir, mods_in_cluster, false).await;
|
||||
}
|
||||
|
||||
async fn import_manual_content_with(
|
||||
|
|
@ -698,6 +912,7 @@ async fn import_manual_content_with(
|
|||
cluster: &Cluster,
|
||||
game_dir: &Path,
|
||||
mods_in_cluster: bool,
|
||||
discard_originals: bool,
|
||||
) {
|
||||
let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await {
|
||||
Ok(linked) => linked,
|
||||
|
|
@ -739,6 +954,7 @@ async fn import_manual_content_with(
|
|||
ContentType::Mod,
|
||||
&names_linked_here(&linked, ContentType::Mod),
|
||||
mods_manifest,
|
||||
discard_originals,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -766,6 +982,7 @@ async fn import_manual_content_with(
|
|||
content_type,
|
||||
&known,
|
||||
global_manifest.as_ref(),
|
||||
discard_originals,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
|
@ -789,6 +1006,7 @@ async fn import_from_dir(
|
|||
content_type: ContentType,
|
||||
known: &HashSet<String>,
|
||||
manifest: Option<&MaterializedManifest>,
|
||||
discard_originals: bool,
|
||||
) {
|
||||
let Ok(mut entries) = polyio::read_dir(dir).await else {
|
||||
return;
|
||||
|
|
@ -818,7 +1036,7 @@ async fn import_from_dir(
|
|||
continue;
|
||||
}
|
||||
|
||||
if manifest.is_none() && is_cached_artifact(services, &path).await {
|
||||
if is_cached_artifact(services, &path).await {
|
||||
tracing::debug!(
|
||||
file = name,
|
||||
dir = %dir.display(),
|
||||
|
|
@ -833,8 +1051,11 @@ async fn import_from_dir(
|
|||
match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::debug!(file = name, "registered manually-added content")
|
||||
Ok(row) => {
|
||||
tracing::debug!(file = name, "registered manually-added content");
|
||||
if discard_originals {
|
||||
discard_adopted_original(&row, &path, name).await;
|
||||
}
|
||||
}
|
||||
Err(err) => tracing::warn!(
|
||||
file = name,
|
||||
|
|
@ -845,6 +1066,24 @@ async fn import_from_dir(
|
|||
}
|
||||
}
|
||||
|
||||
async fn discard_adopted_original(
|
||||
row: &oneclient_db::models::ArtifactRow,
|
||||
path: &Path,
|
||||
name: &str,
|
||||
) {
|
||||
match artifact_absolute_path(&row.path) {
|
||||
Ok(cached) if polyio::try_exists(&cached).await.unwrap_or(false) => {}
|
||||
_ => {
|
||||
tracing::warn!(file = name, "adopted content is not in the cache; leaving the original");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = polyio::remove_file(path).await {
|
||||
tracing::warn!(file = name, error = %err, "failed to clear the adopted original");
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool {
|
||||
let Ok(hash) = polyio::sha1_file(path).await else {
|
||||
return false;
|
||||
|
|
@ -1382,6 +1621,54 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
|
||||
fn link(file_name: &str, enabled: bool) -> oneclient_content::packages::LinkedArtifactInfo {
|
||||
oneclient_content::packages::LinkedArtifactInfo {
|
||||
hash: format!("{file_name}-hash"),
|
||||
cluster_file_name: file_name.into(),
|
||||
enabled,
|
||||
content_type: ContentType::Mod,
|
||||
file_name: file_name.into(),
|
||||
project_id: None,
|
||||
version_id: None,
|
||||
display_name: None,
|
||||
display_version: None,
|
||||
provider: None,
|
||||
published_at: None,
|
||||
seen_status: oneclient_db::models::SeenStatus::Seen,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_switched_off_jar_goes_unless_a_live_copy_shares_its_name() {
|
||||
let names = disabled_mod_names(&[
|
||||
link("NBTac-FABRIC-26.2-2.0.1.jar", true),
|
||||
link("NBTac-FABRIC-26.1-1.3.15.jar", false),
|
||||
link("shared.jar", true),
|
||||
link("shared.jar", false),
|
||||
]);
|
||||
|
||||
assert!(
|
||||
names.contains("NBTac-FABRIC-26.1-1.3.15.jar"),
|
||||
"the jar the old stash stranded has to go"
|
||||
);
|
||||
assert!(
|
||||
!names.contains("NBTac-FABRIC-26.2-2.0.1.jar"),
|
||||
"a mod that is on is never cleared"
|
||||
);
|
||||
assert!(
|
||||
!names.contains("shared.jar"),
|
||||
"a name another copy still has switched on must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_content_types_are_left_to_their_own_folders() {
|
||||
let mut pack = link("pack.zip", false);
|
||||
pack.content_type = ContentType::ResourcePack;
|
||||
|
||||
assert!(disabled_mod_names(&[pack]).is_empty());
|
||||
}
|
||||
|
||||
fn names(names: &[&str]) -> HashSet<String> {
|
||||
names.iter().map(|n| (*n).to_string()).collect()
|
||||
}
|
||||
|
|
@ -1690,7 +1977,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn the_global_notice_owns_up_to_its_reach() {
|
||||
let (_, body) = removal_notice(&["bsl.zip".into()], None);
|
||||
let (_, body) = removal_notice(&["bsl.zip".into()]);
|
||||
|
||||
assert!(body.contains("every cluster"), "{body}");
|
||||
assert!(!body.contains("'s folder"), "{body}");
|
||||
|
|
@ -1785,19 +2072,6 @@ mod tests {
|
|||
std::fs::remove_dir_all(dir.path()).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_notice_names_a_few_and_counts_the_rest() {
|
||||
let (title, body) = removal_notice(&["sodium.jar".into()], Some("26.2 Fabric"));
|
||||
// assert_eq!(title, "Mod disabled");
|
||||
assert!(body.contains("sodium.jar is gone"), "{body}");
|
||||
|
||||
let many: Vec<String> = (0..6).map(|i| format!("mod{i}.jar")).collect();
|
||||
let (title, body) = removal_notice(&many, Some("26.2 Fabric"));
|
||||
assert_eq!(title, "Mods disabled");
|
||||
assert!(body.contains("and 3 more"), "{body}");
|
||||
assert!(!body.contains("mod5.jar"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_are_never_user_content() {
|
||||
assert!(is_note(EMPTY_NOTE_NAME));
|
||||
|
|
|
|||
|
|
@ -234,6 +234,19 @@ pub async fn get_release_by_hash(
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn list_release_game_versions(
|
||||
pool: &SqlitePool,
|
||||
hash: &str,
|
||||
) -> Result<Vec<String>, sqlx::Error> {
|
||||
let rows: Vec<(String,)> =
|
||||
sqlx::query_as("SELECT mc_versions FROM provider_releases WHERE hash = ?")
|
||||
.bind(hash)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|(versions,)| versions).collect())
|
||||
}
|
||||
|
||||
pub async fn link_cluster_artifact(
|
||||
pool: &SqlitePool,
|
||||
cluster_id: i64,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue