diff --git a/packages/oneclient_app/src/hooks/actions/release_migration.rs b/packages/oneclient_app/src/hooks/actions/release_migration.rs index 4f6fb86f..74e32d26 100644 --- a/packages/oneclient_app/src/hooks/actions/release_migration.rs +++ b/packages/oneclient_app/src/hooks/actions/release_migration.rs @@ -512,12 +512,14 @@ impl Actions { pub fn open_manual_migration(&self, target_cluster_id: i64, source_cluster_id: i64) { self.set_release_migration_checking(target_cluster_id, true); + self.set_release_migration_checking(source_cluster_id, true); let actions = self.clone(); spawn_forever(async move { actions .run_manual_migration(target_cluster_id, source_cluster_id) .await; actions.set_release_migration_checking(target_cluster_id, false); + actions.set_release_migration_checking(source_cluster_id, false); }); } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index ebdf2e10..50196c6d 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -8,11 +8,11 @@ mod shortcut_actions; mod view_state; pub use debounce::use_debounced; -pub use selection::{Selection, use_selection}; pub use overlay_claims::{ OverlayClaims, use_overlay_claim, use_overlay_claim_when, use_overlay_claims, use_provide_overlay_claims, }; +pub use selection::{Selection, use_selection}; pub use view_state::{PersistedView, use_view_state}; pub use active_cluster::{ @@ -34,44 +34,42 @@ pub use queries::{ CachedImageQuery, CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, ClusterLogsQuery, ClusterMutation, DisableWarningsQuery, DiscardLeftoversKeys, DiscardLeftoversMutation, FinishMicrosoftLoginMutation, GameVersion, JavaMajorsQuery, - LeftoversQuery, LogAction, LogContentQuery, MigrationQuery, - OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, - SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery, - UploadLogKeys, UploadLogMutation, UseDiscardLeftovers, UseLogAction, UseRefreshAccount, - UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseStorageAction, UseUploadLog, - VERSIONS_PAGE_SIZE, accounts_have_microsoft, available_bundles, bundle_overrides_map, - bundles_with_status_items, category_list, changelog_error, changelog_groups, - changelog_is_loading, cluster_content_items, content_type_for_slug, disable_warnings, - game_versions, has_migration_data, invalidate_cluster_content_queries, - invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries, - invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, - invalidate_storage_queries, java_majors, java_runtimes, latest_changelog_version, loaded_image, - loader_versions, login_code_already_handled, migration_detection, - mutation_error, mutation_is_pending, mutation_is_running, mutation_ok, - onboarding_bundles_items, package_meta_batch, package_updates, pick_version_metadata, - project_detail, provider_versions, query_error, query_is_busy, query_is_loading, - cluster_art_url, refresh_version_art_gallery, reset_login_code_dedup, resolve_art_url, search_items, - search_pending, search_total, settled_or_loading, stale_hashes, terms_document, terms_error, - terms_is_loading, try_account, try_accounts, try_cluster_analytics, try_cluster_logs, - try_cluster_screenshots, try_default_account, try_game_profile, try_global_analytics, - try_leftovers, try_log_content, try_storage_report, use_account, use_accounts, - use_add_microsoft_account, use_add_offline_account, use_available_bundles, - use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, - use_cached_image, use_cancel_microsoft_login, use_changelog, use_cluster, - use_cluster_analytics, use_cluster_content, use_cluster_logs, use_cluster_mutation, - use_cluster_profile, use_cluster_screenshots, use_cluster_settings, use_clusters, - use_current_account, use_default_account, use_disable_warnings, 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_migration, + LeftoversQuery, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery, + RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, StorageAction, + StorageActionMutation, StorageReportQuery, TermsQuery, UploadLogKeys, UploadLogMutation, + UseDiscardLeftovers, UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, + UseSetDefaultAccount, UseStorageAction, UseUploadLog, VERSIONS_PAGE_SIZE, + accounts_have_microsoft, available_bundles, bundle_overrides_map, bundles_with_status_items, + category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_art_url, + cluster_content_items, content_type_for_slug, disable_warnings, game_versions, + has_migration_data, invalidate_cluster_content_queries, invalidate_cluster_queries, + invalidate_java_queries, invalidate_leftovers_queries, invalidate_logs_queries, + invalidate_profile_queries, invalidate_screenshots_queries, invalidate_storage_queries, + java_majors, java_runtimes, latest_changelog_version, loaded_image, loader_versions, + login_code_already_handled, migration_detection, mutation_error, mutation_is_pending, + mutation_is_running, mutation_ok, onboarding_bundles_items, package_meta_batch, + package_updates, pick_version_metadata, project_detail, provider_versions, query_error, + query_is_busy, query_is_loading, refresh_version_art_gallery, reset_login_code_dedup, + resolve_art_url, search_items, search_pending, search_total, settled_or_loading, stale_hashes, + terms_document, terms_error, terms_is_loading, try_account, try_accounts, + try_cluster_analytics, try_cluster_logs, try_cluster_screenshots, try_default_account, + try_game_profile, try_global_analytics, try_leftovers, try_log_content, try_storage_report, + use_account, use_accounts, use_add_microsoft_account, use_add_offline_account, + use_available_bundles, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, + use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog, + use_cluster, use_cluster_analytics, use_cluster_content, use_cluster_logs, + use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, use_cluster_settings, + use_clusters, use_current_account, use_default_account, use_disable_warnings, + 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, 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_version_metadata, use_versions, version_list, versions_metadata, versions_total, }; pub use queries::{ add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents, diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs index 196d57f5..7f98ba08 100644 --- a/packages/oneclient_app/src/hooks/queries/cluster_content.rs +++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs @@ -1,8 +1,14 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use std::collections::HashSet; +use std::sync::Arc; + +use oneclient_content::packages::release_migration::has_packages_to_migrate; use oneclient_content::packages::{ContentType, PackageStore}; use oneclient_core::{LauncherError, LinkedArtifactInfo}; use oneclient_db::models::ClusterId; +use crate::launcher::off_ui; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ClusterContentQuery { pub cluster_id: ClusterId, @@ -50,3 +56,35 @@ pub fn use_cluster_content( pub fn cluster_content_items(query: &UseQuery) -> Vec { super::state::settled_or_loading(query).unwrap_or_default() } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MigratableRoutesQuery; + +impl QueryCapability for MigratableRoutesQuery { + type Ok = Arc>; + type Err = LauncherError; + type Keys = Vec<(ClusterId, ClusterId)>; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let routes = keys.clone(); + off_ui(async move { + let content = state.services.content(); + let mut migratable = HashSet::new(); + for (target, source) in routes { + if has_packages_to_migrate(source, target, &state.bundles, &content).await? { + migratable.insert((target, source)); + } + } + Ok(Arc::new(migratable)) + }) + .await + } +} + +pub fn use_migratable_routes( + routes: Vec<(ClusterId, ClusterId)>, +) -> Arc> { + let query = use_query(Query::new(routes, MigratableRoutesQuery)); + super::state::settled_or_loading(&query).unwrap_or_default() +} diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index f64270e5..5a3e03a0 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -59,7 +59,7 @@ pub use changelog::{ changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, use_changelog, }; -pub use cluster_content::{cluster_content_items, use_cluster_content}; +pub use cluster_content::{cluster_content_items, use_cluster_content, use_migratable_routes}; 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; @@ -108,10 +108,8 @@ pub use version_metadata::{ use_version_art, use_version_art_gallery, use_version_metadata, }; pub use versions::{ - GameVersion, JavaMajorsQuery, game_versions, java_majors, - loader_versions, use_game_versions, use_java_majors, - use_loader_versions, use_version_loaders, use_versions, - versions_metadata, + GameVersion, JavaMajorsQuery, game_versions, java_majors, loader_versions, use_game_versions, + use_java_majors, use_loader_versions, use_version_loaders, use_versions, versions_metadata, }; pub use worlds::{ add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents, diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index d9c4275b..93d3c183 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -7,7 +7,7 @@ use std::collections::HashSet; use std::path::PathBuf; use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; -use super::cluster_content::ClusterContentQuery; +use super::cluster_content::{ClusterContentQuery, MigratableRoutesQuery}; use super::clusters::ListClustersQuery; use super::package_updates::PackageUpdatesQuery; use super::settings_profiles::{ @@ -42,6 +42,11 @@ pub async fn invalidate_cluster_queries() { QueriesStorage::::invalidate_all(), ) .await; + timed( + "migratable_routes", + QueriesStorage::::invalidate_all(), + ) + .await; timed( "clusters", QueriesStorage::::invalidate_all(), @@ -68,6 +73,7 @@ pub async fn invalidate_cluster_queries() { /// this before dropping its busy flag pub async fn invalidate_cluster_content_queries() { QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; } /// Everything [`invalidate_cluster_queries`] does bar the cluster list and the diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index aeec0da7..64a3065b 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -14,14 +14,14 @@ use crate::hooks::{ ClusterAction, java_runtimes, loader_versions, mutation_is_running, query_error, settled_or_loading, try_game_profile, use_cluster_mutation, use_clusters, use_dispatch, use_game_profile, use_game_snapshot, use_java_runtimes, use_loader_versions, - use_release_migration_checking, use_settings_snapshot, + use_migratable_routes, use_release_migration_checking, use_settings_snapshot, }; use crate::layout::cluster_content; use crate::theme::colors; use crate::ui::centered_note; use crate::view::app::clusters::{DeleteInstanceModal, EditInstanceModal, InstanceFacts}; use crate::view::app::settings::{section_header, settings_row, settings_row_disabled}; -use oneclient_core::clusters::rank_migration_sources; +use oneclient_core::clusters::{can_migrate_manually, rank_migration_sources}; use super::cluster_not_found; use super::modpack_settings::{ModpackRepairRow, ModpackUpdateRow}; @@ -890,29 +890,47 @@ impl Component for MigrateFromRow { let cluster_id = self.cluster_id; let dispatch = use_dispatch(); let checking = use_release_migration_checking(cluster_id); - let running = use_game_snapshot().is_running(cluster_id); + let game = use_game_snapshot(); let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); - let mut picked = use_state(|| None::); + let mut picked = use_state(|| None::<(i64, i64)>); - let sources = clusters - .iter() - .find(|cluster| cluster.id == cluster_id) - .map(|target| rank_migration_sources(target, &clusters)) - .unwrap_or_default(); + let mut routes: Vec<(String, i64, i64)> = Vec::new(); + if let Some(this) = clusters.iter().find(|cluster| cluster.id == cluster_id) { + routes.extend( + rank_migration_sources(this, &clusters) + .into_iter() + .map(|source| (format!("From {}", source.name), cluster_id, source.id)), + ); + routes.extend( + clusters + .iter() + .filter(|other| { + other.id != cluster_id + && can_migrate_manually(this.mc_loader, other.mc_loader) + }) + .map(|target| (format!("To {}", target.name), target.id, cluster_id)), + ); + } + let has_partners = !routes.is_empty(); + let migratable = + use_migratable_routes(routes.iter().map(|route| (route.1, route.2)).collect()); + routes.retain(|route| migratable.contains(&(route.1, route.2))); let selected = picked .read() - .and_then(|id| sources.iter().find(|source| source.id == id)) - .or_else(|| sources.first()) + .and_then(|pair| routes.iter().find(|route| (route.1, route.2) == pair)) + .or_else(|| routes.first()) .cloned(); - let selected_id = selected.as_ref().map(|source| source.id); + let selected_pair = selected.as_ref().map(|route| (route.1, route.2)); + let running = selected_pair + .is_some_and(|(target, source)| game.is_running(target) || game.is_running(source)); let mut button = Button::new() .secondary() - .enabled(selected_id.is_some() && !checking && !running) + .enabled(selected_pair.is_some() && !checking && !running) .on_press(move |_| { - if let Some(source_id) = selected_id { - dispatch.open_manual_migration(cluster_id, source_id); + if let Some((target, source)) = selected_pair { + dispatch.open_manual_migration(target, source); } }); button = if checking { @@ -928,33 +946,34 @@ impl Component for MigrateFromRow { button = button.tooltip("Close the game before migrating"); } - let picker: Element = if sources.is_empty() { + let picker: Element = if routes.is_empty() { label() - .text("No other clusters with this loader") + .text(if has_partners { + "Nothing installed from the browser left to copy" + } else { + "No other clusters with this loader" + }) .font_size(12.) .color(colors::fg_secondary()) .into_element() } else { - let ids: Vec = sources.iter().map(|source| source.id).collect(); - let names: Vec = sources.iter().map(|source| source.name.clone()).collect(); - Dropdown::new( - selected.map(|source| source.name).unwrap_or_default(), - names, - ) - .width(Size::px(180.)) - .height(Size::px(34.)) - .on_select(move |index: usize| { - if let Some(id) = ids.get(index) { - picked.set(Some(*id)); - } - }) - .into_element() + let pairs: Vec<(i64, i64)> = routes.iter().map(|route| (route.1, route.2)).collect(); + let names: Vec = routes.iter().map(|route| route.0.clone()).collect(); + Dropdown::new(selected.map(|route| route.0).unwrap_or_default(), names) + .width(Size::px(220.)) + .height(Size::px(34.)) + .on_select(move |index: usize| { + if let Some(pair) = pairs.get(index) { + picked.set(Some(*pair)); + } + }) + .into_element() }; settings_row( IconType::Copy01, "Migrate", - "Bring mods, resource packs and shaders you installed in another cluster. Compatible versions are downloaded; the other cluster is left as it is.", + "Copy mods, resource packs and shaders you installed from the browser to or from another cluster.", rect() .horizontal() .cross_align(Alignment::Center) diff --git a/packages/oneclient_content/src/packages/release_migration.rs b/packages/oneclient_content/src/packages/release_migration.rs index 65db538d..4018bf62 100644 --- a/packages/oneclient_content/src/packages/release_migration.rs +++ b/packages/oneclient_content/src/packages/release_migration.rs @@ -150,6 +150,23 @@ pub async fn has_migratable_packages(cluster_id: i64, ctx: &ContentCtx) -> Conte Ok(!migratable_candidates(cluster_id, ctx).await?.is_empty()) } +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn has_packages_to_migrate( + source_cluster_id: i64, + target_cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult { + let candidates = migratable_candidates(source_cluster_id, ctx).await?; + if candidates.is_empty() { + return Ok(false); + } + let present = target_projects(target_cluster_id, bundles, ctx).await?; + Ok(candidates + .iter() + .any(|candidate| !present.contains(&candidate.project_id))) +} + async fn target_projects( target_cluster_id: i64, bundles: &BundlesManager, diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index c60cd63e..f4a2e2db 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -17,8 +17,8 @@ pub use prepare::{ }; pub use provision::{ensure_from_bundles, ensure_from_versions}; pub use release_migration::{ - OfferLookup, ReleaseMigrationOffer, manual_migration_offer, rank_migration_sources, - record_new_versions, release_migration_offer, + OfferLookup, ReleaseMigrationOffer, can_migrate_manually, manual_migration_offer, + rank_migration_sources, record_new_versions, release_migration_offer, }; pub use unlink_legacy::{SweepReport, unlink_legacy_cluster_content}; diff --git a/packages/oneclient_core/src/clusters/release_migration.rs b/packages/oneclient_core/src/clusters/release_migration.rs index 720329a1..2eff216d 100644 --- a/packages/oneclient_core/src/clusters/release_migration.rs +++ b/packages/oneclient_core/src/clusters/release_migration.rs @@ -4,7 +4,9 @@ use oneclient_common::domain::GameLoader; use oneclient_cluster::Cluster; use oneclient_common::version::parse_mc_version; -use oneclient_content::packages::release_migration::has_migratable_packages; +use oneclient_content::packages::release_migration::{ + has_migratable_packages, has_packages_to_migrate, +}; use crate::LauncherResult; use crate::state::LauncherState; @@ -50,9 +52,7 @@ fn is_migration_source( target: &Cluster, loader_ok: impl Fn(GameLoader, GameLoader) -> bool, ) -> bool { - candidate.id != target.id - && !candidate.user_created - && loader_ok(candidate.mc_loader, target.mc_loader) + candidate.id != target.id && loader_ok(candidate.mc_loader, target.mc_loader) } fn is_migration_destination(target: &ReleaseTarget, rules: &[RemoteMigration]) -> bool { @@ -142,7 +142,7 @@ pub async fn manual_migration_offer( }; if !is_migration_source(source, target, can_migrate_manually) - || !has_migratable_packages(source.id, &content).await? + || !has_packages_to_migrate(source.id, target.id, &state.bundles, &content).await? { return Ok(OfferLookup::NoSources); } @@ -188,7 +188,7 @@ async fn offer_for( let mut sources = Vec::new(); for cluster in clusters { - if !is_migration_source(cluster, target, |from, to| from == to) { + if cluster.user_created || !is_migration_source(cluster, target, |from, to| from == to) { continue; } if !version_order(&cluster.mc_version).is_some_and(|order| order != target_order) { @@ -335,4 +335,34 @@ mod tests { fn a_bare_minor_orders_below_its_patches() { assert!(version_order("26.3") < version_order("26.3.1")); } + + fn user_cluster(id: i64, mc_version: &str) -> Cluster { + Cluster { + id, + name: format!("{mc_version} Fabric"), + folder_name: format!("cluster-{id}"), + setting_profile_name: None, + mc_version: mc_version.to_string(), + mc_loader: GameLoader::Fabric, + mc_loader_version: None, + stage: oneclient_cluster::ClusterStage::default(), + created_at: None, + last_played: None, + overall_played: std::time::Duration::ZERO, + linked_modpack_hash: None, + kind: oneclient_cluster::ClusterKind::OneClient, + user_created: true, + description: None, + tags: Vec::new(), + cover_path: None, + } + } + + #[test] + fn manual_migration_lists_user_created_sources() { + let target = user_cluster(1, "26.2"); + let clusters = [target.clone(), user_cluster(2, "26.3")]; + let sources = rank_migration_sources(&target, &clusters); + assert_eq!(sources.iter().map(|c| c.id).collect::>(), [2]); + } }