From abf708fdb413405474a84756729b23c57654abd3 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Mon, 5 Oct 2026 17:20:04 -0400 Subject: [PATCH] mods folder sync (#937) --- .../mods_folder_syncs_with_the_launcher.md | 5 + .../src/components/optional_mods_popup.rs | 1 + .../src/components/package_row.rs | 10 +- packages/oneclient_app/src/hooks/mod.rs | 15 +- .../src/hooks/queries/cluster_content.rs | 62 ++ .../oneclient_app/src/hooks/queries/mod.rs | 5 +- .../src/hooks/queries/mutations.rs | 7 +- .../src/view/app/cluster/mods.rs | 40 +- .../view/app/cluster/package_manager/mod.rs | 1 + packages/oneclient_cluster/src/lib.rs | 2 +- packages/oneclient_cluster/src/manager.rs | 14 +- .../oneclient_content/src/bundles/install.rs | 9 +- .../src/packages/local_manifest.rs | 76 ++ .../src/packages/store/link.rs | 26 + .../src/packages/store/manifest.rs | 2 +- .../src/packages/store/mod.rs | 9 +- packages/oneclient_core/src/game/mod.rs | 2 +- .../oneclient_core/src/game/shared_dir.rs | 805 +++++++++++++++++- .../oneclient_core/tests/mods_folder_sync.rs | 124 +++ 19 files changed, 1163 insertions(+), 52 deletions(-) create mode 100644 .changeset/mods_folder_syncs_with_the_launcher.md create mode 100644 packages/oneclient_core/tests/mods_folder_sync.rs diff --git a/.changeset/mods_folder_syncs_with_the_launcher.md b/.changeset/mods_folder_syncs_with_the_launcher.md new file mode 100644 index 00000000..dd03fbd3 --- /dev/null +++ b/.changeset/mods_folder_syncs_with_the_launcher.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# The mods folder now syncs with the launcher: jars added, renamed or replaced by hand show up in the Mods list, and jars deleted by hand are switched off instead of being put back diff --git a/packages/oneclient_app/src/components/optional_mods_popup.rs b/packages/oneclient_app/src/components/optional_mods_popup.rs index 6aa080cb..a6c759f1 100644 --- a/packages/oneclient_app/src/components/optional_mods_popup.rs +++ b/packages/oneclient_app/src/components/optional_mods_popup.rs @@ -128,6 +128,7 @@ fn entry_from_item( manifest_default: false, hidden: false, opted_in: true, + shadowed: false, advanced: false, update_available: false, // Offered, not installed, so there is no recency badge to show diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 94095444..bbc646e2 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -65,6 +65,7 @@ pub struct PackageEntry { pub advanced: bool, /// Only set for browser-installed content bundle packages use the bundle update flow pub update_available: bool, + pub shadowed: bool, /// Recency badge state cleared once the user views the list pub seen_status: SeenStatus, pub essential: Option<&'static EssentialPackage>, @@ -469,7 +470,8 @@ pub(crate) fn grid_card( .into_element() }); - let floating = (item.is_outdated() || item.recency_badge().is_some()).then(|| { + let badged = item.is_outdated() || item.shadowed || item.recency_badge().is_some(); + let floating = badged.then(|| { rect() .horizontal() .width(Size::fill()) @@ -482,6 +484,7 @@ pub(crate) fn grid_card( .cross_align(Alignment::Center) .spacing(4.) .maybe_child(item.is_outdated().then(outdated_badge)) + .maybe_child(item.shadowed.then(shadowed_badge)) .maybe_child(item.recency_badge()) .into_element() }); @@ -767,6 +770,7 @@ fn package_info( local_badge() }) .maybe_child(item.is_outdated().then(outdated_badge)) + .maybe_child(item.shadowed.then(shadowed_badge)) .maybe_child(item.recency_badge()), ) .maybe(!item.author.is_empty(), |el| { @@ -868,6 +872,10 @@ fn outdated_badge() -> Element { status_tag("Update available", colors::brand()) } +fn shadowed_badge() -> Element { + status_tag("Using your copy", colors::brand()) +} + fn new_badge() -> Element { status_tag("New", colors::success()) } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 2464288f..f4fb35d2 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -64,13 +64,14 @@ pub use queries::{ use_discard_leftovers, use_finish_microsoft_login, use_game_profile, use_game_versions, use_global_analytics, use_java_majors, use_java_runtimes, use_leftovers, use_loader_versions, use_local_image, use_log_action, use_log_content, use_migratable_routes, use_migration, - use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch, - use_package_project, use_package_search, use_package_updates, use_package_versions_when, - use_player_profile, use_player_skin, use_provider_versions, use_refresh_account, - use_refresh_all_accounts, use_remove_account, use_screenshot_action, - use_screenshot_folder_watch, use_set_default_account, use_storage_action, use_storage_report, - use_terms, use_upload_log, use_version_art, use_version_art_gallery, use_version_loaders, - use_version_metadata, use_versions, version_list, versions_metadata, versions_total, + use_mods_folder_sync, use_named_profiles, use_onboarding_bundles, use_package_categories, + use_package_meta_batch, use_package_project, use_package_search, use_package_updates, + use_package_versions_when, use_player_profile, use_player_skin, use_provider_versions, + use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action, + use_screenshot_folder_watch, use_set_default_account, use_shadowed_mods, use_storage_action, + use_storage_report, use_terms, use_upload_log, use_version_art, use_version_art_gallery, + use_version_loaders, use_version_metadata, use_versions, version_list, versions_metadata, + versions_total, }; pub use queries::{ add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents, diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs index 7f98ba08..a3d40eeb 100644 --- a/packages/oneclient_app/src/hooks/queries/cluster_content.rs +++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs @@ -1,5 +1,8 @@ +use freya::prelude::{spawn, use_hook}; use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use notify::{Event, EventKind}; use std::collections::HashSet; +use std::path::{Path, PathBuf}; use std::sync::Arc; use oneclient_content::packages::release_migration::has_packages_to_migrate; @@ -88,3 +91,62 @@ pub fn use_migratable_routes( let query = use_query(Query::new(routes, MigratableRoutesQuery)); super::state::settled_or_loading(&query).unwrap_or_default() } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ShadowedModsQuery; + +impl QueryCapability for ShadowedModsQuery { + type Ok = Arc>; + type Err = LauncherError; + type Keys = ClusterId; + + async fn run(&self, cluster_id: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let cluster_id = *cluster_id; + Ok(off_ui(async move { + Arc::new(oneclient_core::game::shadowed_bundle_mods(&state, cluster_id).await) + }) + .await) + } +} + +pub fn use_shadowed_mods(cluster_id: ClusterId) -> Arc> { + let query = use_query(Query::new(cluster_id, ShadowedModsQuery)); + super::state::settled_or_loading(&query).unwrap_or_default() +} + +pub fn use_mods_folder_sync(cluster_id: ClusterId, folder: Option) { + let sync = move || { + spawn(async move { + let changed = off_ui(async move { + let Ok(state) = crate::launcher::state() else { + return false; + }; + oneclient_core::game::sync_cluster_mods(&state, cluster_id).await + }) + .await; + + if changed { + super::mutations::invalidate_cluster_queries().await; + } + }); + }; + + use_hook(sync); + super::folder_watch::use_folder_watch(folder, false, touches_jar, sync); +} + +fn touches_jar(_root: &Path, event: &Event) -> bool { + if matches!(event.kind, EventKind::Access(_)) { + return false; + } + + event.paths.iter().any(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + let name = name.to_ascii_lowercase(); + name.ends_with(".jar") || name.ends_with(".disabled") + }) + }) +} diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index dbfabdd3..c9eabba8 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -59,7 +59,10 @@ pub use changelog::{ changelog_entries, changelog_error, changelog_is_loading, latest_changelog_version, use_changelog, }; -pub use cluster_content::{cluster_content_items, use_cluster_content, use_migratable_routes}; +pub use cluster_content::{ + cluster_content_items, use_cluster_content, use_migratable_routes, use_mods_folder_sync, + use_shadowed_mods, +}; pub use clusters::{use_cluster, use_clusters}; pub use disable_warnings::{DisableWarningsQuery, disable_warnings, use_disable_warnings}; pub(crate) use folder_watch::use_folder_watch; diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index 700bb1d6..a5d4d9df 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -7,7 +7,7 @@ use std::collections::HashSet; use std::path::PathBuf; use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; -use super::cluster_content::{ClusterContentQuery, MigratableRoutesQuery}; +use super::cluster_content::{ClusterContentQuery, MigratableRoutesQuery, ShadowedModsQuery}; use super::clusters::ListClustersQuery; use super::package_updates::PackageUpdatesQuery; use super::settings_profiles::{ @@ -32,6 +32,11 @@ pub async fn invalidate_cluster_queries() { QueriesStorage::::invalidate_all(), ) .await; + timed( + "shadowed_mods", + QueriesStorage::::invalidate_all(), + ) + .await; timed( "bundle_overrides", QueriesStorage::::invalidate_all(), diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs index e5afc692..b27ca276 100644 --- a/packages/oneclient_app/src/view/app/cluster/mods.rs +++ b/packages/oneclient_app/src/view/app/cluster/mods.rs @@ -1,9 +1,13 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::PathBuf; + use freya::prelude::*; use oneclient_content::packages::ContentType; use crate::hooks::{ bundle_overrides_map, bundles_with_status_items, cluster_content_items, stale_hashes, - use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_updates, + use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_mods_folder_sync, + use_package_updates, use_shadowed_mods, }; use crate::layout::cluster_content; @@ -24,16 +28,17 @@ impl Component for ClusterMods { let bundles = use_bundles_with_status(self.cluster_id); let overrides = use_bundle_overrides(self.cluster_id); let updates = use_package_updates(self.cluster_id); + let shadowed = use_shadowed_mods(self.cluster_id); let bundle_items = bundles_with_status_items(&bundles); let content_items = cluster_content_items(&content); let meta = use_content_meta(&content_items, &bundle_items, ContentType::Mod); - let Some(_cluster) = use_cluster(self.cluster_id) else { + let Some(cluster) = use_cluster(self.cluster_id) else { return cluster_not_found(); }; let all_categories = bundle_categories(&bundle_items); - let items = bundle_packages( + let mut items = bundle_packages( content_items, &bundle_items, &bundle_overrides_map(&overrides), @@ -41,8 +46,18 @@ impl Component for ClusterMods { &stale_hashes(&updates), ContentType::Mod, ); + for item in &mut items { + item.shadowed = item + .hash + .as_ref() + .is_some_and(|hash| shadowed.contains(hash)); + } cluster_content() + .child(ModsFolderSync { + cluster_id: self.cluster_id, + folder: oneclient_common::paths::cluster_mods_dir(&cluster.folder_name).ok(), + }) .child( PackageManager::new( "Mods", @@ -58,3 +73,22 @@ impl Component for ClusterMods { .into_element() } } + +#[derive(PartialEq)] +struct ModsFolderSync { + cluster_id: i64, + folder: Option, +} + +impl Component for ModsFolderSync { + fn render(&self) -> impl IntoElement { + use_mods_folder_sync(self.cluster_id, self.folder.clone()); + rect() + } + + fn render_key(&self) -> DiffKey { + let mut hasher = DefaultHasher::new(); + (self.cluster_id, &self.folder).hash(&mut hasher); + DiffKey::U64(hasher.finish()) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 0bb690a0..a573192f 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -316,6 +316,7 @@ fn make_row( update_available, hidden, opted_in, + shadowed: false, advanced: false, seen_status: installed_info.map(|i| i.seen_status).unwrap_or_default(), } diff --git a/packages/oneclient_cluster/src/lib.rs b/packages/oneclient_cluster/src/lib.rs index ff946c5a..e9de17b5 100644 --- a/packages/oneclient_cluster/src/lib.rs +++ b/packages/oneclient_cluster/src/lib.rs @@ -19,7 +19,7 @@ pub mod worlds; pub use cluster::{Cluster, ClusterLinkTarget, encode_tags, remove_mods_link}; pub use error::{ClusterError, ClusterResult}; pub use identity::InstanceIdentity; -pub use manager::ClusterManager; +pub use manager::{ClusterManager, unlinked_mods_repair_id}; pub use oneclient_db::models::ClusterKind; pub use options::{ClusterUpdate, CreateClusterOptions}; pub use profile::{GameSettingsProfile, PackageUpdateMode, SettingsOsExtra}; diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs index 0b43f6ff..e1560d01 100644 --- a/packages/oneclient_cluster/src/manager.rs +++ b/packages/oneclient_cluster/src/manager.rs @@ -1,4 +1,6 @@ -use oneclient_db::dao::{cluster as cluster_dao, setting_profile as profile_dao}; +use oneclient_db::dao::{ + applied_migration as migration_dao, cluster as cluster_dao, setting_profile as profile_dao, +}; use oneclient_db::models::{ClusterId, ClusterKind, ClusterPatch, NewCluster}; use crate::error::ClusterResult; @@ -169,6 +171,12 @@ impl ClusterManager { { Ok(cluster) => { tracing::info!(cluster_id = cluster.id, name = %cluster.name, "created cluster"); + if let Err(err) = + migration_dao::mark_applied(&self.db, &unlinked_mods_repair_id(cluster.id)) + .await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to record a one-time repair"); + } Ok(cluster) } Err(err) => { @@ -675,6 +683,10 @@ fn identity_of(cluster: &Cluster) -> crate::identity::InstanceIdentity { } } +pub fn unlinked_mods_repair_id(cluster_id: ClusterId) -> String { + format!("clear-unlinked-mods:{cluster_id}") +} + #[tracing::instrument(level = "debug", skip(pool))] async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> ClusterResult<()> { if profile_dao::get_by_name(pool, name).await?.is_none() { diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index b8d7ffd3..c331baad 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -13,7 +13,9 @@ use crate::bundles::types::{BundleArchive, BundleFile, BundleFileKind}; use crate::ctx::ContentCtx; use crate::error::ContentError; use crate::error::ContentResult; -use crate::packages::store::{LiveSync, PackageStore, evict_if_unused, try_unlink_materialized}; +use crate::packages::store::{ + LiveSync, PackageStore, drop_unmanaged_mod, evict_if_unused, try_unlink_materialized, +}; use crate::packages::types::ExternalFile; use oneclient_common::domain::{ContentType, GameLoader}; use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; @@ -984,6 +986,11 @@ pub async fn remove_artifact_from_cluster( // The folder is rebuilt from the database at the next launch artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + if let (Some(content_type), Some(link)) = (target, &link) { + let name = &link.cluster_file_name; + drop_unmanaged_mod(&cluster, content_type, name, hash, &ctx.db).await; + } + // Best-effort folder cleanup failure here is not an error let deferred = match (target, link) { (Some(content_type), Some(link)) => { diff --git a/packages/oneclient_content/src/packages/local_manifest.rs b/packages/oneclient_content/src/packages/local_manifest.rs index 157431b1..f9803b10 100644 --- a/packages/oneclient_content/src/packages/local_manifest.rs +++ b/packages/oneclient_content/src/packages/local_manifest.rs @@ -88,6 +88,49 @@ pub async fn read_jar_manifest(jar: &Path) -> JarManifest { out } +#[tracing::instrument(level = "debug", fields(jar = %jar.display()))] +pub async fn read_jar_mod_id(jar: &Path) -> Option { + let entries = polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name)) + .await + .inspect_err(|err| tracing::debug!("could not read {}: {err}", jar.display())) + .ok()?; + + MANIFESTS.into_iter().find_map(|wanted| { + let (_, bytes) = entries.iter().find(|(name, _)| name == wanted)?; + parse_mod_id(wanted, std::str::from_utf8(bytes).ok()?) + }) +} + +fn parse_mod_id(manifest: &str, text: &str) -> Option { + let id = match manifest { + NEOFORGE | FORGE => toml::from_str::(text) + .ok()? + .get("mods")? + .get(0)? + .get("modId")? + .as_str()? + .to_owned(), + _ => { + let json: serde_json::Value = serde_json::from_str(text).ok()?; + let pointers: &[&str] = match manifest { + FABRIC => &["/id"], + QUILT => &["/quilt_loader/id"], + _ => &["/0/modid", "/modList/0/modid"], + }; + pointers + .iter() + .find_map(|pointer| json.pointer(pointer)?.as_str())? + .to_owned() + } + }; + + clean(Some(id)).filter(|id| !is_placeholder_id(id)) +} + +fn is_placeholder_id(id: &str) -> bool { + id.contains('$') || id.eq_ignore_ascii_case("examplemod") +} + #[tracing::instrument(level = "debug", fields(jar = %jar.display()))] pub async fn read_jar_loader(jar: &Path) -> Option { let entries = polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name)) @@ -369,6 +412,39 @@ fn entry_path(raw: String) -> Option { mod tests { use super::*; + #[test] + fn every_loader_names_its_mod_id() { + assert_eq!( + parse_mod_id(FABRIC, r#"{"id":"argentum","name":"Argentum"}"#).as_deref(), + Some("argentum") + ); + assert_eq!( + parse_mod_id(QUILT, r#"{"quilt_loader":{"id":"qsl"}}"#).as_deref(), + Some("qsl") + ); + assert_eq!( + parse_mod_id(FORGE, "[[mods]]\nmodId = \"jei\"\n").as_deref(), + Some("jei") + ); + assert_eq!( + parse_mod_id(LEGACY_FORGE, r#"[{"modid":"patcher"}]"#).as_deref(), + Some("patcher") + ); + assert_eq!( + parse_mod_id(LEGACY_FORGE, r#"{"modList":[{"modid":"oneconfig"}]}"#).as_deref(), + Some("oneconfig") + ); + assert_eq!(parse_mod_id(FABRIC, r#"{"id":" "}"#), None); + assert_eq!( + parse_mod_id(LEGACY_FORGE, r#"[{"modid":"examplemod"}]"#), + None + ); + assert_eq!( + parse_mod_id(LEGACY_FORGE, r#"[{"modid":"${modid}"}]"#), + None + ); + } + #[test] fn fabric_reads_every_field() { let parsed = parse_fabric( diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index fb0678ae..5dd6ebc0 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -93,6 +93,32 @@ pub async fn remove_entry(path: &Path) -> ContentResult<()> { Ok(()) } +pub async fn drop_unmanaged_mod( + cluster: &ClusterRow, + content_type: ContentType, + file_name: &str, + hash: &str, + db: &DbPool, +) { + if content_type != ContentType::Mod || session_owns(cluster, db).await { + return; + } + let Ok(dir) = paths::cluster_dir(&cluster.folder_name) else { + return; + }; + if manifest::mods_live_in_cluster(&dir).await { + return; + } + + let path = dir.join(content_type.folder_name()).join(file_name); + let linked = polyio::sha1_file(&path) + .await + .is_ok_and(|disk| polyio::normalize_hash(&disk) == hash); + if linked && let Err(err) = remove_entry(&path).await { + tracing::debug!(file = file_name, error = %err, "could not drop the unlinked mod from the cluster folder"); + } +} + async fn materialized_root( cluster: &ClusterRow, content_type: ContentType, diff --git a/packages/oneclient_content/src/packages/store/manifest.rs b/packages/oneclient_content/src/packages/store/manifest.rs index 6970bbaa..a2250555 100644 --- a/packages/oneclient_content/src/packages/store/manifest.rs +++ b/packages/oneclient_content/src/packages/store/manifest.rs @@ -9,7 +9,7 @@ pub const GLOBAL_MANIFEST_NAME: &str = ".oneclient-global.json"; const MANIFEST_VERSION: u32 = 1; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ManifestEntry { pub path: String, pub hash: String, diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 0b95d690..3ce59eaf 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -10,8 +10,8 @@ pub use gc::{ remove_unreferenced_files, }; pub use link::{ - LiveSync, link_or_copy, remove_entry, sweep_staging_files, try_link_materialized, - try_unlink_materialized, + LiveSync, drop_unmanaged_mod, link_or_copy, remove_entry, sweep_staging_files, + try_link_materialized, try_unlink_materialized, }; pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; @@ -183,6 +183,11 @@ impl PackageStore { artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + if let (Some(content_type), Some(link)) = (content_type, &link) { + let name = &link.cluster_file_name; + drop_unmanaged_mod(&cluster, content_type, name, hash, &ctx.db).await; + } + if let (Some(content_type), Some(link)) = (content_type, link) && link::try_unlink_materialized( &cluster, diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 8ce71299..39fce0f8 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -40,5 +40,5 @@ pub use process::{ pub use reattach::recover_sessions; pub use shared_dir::{ dematerialize_content, import_manual_content, link_cluster_logs, materialize_content, - unlink_cluster_logs, write_allowed_symlinks, + shadowed_bundle_mods, sync_cluster_mods, unlink_cluster_logs, write_allowed_symlinks, }; diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 5816a663..a9b69a05 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -5,10 +5,11 @@ 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 oneclient_db::dao::cluster_bundle as bundle_dao; use crate::LauncherResult; use crate::clusters::Cluster; -use crate::state::LauncherServices; +use crate::state::{LauncherServices, LauncherState}; use oneclient_cluster::remove_mods_link; use oneclient_common::domain::{ContentType, ProviderId}; use oneclient_common::paths; @@ -63,6 +64,8 @@ pub async fn materialize_content( game_dir: &Path, mods_in_cluster: bool, ) -> LauncherResult<()> { + let mods_sync = lock_mods_sync(cluster.id).await; + let dedicated = cluster.uses_dedicated_dir(); let cluster_dir = cluster.dir()?; let isolated = cluster.is_isolated(); @@ -106,12 +109,28 @@ pub async fn materialize_content( } else { loaded_previous.map(without_global_entries) }; - let previous_mods = manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await; let previous_global = if isolated { None } else { manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await }; + + let repair_unlinked = mods_in_cluster && !cluster.user_created && !isolated; + if repair_unlinked && manifest::mods_live_in_cluster(&cluster_dir).await { + clear_unlinked_mods_once( + services, + cluster, + &cluster_dir.join(ContentType::Mod.folder_name()), + ) + .await; + } + + if mods_in_cluster { + sync_mods_folder(services, cluster).await; + } + drop(mods_sync); + let previous_mods = manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await; + crate::game::heal::clear_zeroed_files(game_dir).await; if mods_in_cluster { crate::game::heal::clear_zeroed_mods(&cluster_dir).await; @@ -148,7 +167,7 @@ pub async fn materialize_content( } } - if mods_in_cluster && !cluster.user_created && !cluster.is_isolated() { + if repair_unlinked { clear_unlinked_mods_once( services, cluster, @@ -510,10 +529,15 @@ async fn disable_hand_removed( previous: Option<&MaterializedManifest>, ) -> Vec { let removed = hand_removed_content(dir, content_type, previous).await; - if removed.is_empty() { - return Vec::new(); - } + disable_removed(services, cluster, content_type, removed).await +} +async fn disable_removed( + services: &LauncherServices, + cluster: &Cluster, + content_type: ContentType, + removed: Vec<(String, String)>, +) -> Vec { let ctx = services.content(); let mut disabled = Vec::new(); @@ -549,6 +573,458 @@ async fn disable_hand_removed( disabled } +async fn lock_mods_sync(cluster_id: i64) -> tokio::sync::OwnedMutexGuard<()> { + static LOCKS: std::sync::Mutex< + std::collections::BTreeMap>>, + > = std::sync::Mutex::new(std::collections::BTreeMap::new()); + + let lock = { + let mut locks = LOCKS.lock().unwrap(); + locks.retain(|_, lock| std::sync::Arc::strong_count(lock) > 1); + locks.entry(cluster_id).or_default().clone() + }; + lock.lock_owned().await +} + +pub async fn sync_cluster_mods(state: &LauncherState, cluster_id: i64) -> bool { + let _sync = lock_mods_sync(cluster_id).await; + if state.games.is_active(cluster_id) { + return false; + } + let Ok(cluster) = state.clusters.get(cluster_id).await else { + return false; + }; + let Ok(cluster_dir) = cluster.dir() else { + return false; + }; + + let in_cluster = launch_uses_cluster_mods(state, &cluster).await; + if !in_cluster && !cluster.uses_dedicated_dir() { + return false; + } + if in_cluster + && !cluster.user_created + && !cluster.is_isolated() + && !unlinked_mods_cleared(&state.services, cluster_id).await + { + return false; + } + + if manifest::mods_live_in_cluster(&cluster_dir).await { + return sync_mods_folder(&state.services, &cluster).await; + } + + let dir = cluster_dir.join(ContentType::Mod.folder_name()); + let Some(linked) = linked_mods(&cluster, &state.services.content()).await else { + return false; + }; + let Some(on_disk) = jars_on_disk(&state.services, &dir, &linked).await else { + return false; + }; + let added: Vec<&str> = on_disk + .iter() + .filter(|(name, hash)| { + !linked + .iter() + .any(|link| link.hash == *hash || link.cluster_file_name == *name) + }) + .map(|(name, _)| name.as_str()) + .collect(); + + import_added(&state.services, &cluster, &dir, &added, true).await +} + +async fn launch_uses_cluster_mods(state: &LauncherState, cluster: &Cluster) -> bool { + let global = state.settings.read().global_game_settings.clone(); + let custom_args = state + .clusters + .resolve_settings(&global, cluster) + .await + .ok() + .and_then(|profile| profile.launch_args) + .unwrap_or_default(); + let loader_version = cluster.mc_loader_version.as_deref().unwrap_or("999.0.0"); + + super::fabric::uses_cluster_mods_folder(cluster.mc_loader, Some(loader_version), &custom_args) +} + +async fn import_added( + services: &LauncherServices, + cluster: &Cluster, + dir: &Path, + added: &[&str], + discard_stale: bool, +) -> bool { + let mut changed = false; + for name in added { + let Some(Ok(row)) = adopt_file( + services, + cluster, + &dir.join(name), + ContentType::Mod, + discard_stale, + ) + .await + else { + continue; + }; + tracing::info!(file = %name, "registered a mod added by hand"); + if row.file_name != *name + && let Err(err) = + artifact_dao::link_cluster_artifact(&services.db, cluster.id, &row.hash, name).await + { + tracing::warn!(file = %name, error = %err, "failed to keep the added mod's name"); + } + changed = true; + } + + changed +} + +fn merge_live_writes( + mut current: MaterializedManifest, + previous: &MaterializedManifest, + fresh: &MaterializedManifest, +) -> MaterializedManifest { + current + .entries + .retain(|entry| !previous.entries.contains(entry) || fresh.entries.contains(entry)); + + for entry in &fresh.entries { + if !previous.entries.contains(entry) && !current.contains(&entry.path) { + current.entries.push(entry.clone()); + } + } + + current +} + +async fn sync_mods_folder(services: &LauncherServices, cluster: &Cluster) -> bool { + let Ok(cluster_dir) = cluster.dir() else { + return false; + }; + let Some(previous) = manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME) + .await + .filter(|loaded| loaded.cluster_id == cluster.id) + else { + return false; + }; + let dir = cluster_dir.join(ContentType::Mod.folder_name()); + let ctx = services.content(); + + let Some(linked) = linked_mods(cluster, &ctx).await else { + return false; + }; + let Some(on_disk) = jars_on_disk(services, &dir, &linked).await else { + return false; + }; + let names_on_disk: HashSet<&str> = on_disk.iter().map(|(name, _)| name.as_str()).collect(); + let mut changed = false; + let mut added = Vec::new(); + let mut replaced: Vec<(String, String, String)> = Vec::new(); + + for (name, hash) in &on_disk { + let relative = manifest::entry_path(ContentType::Mod.folder_name(), name); + let materialized = previous + .entries + .iter() + .any(|entry| entry.path == relative && entry.hash == *hash); + + if materialized { + continue; + } + + replaced.extend( + linked + .iter() + .filter(|link| { + link.enabled && link.cluster_file_name == *name && link.hash != *hash + }) + .map(|link| (name.clone(), link.hash.clone(), hash.clone())), + ); + + if let Some(link) = linked.iter().find(|link| &link.hash == hash) { + if link.cluster_file_name != *name + && !names_on_disk.contains(link.cluster_file_name.as_str()) + { + tracing::info!(from = %link.cluster_file_name, to = %name, "mod renamed by hand"); + match artifact_dao::link_cluster_artifact(&services.db, cluster.id, hash, name) + .await + { + Ok(_) => changed = true, + Err(err) => { + tracing::warn!(file = %name, error = %err, "failed to follow a renamed mod") + } + } + } + + if !link.enabled { + tracing::info!(file = %name, "mod put back by hand; switching it on"); + match oneclient_content::bundles::set_artifact_enabled_to( + cluster.id, hash, true, &ctx, + ) + .await + { + Ok(_) => changed = true, + Err(err) => { + tracing::warn!(file = %name, error = %err, "failed to switch a restored mod on") + } + } + } + continue; + } + + added.push(name.as_str()); + } + + changed |= import_added(services, cluster, &dir, &added, false).await; + + let Some(linked) = linked_mods(cluster, &ctx).await else { + return changed; + }; + + for (name, old_hash, new_hash) in &replaced { + if !linked.iter().any(|link| link.hash == *new_hash) { + continue; + } + tracing::info!(file = %name, "mod replaced by hand; switching the old copy off"); + detach_overwritten_link(services, &dir.join(name), old_hash).await; + let relative = manifest::entry_path(ContentType::Mod.folder_name(), name); + forget_manifest_entry(&cluster_dir, &relative).await; + match PackageStore::set_artifact_enabled_to(cluster.id, old_hash, false, &ctx).await { + Ok(_) => changed = true, + Err(err) => { + tracing::warn!(file = %name, error = %err, "failed to switch the replaced mod off") + } + } + } + + let removed = { + let _guard = manifest::lock().await; + match manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await { + Some(fresh) => { + let mut live = live_mod_entries(&previous, &linked); + live.entries.retain(|entry| fresh.entries.contains(entry)); + hand_removed_content(&dir, ContentType::Mod, Some(&live)).await + } + None => Vec::new(), + } + }; + let disabled = disable_removed(services, cluster, ContentType::Mod, removed).await; + if !disabled.is_empty() { + changed = true; + let (title, body) = removal_notice(&disabled, cluster.shares_content(ContentType::Mod)); + services.events.notify(title).body(body).send(); + } + + let Some(linked) = linked_mods(cluster, &ctx).await else { + return changed; + }; + let mut current = next_mods_manifest(&previous, on_disk, &linked); + for entry in &previous.entries { + let path = cluster_dir.join(&entry.path); + if !current.contains(&entry.path) + && polyio::symlink_metadata(&path).await.is_ok() + && !polyio::try_exists(&path).await.unwrap_or(true) + { + current.entries.push(entry.clone()); + } + } + { + let _guard = manifest::lock().await; + let current = match manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await { + Some(fresh) => merge_live_writes(current, &previous, &fresh), + None => current, + }; + manifest::save(&cluster_dir, manifest::MODS_MANIFEST_NAME, ¤t).await; + } + + changed +} + +fn next_mods_manifest( + previous: &MaterializedManifest, + on_disk: Vec<(String, String)>, + linked: &[oneclient_content::packages::LinkedArtifactInfo], +) -> MaterializedManifest { + let on_disk = MaterializedManifest::new( + previous.cluster_id, + on_disk + .into_iter() + .map(|(name, hash)| ManifestEntry { + path: manifest::entry_path(ContentType::Mod.folder_name(), &name), + hash, + }) + .collect(), + ); + let live = live_mod_entries(&on_disk, linked); + + let entries = on_disk + .entries + .into_iter() + .filter(|entry| live.entries.contains(entry) || previous.entries.contains(entry)) + .collect(); + + MaterializedManifest::new(previous.cluster_id, entries) +} + +async fn linked_mods( + cluster: &Cluster, + ctx: &oneclient_content::ContentCtx, +) -> Option> { + match PackageStore::list_linked_artifacts(cluster.id, ctx).await { + Ok(linked) => Some( + linked + .into_iter() + .filter(|link| link.content_type == ContentType::Mod) + .collect(), + ), + Err(err) => { + tracing::warn!(error = %err, "cannot list links; leaving the mods folder alone"); + None + } + } +} + +async fn jars_on_disk( + services: &LauncherServices, + dir: &Path, + linked: &[oneclient_content::packages::LinkedArtifactInfo], +) -> Option> { + let mut entries = match polyio::read_dir(dir).await { + Ok(entries) => entries, + Err(err) => { + tracing::warn!(dir = %dir.display(), error = %err, "cannot read the mods folder"); + return None; + } + }; + + let mut out = Vec::new(); + loop { + let entry = match entries.next_entry().await { + Ok(Some(entry)) => entry, + Ok(None) => break, + Err(err) => { + tracing::warn!(dir = %dir.display(), error = %err, "cannot list the mods folder"); + return None; + } + }; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { + continue; + }; + if name.starts_with('.') || !has_content_extension(ContentType::Mod, &name) { + continue; + } + + if let Some(hash) = cache_link_hash(services, &path, &name, linked).await { + out.push((name, hash)); + continue; + } + + let hash = match polyio::stat(&path).await { + Ok(meta) if !meta.is_file() || meta.len() == 0 => continue, + Ok(_) => polyio::sha1_file(&path).await, + Err(err) => Err(err), + }; + let hash = match hash { + Ok(hash) => polyio::normalize_hash(&hash), + Err(_) if !polyio::try_exists(&path).await.unwrap_or(true) => continue, + Err(err) => { + tracing::warn!(file = %name, error = %err, "cannot read a jar in the mods folder"); + return None; + } + }; + + if !linked.iter().any(|link| link.hash == hash) + && let Err(err) = polyio::read_zip_file_entries(&path, |_| false).await + { + tracing::debug!(file = %name, error = %err, "not a whole jar yet; leaving it for a later pass"); + continue; + } + out.push((name, hash)); + } + + Some(out) +} + +async fn cache_link_hash( + services: &LauncherServices, + path: &Path, + name: &str, + linked: &[oneclient_content::packages::LinkedArtifactInfo], +) -> Option { + let target = polyio::read_link(path).await.ok()?; + + for link in linked.iter().filter(|link| link.cluster_file_name == name) { + let Some(artifact) = artifact_dao::get_artifact_by_hash(&services.db, &link.hash) + .await + .ok() + .flatten() + else { + continue; + }; + if artifact_absolute_path(&artifact.path).is_ok_and(|cached| cached == target) { + return cache_intact(&target, &link.hash, artifact.size_bytes) + .await + .then(|| link.hash.clone()); + } + } + + None +} + +async fn detach_overwritten_link(services: &LauncherServices, path: &Path, old_hash: &str) { + let Ok(target) = polyio::read_link(path).await else { + return; + }; + let cached = artifact_dao::get_artifact_by_hash(&services.db, old_hash) + .await + .ok() + .flatten() + .and_then(|artifact| artifact_absolute_path(&artifact.path).ok()); + if cached.as_deref() != Some(target.as_path()) { + return; + } + + if let Err(err) = move_entry(&target, path).await { + tracing::warn!(file = %path.display(), error = %err, "failed to move a replaced mod out of the cache"); + } +} + +async fn forget_manifest_entry(cluster_dir: &Path, relative: &str) { + let _guard = manifest::lock().await; + if let Some(mut loaded) = manifest::load(cluster_dir, manifest::MODS_MANIFEST_NAME).await { + loaded.entries.retain(|entry| entry.path != relative); + manifest::save(cluster_dir, manifest::MODS_MANIFEST_NAME, &loaded).await; + } +} + +fn live_mod_entries( + previous: &MaterializedManifest, + linked: &[oneclient_content::packages::LinkedArtifactInfo], +) -> MaterializedManifest { + let live: HashSet<(String, &str)> = linked + .iter() + .filter(|link| link.content_type == ContentType::Mod && link.enabled) + .map(|link| { + ( + manifest::entry_path(ContentType::Mod.folder_name(), &link.cluster_file_name), + link.hash.as_str(), + ) + }) + .collect(); + + let entries = previous + .entries + .iter() + .filter(|entry| live.contains(&(entry.path.clone(), entry.hash.as_str()))) + .cloned() + .collect(); + + MaterializedManifest::new(previous.cluster_id, entries) +} + // switches a globally installed artifact off for every cluster that has it async fn disable_globally( cluster: &Cluster, @@ -715,7 +1191,165 @@ async fn desired_linked( }); } - Ok(desired) + Ok(skip_shadowed_bundle_mods(services, cluster, desired).await) +} + +async fn skip_shadowed_bundle_mods( + services: &LauncherServices, + cluster: &Cluster, + mut desired: Vec, +) -> Vec { + let mods: Vec<(String, PathBuf)> = desired + .iter() + .filter(|item| item.content_type == ContentType::Mod) + .map(|item| (item.hash.clone(), item.src.clone())) + .collect(); + let shadowed = shadowed_bundle_hashes(services, cluster.id, &mods).await; + if shadowed.is_empty() { + return desired; + } + + static SAID: std::sync::Mutex> = + std::sync::Mutex::new(std::collections::BTreeSet::new()); + + let mut newly_skipped = Vec::new(); + if let Ok(cluster_dir) = cluster.dir() { + let in_cluster = manifest::mods_live_in_cluster(&cluster_dir).await; + for item in desired.iter().filter(|item| shadowed.contains(&item.hash)) { + let newly = if in_cluster { + polyio::symlink_metadata(cluster_dir.join(item.relative_path())) + .await + .is_ok() + } else { + SAID.lock().unwrap().insert((cluster.id, item.hash.clone())) + }; + if newly { + newly_skipped.push(item.file_name.clone()); + } + } + } + if !newly_skipped.is_empty() { + services + .events + .notify("Bundle mod skipped") + .body(format!( + "Your own copy of {} is used instead of the bundle's. Remove yours to get the bundle's back.", + removal_summary(&newly_skipped) + )) + .send(); + } + + desired.retain(|item| { + let skip = shadowed.contains(&item.hash); + if skip { + tracing::info!( + cluster_id = cluster.id, + file = %item.file_name, + "skipping a bundle mod the user added their own copy of" + ); + } + !skip + }); + + desired +} + +pub async fn shadowed_bundle_mods(state: &LauncherState, cluster_id: i64) -> HashSet { + let services = &state.services; + let Ok(linked) = PackageStore::list_linked_artifacts(cluster_id, &services.content()).await + else { + return HashSet::new(); + }; + + let mut mods = Vec::new(); + for link in linked + .iter() + .filter(|link| link.content_type == ContentType::Mod && link.enabled) + { + if let Ok(Some(artifact)) = + artifact_dao::get_artifact_by_hash(&services.db, &link.hash).await + && let Ok(path) = artifact_absolute_path(&artifact.path) + { + mods.push((link.hash.clone(), path)); + } + } + + shadowed_bundle_hashes(services, cluster_id, &mods).await +} + +async fn shadowed_bundle_hashes( + services: &LauncherServices, + cluster_id: i64, + mods: &[(String, PathBuf)], +) -> HashSet { + let bundled: HashSet = match bundle_dao::list_bundle_tracked(&services.db, cluster_id) + .await + { + Ok(rows) => rows.into_iter().map(|row| row.hash).collect(), + Err(err) => { + tracing::warn!(error = %err, "cannot list bundle mods; not checking for duplicates"); + return HashSet::new(); + } + }; + if bundled.is_empty() { + return HashSet::new(); + } + + let mut copies = Vec::new(); + for bundle_pass in [false, true] { + let pass = mods + .iter() + .filter(|(hash, _)| bundled.contains(hash) == bundle_pass); + copies.extend( + futures_util::future::join_all(pass.map(async |(hash, jar)| ModCopy { + hash: hash.clone(), + bundled: bundle_pass, + mod_id: cached_mod_id(hash, jar).await, + })) + .await, + ); + + if !bundle_pass && copies.iter().all(|copy| copy.mod_id.is_none()) { + return HashSet::new(); + } + } + + shadowed_bundle_copies(&copies) +} + +async fn cached_mod_id(hash: &str, jar: &Path) -> Option { + static IDS: std::sync::Mutex>> = + std::sync::Mutex::new(std::collections::BTreeMap::new()); + + if let Some(id) = IDS.lock().unwrap().get(hash) { + return id.clone(); + } + + let id = oneclient_content::packages::local_manifest::read_jar_mod_id(jar).await; + if id.is_some() || polyio::try_exists(jar).await.unwrap_or(false) { + IDS.lock().unwrap().insert(hash.to_owned(), id.clone()); + } + id +} + +struct ModCopy { + hash: String, + bundled: bool, + mod_id: Option, +} + +fn shadowed_bundle_copies(mods: &[ModCopy]) -> HashSet { + let users: HashSet<&str> = mods + .iter() + .filter(|copy| !copy.bundled) + .filter_map(|copy| copy.mod_id.as_deref()) + .collect(); + + mods.iter() + .filter(|copy| copy.bundled) + .filter(|copy| copy.mod_id.as_deref().is_some_and(|id| users.contains(id))) + .map(|copy| copy.hash.clone()) + .collect() } async fn cached_file( @@ -845,11 +1479,9 @@ 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); + let id = oneclient_cluster::unlinked_mods_repair_id(cluster.id); match migration_dao::is_applied(&services.db, &id).await { Ok(true) => return, Ok(false) => {} @@ -895,6 +1527,13 @@ async fn clear_unlinked_mods_once(services: &LauncherServices, cluster: &Cluster mark_repair_applied(services, &id).await; } +async fn unlinked_mods_cleared(services: &LauncherServices, cluster_id: i64) -> bool { + let id = oneclient_cluster::unlinked_mods_repair_id(cluster_id); + migration_dao::is_applied(&services.db, &id) + .await + .unwrap_or(false) +} + 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"); @@ -1047,6 +1686,7 @@ pub async fn import_manual_content( cluster: &Cluster, game_dir: &Path, ) { + let _sync = lock_mods_sync(cluster.id).await; let mods_in_cluster = match cluster.dir() { Ok(dir) => manifest::mods_live_in_cluster(&dir).await, Err(_) => false, @@ -1202,41 +1842,53 @@ async fn import_from_dir( continue; } - if !cluster.is_isolated() && is_stale_launcher_content(services, &path).await { - tracing::debug!( - file = name, - dir = %dir.display(), - "discarding stale launcher content; the cache still holds it" - ); - if let Err(err) = polyio::remove_file(&path).await { - tracing::warn!(file = name, error = %err, "failed to discard stale content"); - } - continue; - } - - match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()) - .await - { - Ok(row) => { + match adopt_file(services, cluster, &path, content_type, true).await { + Some(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, - error = %err, - "failed to register manually-added content" - ); + Some(Err(_)) => { failed.insert(name.to_owned()); } + None => {} } } failed } +async fn adopt_file( + services: &LauncherServices, + cluster: &Cluster, + path: &Path, + content_type: ContentType, + discard_stale: bool, +) -> Option> { + if discard_stale && !cluster.is_isolated() && is_stale_launcher_content(services, path).await { + tracing::debug!( + file = %path.display(), + "discarding stale launcher content; the cache still holds it" + ); + if let Err(err) = polyio::remove_file(path).await { + tracing::warn!(file = %path.display(), error = %err, "failed to discard stale content"); + } + return None; + } + + let imported = + PackageStore::import_local_file(path, content_type, cluster.id, &services.content()).await; + if let Err(err) = &imported { + tracing::warn!( + file = %path.display(), + error = %err, + "failed to register manually-added content" + ); + } + Some(imported) +} + async fn discard_adopted_original( row: &oneclient_db::models::ArtifactRow, path: &Path, @@ -2215,6 +2867,95 @@ mod tests { ) } + #[test] + fn a_bundle_mod_the_user_added_their_own_copy_of_is_skipped() { + let copy = |hash: &str, bundled: bool, mod_id: Option<&str>| ModCopy { + hash: hash.into(), + bundled, + mod_id: mod_id.map(Into::into), + }; + + let shadowed = shadowed_bundle_copies(&[ + copy("bundle-argentum", true, Some("argentum")), + copy("user-argentum", false, Some("argentum")), + copy("bundle-sodium", true, Some("sodium")), + copy("bundle-unreadable", true, None), + copy("user-unreadable", false, None), + ]); + + assert_eq!(shadowed, HashSet::from(["bundle-argentum".to_owned()])); + } + + #[test] + fn the_next_manifest_keeps_live_jars_and_the_launchers_leftovers() { + let previous = mods_manifest(&["leftover.jar"]); + let on_disk = vec![ + ("leftover.jar".to_owned(), "hash-leftover.jar".to_owned()), + ("live.jar".to_owned(), "live.jar-hash".to_owned()), + ("off.jar".to_owned(), "off.jar-hash".to_owned()), + ( + "unregistered.jar".to_owned(), + "unregistered-hash".to_owned(), + ), + ]; + + let next = next_mods_manifest( + &previous, + on_disk, + &[link("live.jar", true), link("off.jar", false)], + ); + + let paths: Vec<&str> = next.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!( + paths, + vec![ + manifest::entry_path("mods", "leftover.jar").as_str(), + manifest::entry_path("mods", "live.jar").as_str(), + ], + "a switched-off jar on disk must stay out so putting it back reads as the user's doing" + ); + } + + #[test] + fn a_live_install_or_removal_during_a_pass_survives_its_save() { + let previous = mods_manifest(&["kept.jar", "removed.jar"]); + let fresh = mods_manifest(&["kept.jar", "installed.jar"]); + let current = mods_manifest(&["kept.jar", "removed.jar", "dropped-in.jar"]); + + let merged = merge_live_writes(current, &previous, &fresh); + + let paths: Vec<&str> = merged.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!( + paths, + vec![ + manifest::entry_path("mods", "kept.jar").as_str(), + manifest::entry_path("mods", "dropped-in.jar").as_str(), + manifest::entry_path("mods", "installed.jar").as_str(), + ] + ); + } + + #[test] + fn only_enabled_links_under_the_same_name_and_hash_can_be_hand_removed() { + let manifest = mods_manifest(&["live.jar", "off.jar", "replaced.jar", "renamed.jar"]); + let mut live = link("live.jar", true); + live.hash = "hash-live.jar".into(); + let mut off = link("off.jar", false); + off.hash = "hash-off.jar".into(); + let mut updated = link("replaced.jar", true); + updated.hash = "hash-new".into(); + let mut renamed = link("renamed-2.jar", true); + renamed.hash = "hash-renamed.jar".into(); + + let kept = live_mod_entries(&manifest, &[live, off, updated, renamed]); + + let paths: Vec<&str> = kept.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!( + paths, + vec![manifest::entry_path("mods", "live.jar").as_str()] + ); + } + #[tokio::test] async fn a_jar_the_user_deleted_is_reported_with_its_hash() { let dir = mods_scratch("hand_removed", &["kept.jar", "also_kept.jar"]).await; diff --git a/packages/oneclient_core/tests/mods_folder_sync.rs b/packages/oneclient_core/tests/mods_folder_sync.rs new file mode 100644 index 00000000..2e0ef58d --- /dev/null +++ b/packages/oneclient_core/tests/mods_folder_sync.rs @@ -0,0 +1,124 @@ +use oneclient_common::domain::GameLoader; +use oneclient_content::packages::PackageStore; +use oneclient_content::packages::store::manifest::{self, MaterializedManifest}; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_core::game::sync_cluster_mods; + +fn jar(tag: &str) -> Vec { + let mut bytes = b"PK\x05\x06".to_vec(); + bytes.extend([0; 16]); + bytes.extend((tag.len() as u16).to_le_bytes()); + bytes.extend(tag.as_bytes()); + bytes +} + +#[tokio::test] +async fn hand_edits_to_the_mods_folder_reach_the_database() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let global = state.settings.read().global_game_settings.clone(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Sync", "1.21.1", GameLoader::Fabric), + ) + .await + .unwrap(); + + let cluster_dir = cluster.dir().unwrap(); + let mods = cluster_dir.join("mods"); + std::fs::create_dir_all(&mods).unwrap(); + manifest::save( + &cluster_dir, + manifest::MODS_MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, Vec::new()), + ) + .await; + + let ctx = state.services.content(); + let links = || async { + let mut links: Vec<(String, bool)> = PackageStore::list_linked_artifacts(cluster.id, &ctx) + .await + .unwrap() + .into_iter() + .map(|link| (link.cluster_file_name, link.enabled)) + .collect(); + links.sort(); + links + }; + + std::fs::write(mods.join("a.jar"), jar("one")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + assert_eq!(links().await, vec![("a.jar".to_owned(), true)]); + assert!( + !sync_cluster_mods(&state, cluster.id).await, + "a second pass over an untouched folder changes nothing" + ); + + std::fs::rename(mods.join("a.jar"), mods.join("b.jar")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + assert_eq!(links().await, vec![("b.jar".to_owned(), true)]); + + std::fs::write(mods.join("b.jar"), jar("two")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + assert_eq!( + links().await, + vec![("b.jar".to_owned(), false), ("b.jar".to_owned(), true)], + "the old build is switched off and the new one linked" + ); + assert!( + mods.join("b.jar").exists(), + "switching the old build off must not take the new jar with it" + ); + + std::fs::remove_file(mods.join("b.jar")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + assert_eq!( + links().await, + vec![("b.jar".to_owned(), false), ("b.jar".to_owned(), false)] + ); + + std::fs::write(mods.join("b.jar"), jar("two")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + assert_eq!( + links().await, + vec![("b.jar".to_owned(), false), ("b.jar".to_owned(), true)], + "a jar put back is switched on again" + ); +} + +#[tokio::test] +async fn a_mod_removed_in_the_launcher_stays_removed_before_the_first_launch() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let global = state.settings.read().global_game_settings.clone(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Unlaunched", "1.21.1", GameLoader::Fabric), + ) + .await + .unwrap(); + + let mods = cluster.dir().unwrap().join("mods"); + std::fs::create_dir_all(&mods).unwrap(); + let ctx = state.services.content(); + + std::fs::write(mods.join("a.jar"), jar("one")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + let linked = PackageStore::list_linked_artifacts(cluster.id, &ctx) + .await + .unwrap(); + + oneclient_content::bundles::remove_artifact_from_cluster( + cluster.id, + &linked[0].hash, + false, + &ctx, + ) + .await + .unwrap(); + + assert!(!mods.join("a.jar").exists()); + assert!(!sync_cluster_mods(&state, cluster.id).await); +}