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,
|
manifest_default: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
opted_in: true,
|
opted_in: true,
|
||||||
|
shadowed: false,
|
||||||
advanced: false,
|
advanced: false,
|
||||||
update_available: false,
|
update_available: false,
|
||||||
// Offered, not installed, so there is no recency badge to show
|
// Offered, not installed, so there is no recency badge to show
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ pub struct PackageEntry {
|
||||||
pub advanced: bool,
|
pub advanced: bool,
|
||||||
/// Only set for browser-installed content bundle packages use the bundle update flow
|
/// Only set for browser-installed content bundle packages use the bundle update flow
|
||||||
pub update_available: bool,
|
pub update_available: bool,
|
||||||
|
pub shadowed: bool,
|
||||||
/// Recency badge state cleared once the user views the list
|
/// Recency badge state cleared once the user views the list
|
||||||
pub seen_status: SeenStatus,
|
pub seen_status: SeenStatus,
|
||||||
pub essential: Option<&'static EssentialPackage>,
|
pub essential: Option<&'static EssentialPackage>,
|
||||||
|
|
@ -469,7 +470,8 @@ pub(crate) fn grid_card(
|
||||||
.into_element()
|
.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()
|
rect()
|
||||||
.horizontal()
|
.horizontal()
|
||||||
.width(Size::fill())
|
.width(Size::fill())
|
||||||
|
|
@ -482,6 +484,7 @@ pub(crate) fn grid_card(
|
||||||
.cross_align(Alignment::Center)
|
.cross_align(Alignment::Center)
|
||||||
.spacing(4.)
|
.spacing(4.)
|
||||||
.maybe_child(item.is_outdated().then(outdated_badge))
|
.maybe_child(item.is_outdated().then(outdated_badge))
|
||||||
|
.maybe_child(item.shadowed.then(shadowed_badge))
|
||||||
.maybe_child(item.recency_badge())
|
.maybe_child(item.recency_badge())
|
||||||
.into_element()
|
.into_element()
|
||||||
});
|
});
|
||||||
|
|
@ -767,6 +770,7 @@ fn package_info(
|
||||||
local_badge()
|
local_badge()
|
||||||
})
|
})
|
||||||
.maybe_child(item.is_outdated().then(outdated_badge))
|
.maybe_child(item.is_outdated().then(outdated_badge))
|
||||||
|
.maybe_child(item.shadowed.then(shadowed_badge))
|
||||||
.maybe_child(item.recency_badge()),
|
.maybe_child(item.recency_badge()),
|
||||||
)
|
)
|
||||||
.maybe(!item.author.is_empty(), |el| {
|
.maybe(!item.author.is_empty(), |el| {
|
||||||
|
|
@ -868,6 +872,10 @@ fn outdated_badge() -> Element {
|
||||||
status_tag("Update available", colors::brand())
|
status_tag("Update available", colors::brand())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shadowed_badge() -> Element {
|
||||||
|
status_tag("Using your copy", colors::brand())
|
||||||
|
}
|
||||||
|
|
||||||
fn new_badge() -> Element {
|
fn new_badge() -> Element {
|
||||||
status_tag("New", colors::success())
|
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_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_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_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_mods_folder_sync, use_named_profiles, use_onboarding_bundles, use_package_categories,
|
||||||
use_package_project, use_package_search, use_package_updates, use_package_versions_when,
|
use_package_meta_batch, use_package_project, use_package_search, use_package_updates,
|
||||||
use_player_profile, use_player_skin, use_provider_versions, use_refresh_account,
|
use_package_versions_when, use_player_profile, use_player_skin, use_provider_versions,
|
||||||
use_refresh_all_accounts, use_remove_account, use_screenshot_action,
|
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_screenshot_folder_watch, use_set_default_account, use_shadowed_mods, use_storage_action,
|
||||||
use_terms, use_upload_log, use_version_art, use_version_art_gallery, use_version_loaders,
|
use_storage_report, use_terms, use_upload_log, use_version_art, use_version_art_gallery,
|
||||||
use_version_metadata, use_versions, version_list, versions_metadata, versions_total,
|
use_version_loaders, use_version_metadata, use_versions, version_list, versions_metadata,
|
||||||
|
versions_total,
|
||||||
};
|
};
|
||||||
pub use queries::{
|
pub use queries::{
|
||||||
add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents,
|
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 freya::query::{Query, QueryCapability, UseQuery, use_query};
|
||||||
|
use notify::{Event, EventKind};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use oneclient_content::packages::release_migration::has_packages_to_migrate;
|
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));
|
let query = use_query(Query::new(routes, MigratableRoutesQuery));
|
||||||
super::state::settled_or_loading(&query).unwrap_or_default()
|
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,
|
changelog_entries, changelog_error, changelog_is_loading, latest_changelog_version,
|
||||||
use_changelog,
|
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 clusters::{use_cluster, use_clusters};
|
||||||
pub use disable_warnings::{DisableWarningsQuery, disable_warnings, use_disable_warnings};
|
pub use disable_warnings::{DisableWarningsQuery, disable_warnings, use_disable_warnings};
|
||||||
pub(crate) use folder_watch::use_folder_watch;
|
pub(crate) use folder_watch::use_folder_watch;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery};
|
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::clusters::ListClustersQuery;
|
||||||
use super::package_updates::PackageUpdatesQuery;
|
use super::package_updates::PackageUpdatesQuery;
|
||||||
use super::settings_profiles::{
|
use super::settings_profiles::{
|
||||||
|
|
@ -32,6 +32,11 @@ pub async fn invalidate_cluster_queries() {
|
||||||
QueriesStorage::<ClusterContentQuery>::invalidate_all(),
|
QueriesStorage::<ClusterContentQuery>::invalidate_all(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
timed(
|
||||||
|
"shadowed_mods",
|
||||||
|
QueriesStorage::<ShadowedModsQuery>::invalidate_all(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
timed(
|
timed(
|
||||||
"bundle_overrides",
|
"bundle_overrides",
|
||||||
QueriesStorage::<BundleOverridesQuery>::invalidate_all(),
|
QueriesStorage::<BundleOverridesQuery>::invalidate_all(),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use freya::prelude::*;
|
use freya::prelude::*;
|
||||||
use oneclient_content::packages::ContentType;
|
use oneclient_content::packages::ContentType;
|
||||||
|
|
||||||
use crate::hooks::{
|
use crate::hooks::{
|
||||||
bundle_overrides_map, bundles_with_status_items, cluster_content_items, stale_hashes,
|
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;
|
use crate::layout::cluster_content;
|
||||||
|
|
||||||
|
|
@ -24,16 +28,17 @@ impl Component for ClusterMods {
|
||||||
let bundles = use_bundles_with_status(self.cluster_id);
|
let bundles = use_bundles_with_status(self.cluster_id);
|
||||||
let overrides = use_bundle_overrides(self.cluster_id);
|
let overrides = use_bundle_overrides(self.cluster_id);
|
||||||
let updates = use_package_updates(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 bundle_items = bundles_with_status_items(&bundles);
|
||||||
let content_items = cluster_content_items(&content);
|
let content_items = cluster_content_items(&content);
|
||||||
let meta = use_content_meta(&content_items, &bundle_items, ContentType::Mod);
|
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();
|
return cluster_not_found();
|
||||||
};
|
};
|
||||||
|
|
||||||
let all_categories = bundle_categories(&bundle_items);
|
let all_categories = bundle_categories(&bundle_items);
|
||||||
let items = bundle_packages(
|
let mut items = bundle_packages(
|
||||||
content_items,
|
content_items,
|
||||||
&bundle_items,
|
&bundle_items,
|
||||||
&bundle_overrides_map(&overrides),
|
&bundle_overrides_map(&overrides),
|
||||||
|
|
@ -41,8 +46,18 @@ impl Component for ClusterMods {
|
||||||
&stale_hashes(&updates),
|
&stale_hashes(&updates),
|
||||||
ContentType::Mod,
|
ContentType::Mod,
|
||||||
);
|
);
|
||||||
|
for item in &mut items {
|
||||||
|
item.shadowed = item
|
||||||
|
.hash
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|hash| shadowed.contains(hash));
|
||||||
|
}
|
||||||
|
|
||||||
cluster_content()
|
cluster_content()
|
||||||
|
.child(ModsFolderSync {
|
||||||
|
cluster_id: self.cluster_id,
|
||||||
|
folder: oneclient_common::paths::cluster_mods_dir(&cluster.folder_name).ok(),
|
||||||
|
})
|
||||||
.child(
|
.child(
|
||||||
PackageManager::new(
|
PackageManager::new(
|
||||||
"Mods",
|
"Mods",
|
||||||
|
|
@ -58,3 +73,22 @@ impl Component for ClusterMods {
|
||||||
.into_element()
|
.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,
|
update_available,
|
||||||
hidden,
|
hidden,
|
||||||
opted_in,
|
opted_in,
|
||||||
|
shadowed: false,
|
||||||
advanced: false,
|
advanced: false,
|
||||||
seen_status: installed_info.map(|i| i.seen_status).unwrap_or_default(),
|
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 cluster::{Cluster, ClusterLinkTarget, encode_tags, remove_mods_link};
|
||||||
pub use error::{ClusterError, ClusterResult};
|
pub use error::{ClusterError, ClusterResult};
|
||||||
pub use identity::InstanceIdentity;
|
pub use identity::InstanceIdentity;
|
||||||
pub use manager::ClusterManager;
|
pub use manager::{ClusterManager, unlinked_mods_repair_id};
|
||||||
pub use oneclient_db::models::ClusterKind;
|
pub use oneclient_db::models::ClusterKind;
|
||||||
pub use options::{ClusterUpdate, CreateClusterOptions};
|
pub use options::{ClusterUpdate, CreateClusterOptions};
|
||||||
pub use profile::{GameSettingsProfile, PackageUpdateMode, SettingsOsExtra};
|
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 oneclient_db::models::{ClusterId, ClusterKind, ClusterPatch, NewCluster};
|
||||||
|
|
||||||
use crate::error::ClusterResult;
|
use crate::error::ClusterResult;
|
||||||
|
|
@ -169,6 +171,12 @@ impl ClusterManager {
|
||||||
{
|
{
|
||||||
Ok(cluster) => {
|
Ok(cluster) => {
|
||||||
tracing::info!(cluster_id = cluster.id, name = %cluster.name, "created 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)
|
Ok(cluster)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
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))]
|
#[tracing::instrument(level = "debug", skip(pool))]
|
||||||
async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> ClusterResult<()> {
|
async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> ClusterResult<()> {
|
||||||
if profile_dao::get_by_name(pool, name).await?.is_none() {
|
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::ctx::ContentCtx;
|
||||||
use crate::error::ContentError;
|
use crate::error::ContentError;
|
||||||
use crate::error::ContentResult;
|
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 crate::packages::types::ExternalFile;
|
||||||
use oneclient_common::domain::{ContentType, GameLoader};
|
use oneclient_common::domain::{ContentType, GameLoader};
|
||||||
use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase};
|
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
|
// The folder is rebuilt from the database at the next launch
|
||||||
artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?;
|
artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?;
|
||||||
|
|
||||||
|
if let (Some(content_type), Some(link)) = (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
|
// Best-effort folder cleanup failure here is not an error
|
||||||
let deferred = match (target, link) {
|
let deferred = match (target, link) {
|
||||||
(Some(content_type), Some(link)) => {
|
(Some(content_type), Some(link)) => {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,49 @@ pub async fn read_jar_manifest(jar: &Path) -> JarManifest {
|
||||||
out
|
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()))]
|
#[tracing::instrument(level = "debug", fields(jar = %jar.display()))]
|
||||||
pub async fn read_jar_loader(jar: &Path) -> Option<GameLoader> {
|
pub async fn read_jar_loader(jar: &Path) -> Option<GameLoader> {
|
||||||
let entries = polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name))
|
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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn fabric_reads_every_field() {
|
fn fabric_reads_every_field() {
|
||||||
let parsed = parse_fabric(
|
let parsed = parse_fabric(
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,32 @@ pub async fn remove_entry(path: &Path) -> ContentResult<()> {
|
||||||
Ok(())
|
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(
|
async fn materialized_root(
|
||||||
cluster: &ClusterRow,
|
cluster: &ClusterRow,
|
||||||
content_type: ContentType,
|
content_type: ContentType,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ pub const GLOBAL_MANIFEST_NAME: &str = ".oneclient-global.json";
|
||||||
|
|
||||||
const MANIFEST_VERSION: u32 = 1;
|
const MANIFEST_VERSION: u32 = 1;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct ManifestEntry {
|
pub struct ManifestEntry {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub hash: String,
|
pub hash: String,
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ pub use gc::{
|
||||||
remove_unreferenced_files,
|
remove_unreferenced_files,
|
||||||
};
|
};
|
||||||
pub use link::{
|
pub use link::{
|
||||||
LiveSync, link_or_copy, remove_entry, sweep_staging_files, try_link_materialized,
|
LiveSync, drop_unmanaged_mod, link_or_copy, remove_entry, sweep_staging_files,
|
||||||
try_unlink_materialized,
|
try_link_materialized, try_unlink_materialized,
|
||||||
};
|
};
|
||||||
pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path};
|
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?;
|
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)
|
if let (Some(content_type), Some(link)) = (content_type, link)
|
||||||
&& link::try_unlink_materialized(
|
&& link::try_unlink_materialized(
|
||||||
&cluster,
|
&cluster,
|
||||||
|
|
|
||||||
|
|
@ -40,5 +40,5 @@ pub use process::{
|
||||||
pub use reattach::recover_sessions;
|
pub use reattach::recover_sessions;
|
||||||
pub use shared_dir::{
|
pub use shared_dir::{
|
||||||
dematerialize_content, import_manual_content, link_cluster_logs, materialize_content,
|
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::applied_migration as migration_dao;
|
||||||
use oneclient_db::dao::artifact as artifact_dao;
|
use oneclient_db::dao::artifact as artifact_dao;
|
||||||
use oneclient_db::dao::cluster as cluster_dao;
|
use oneclient_db::dao::cluster as cluster_dao;
|
||||||
|
use oneclient_db::dao::cluster_bundle as bundle_dao;
|
||||||
|
|
||||||
use crate::LauncherResult;
|
use crate::LauncherResult;
|
||||||
use crate::clusters::Cluster;
|
use crate::clusters::Cluster;
|
||||||
use crate::state::LauncherServices;
|
use crate::state::{LauncherServices, LauncherState};
|
||||||
use oneclient_cluster::remove_mods_link;
|
use oneclient_cluster::remove_mods_link;
|
||||||
use oneclient_common::domain::{ContentType, ProviderId};
|
use oneclient_common::domain::{ContentType, ProviderId};
|
||||||
use oneclient_common::paths;
|
use oneclient_common::paths;
|
||||||
|
|
@ -63,6 +64,8 @@ pub async fn materialize_content(
|
||||||
game_dir: &Path,
|
game_dir: &Path,
|
||||||
mods_in_cluster: bool,
|
mods_in_cluster: bool,
|
||||||
) -> LauncherResult<()> {
|
) -> LauncherResult<()> {
|
||||||
|
let mods_sync = lock_mods_sync(cluster.id).await;
|
||||||
|
|
||||||
let dedicated = cluster.uses_dedicated_dir();
|
let dedicated = cluster.uses_dedicated_dir();
|
||||||
let cluster_dir = cluster.dir()?;
|
let cluster_dir = cluster.dir()?;
|
||||||
let isolated = cluster.is_isolated();
|
let isolated = cluster.is_isolated();
|
||||||
|
|
@ -106,12 +109,28 @@ pub async fn materialize_content(
|
||||||
} else {
|
} else {
|
||||||
loaded_previous.map(without_global_entries)
|
loaded_previous.map(without_global_entries)
|
||||||
};
|
};
|
||||||
let previous_mods = manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await;
|
|
||||||
let previous_global = if isolated {
|
let previous_global = if isolated {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await
|
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;
|
crate::game::heal::clear_zeroed_files(game_dir).await;
|
||||||
if mods_in_cluster {
|
if mods_in_cluster {
|
||||||
crate::game::heal::clear_zeroed_mods(&cluster_dir).await;
|
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(
|
clear_unlinked_mods_once(
|
||||||
services,
|
services,
|
||||||
cluster,
|
cluster,
|
||||||
|
|
@ -510,10 +529,15 @@ async fn disable_hand_removed(
|
||||||
previous: Option<&MaterializedManifest>,
|
previous: Option<&MaterializedManifest>,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let removed = hand_removed_content(dir, content_type, previous).await;
|
let removed = hand_removed_content(dir, content_type, previous).await;
|
||||||
if removed.is_empty() {
|
disable_removed(services, cluster, content_type, removed).await
|
||||||
return Vec::new();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
async fn disable_removed(
|
||||||
|
services: &LauncherServices,
|
||||||
|
cluster: &Cluster,
|
||||||
|
content_type: ContentType,
|
||||||
|
removed: Vec<(String, String)>,
|
||||||
|
) -> Vec<String> {
|
||||||
let ctx = services.content();
|
let ctx = services.content();
|
||||||
let mut disabled = Vec::new();
|
let mut disabled = Vec::new();
|
||||||
|
|
||||||
|
|
@ -549,6 +573,458 @@ async fn disable_hand_removed(
|
||||||
disabled
|
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
|
// switches a globally installed artifact off for every cluster that has it
|
||||||
async fn disable_globally(
|
async fn disable_globally(
|
||||||
cluster: &Cluster,
|
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(
|
async fn cached_file(
|
||||||
|
|
@ -845,11 +1479,9 @@ async fn link_desired(root: &Path, desired: &[Desired]) -> Vec<ManifestEntry> {
|
||||||
entries
|
entries
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNLINKED_MODS_REPAIR: &str = "clear-unlinked-mods";
|
|
||||||
|
|
||||||
#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")]
|
#[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) {
|
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 {
|
match migration_dao::is_applied(&services.db, &id).await {
|
||||||
Ok(true) => return,
|
Ok(true) => return,
|
||||||
Ok(false) => {}
|
Ok(false) => {}
|
||||||
|
|
@ -895,6 +1527,13 @@ async fn clear_unlinked_mods_once(services: &LauncherServices, cluster: &Cluster
|
||||||
mark_repair_applied(services, &id).await;
|
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) {
|
async fn mark_repair_applied(services: &LauncherServices, id: &str) {
|
||||||
if let Err(err) = migration_dao::mark_applied(&services.db, id).await {
|
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");
|
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,
|
cluster: &Cluster,
|
||||||
game_dir: &Path,
|
game_dir: &Path,
|
||||||
) {
|
) {
|
||||||
|
let _sync = lock_mods_sync(cluster.id).await;
|
||||||
let mods_in_cluster = match cluster.dir() {
|
let mods_in_cluster = match cluster.dir() {
|
||||||
Ok(dir) => manifest::mods_live_in_cluster(&dir).await,
|
Ok(dir) => manifest::mods_live_in_cluster(&dir).await,
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
|
|
@ -1202,41 +1842,53 @@ async fn import_from_dir(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cluster.is_isolated() && is_stale_launcher_content(services, &path).await {
|
match adopt_file(services, cluster, &path, content_type, true).await {
|
||||||
tracing::debug!(
|
Some(Ok(row)) => {
|
||||||
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) => {
|
|
||||||
tracing::debug!(file = name, "registered manually-added content");
|
tracing::debug!(file = name, "registered manually-added content");
|
||||||
if discard_originals {
|
if discard_originals {
|
||||||
discard_adopted_original(&row, &path, name).await;
|
discard_adopted_original(&row, &path, name).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Some(Err(_)) => {
|
||||||
tracing::warn!(
|
|
||||||
file = name,
|
|
||||||
error = %err,
|
|
||||||
"failed to register manually-added content"
|
|
||||||
);
|
|
||||||
failed.insert(name.to_owned());
|
failed.insert(name.to_owned());
|
||||||
}
|
}
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
failed
|
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(
|
async fn discard_adopted_original(
|
||||||
row: &oneclient_db::models::ArtifactRow,
|
row: &oneclient_db::models::ArtifactRow,
|
||||||
path: &Path,
|
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]
|
#[tokio::test]
|
||||||
async fn a_jar_the_user_deleted_is_reported_with_its_hash() {
|
async fn a_jar_the_user_deleted_is_reported_with_its_hash() {
|
||||||
let dir = mods_scratch("hand_removed", &["kept.jar", "also_kept.jar"]).await;
|
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