mods folder sync (#937)
This commit is contained in:
parent
1ab294f293
commit
abf708fdb4
19 changed files with 1163 additions and 52 deletions
5
.changeset/mods_folder_syncs_with_the_launcher.md
Normal file
5
.changeset/mods_folder_syncs_with_the_launcher.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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, ¤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<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;
|
||||
|
|
|
|||
124
packages/oneclient_core/tests/mods_folder_sync.rs
Normal file
124
packages/oneclient_core/tests/mods_folder_sync.rs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue