diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 1fc88734..b83a9ce7 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -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 = 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]); } diff --git a/packages/oneclient_content/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs index d535924c..41c207e2 100644 --- a/packages/oneclient_content/src/bundles/types.rs +++ b/packages/oneclient_content/src/bundles/types.rs @@ -150,6 +150,17 @@ pub struct ApplyBundleUpdatesResult { pub updates_failed: Vec, pub removals_failed: Vec, pub additions_failed: Vec, + 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)] diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 598753fd..6806b610 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -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, ) -> ContentResult { 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) -> 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, ) -> ContentResult { 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::>() .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::>() .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> { 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() diff --git a/packages/oneclient_content/src/packages/activity.rs b/packages/oneclient_content/src/packages/activity.rs index 3ed1e2f0..8bc344c4 100644 --- a/packages/oneclient_content/src/packages/activity.rs +++ b/packages/oneclient_content/src/packages/activity.rs @@ -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> { + 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 { + 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 = 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) -> Option { @@ -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::>(), + "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(&[ diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index 841dffde..b996a0a5 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -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, }; diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 5d6ee4d2..dc377edf 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -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, diff --git a/packages/oneclient_content/src/packages/updates.rs b/packages/oneclient_content/src/packages/updates.rs index e17d37e6..771913f1 100644 --- a/packages/oneclient_content/src/packages/updates.rs +++ b/packages/oneclient_content/src/packages/updates.rs @@ -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)] diff --git a/packages/oneclient_core/src/game/fabric.rs b/packages/oneclient_core/src/game/fabric.rs index 813a8ed6..611de7ca 100644 --- a/packages/oneclient_core/src/game/fabric.rs +++ b/packages/oneclient_core/src/game/fabric.rs @@ -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"), "")); } diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 6dc76696..2d496e26 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -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, Vec) = 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, Vec) = + 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> { +async fn desired_global( + services: &LauncherServices, + unrestored: &mut Vec, +) -> LauncherResult> { let mut desired = Vec::new(); for content_type in GLOBAL_TYPES { @@ -237,11 +272,10 @@ async fn desired_global(services: &LauncherServices) -> LauncherResult 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, ) -> LauncherResult> { 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 { + 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 { let mut entries = Vec::with_capacity(desired.len()); @@ -624,6 +702,95 @@ async fn link_desired(root: &Path, desired: &[Desired]) -> Vec { 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 { + 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, 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 { 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 = (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)); diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs index af35351a..86b8c802 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -234,6 +234,19 @@ pub async fn get_release_by_hash( .await } +pub async fn list_release_game_versions( + pool: &SqlitePool, + hash: &str, +) -> Result, 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,