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:
Julian Chang 2026-09-16 12:24:09 -04:00 • committed by GitHub
parent ebb476029f
commit c3a45371dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 716 additions and 195 deletions

View file

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

View file

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

View file

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

View file

@ -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(&[

View file

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

View file

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

View file

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

View file

@ -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"), ""));
}

View file

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

View file

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