mods folder sync (#937)

This commit is contained in:
Julian Chang 2026-10-05 17:20:04 -04:00 • committed by GitHub
parent 1ab294f293
commit abf708fdb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1163 additions and 52 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<HashSet<String>>;
type Err = LauncherError;
type Keys = ClusterId;
async fn run(&self, cluster_id: &Self::Keys) -> Result<Self::Ok, Self::Err> {
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<HashSet<String>> {
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<PathBuf>) {
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")
})
})
}

View file

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

View file

@ -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::<ClusterContentQuery>::invalidate_all(),
)
.await;
timed(
"shadowed_mods",
QueriesStorage::<ShadowedModsQuery>::invalidate_all(),
)
.await;
timed(
"bundle_overrides",
QueriesStorage::<BundleOverridesQuery>::invalidate_all(),

View file

@ -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<PathBuf>,
}
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())
}
}

View file

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

View file

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

View file

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

View file

@ -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)) => {

View file

@ -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<String> {
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<String> {
let id = match manifest {
NEOFORGE | FORGE => toml::from_str::<toml::Value>(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<GameLoader> {
let entries = polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name))
@ -369,6 +412,39 @@ fn entry_path(raw: String) -> Option<String> {
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(

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String> {
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<String> {
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<i64, std::sync::Arc<tokio::sync::Mutex<()>>>,
> = 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, &current).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<Vec<oneclient_content::packages::LinkedArtifactInfo>> {
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<Vec<(String, String)>> {
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<String> {
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<Desired>,
) -> Vec<Desired> {
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::collections::BTreeSet<(i64, String)>> =
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<String> {
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<String> {
let bundled: HashSet<String> = 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<String> {
static IDS: std::sync::Mutex<std::collections::BTreeMap<String, Option<String>>> =
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<String>,
}
fn shadowed_bundle_copies(mods: &[ModCopy]) -> HashSet<String> {
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<ManifestEntry> {
entries
}
const UNLINKED_MODS_REPAIR: &str = "clear-unlinked-mods";
#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")]
async fn clear_unlinked_mods_once(services: &LauncherServices, cluster: &Cluster, mods_dir: &Path) {
let id = format!("{UNLINKED_MODS_REPAIR}:{}", cluster.id);
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<oneclient_content::ContentResult<oneclient_db::models::ArtifactRow>> {
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;

View file

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