feat: manual migration in both directions, only when there's something to copy

This commit is contained in:
Julian Chang 2026-10-01 03:45:55 -04:00
parent 0b78f16e9a
commit 0e98be3d5a
No known key found for this signature in database
9 changed files with 187 additions and 79 deletions

View file

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

View file

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

View file

@ -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<ClusterContentQuery>) -> Vec<LinkedArtifactInfo> {
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<HashSet<(ClusterId, ClusterId)>>;
type Err = LauncherError;
type Keys = Vec<(ClusterId, ClusterId)>;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
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<HashSet<(ClusterId, ClusterId)>> {
let query = use_query(Query::new(routes, MigratableRoutesQuery));
super::state::settled_or_loading(&query).unwrap_or_default()
}

View file

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

View file

@ -7,7 +7,7 @@ use std::collections::HashSet;
use std::path::PathBuf;
use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery};
use super::cluster_content::ClusterContentQuery;
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::<BundlesWithStatusQuery>::invalidate_all(),
)
.await;
timed(
"migratable_routes",
QueriesStorage::<MigratableRoutesQuery>::invalidate_all(),
)
.await;
timed(
"clusters",
QueriesStorage::<ListClustersQuery>::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::<ClusterContentQuery>::invalidate_all().await;
QueriesStorage::<MigratableRoutesQuery>::invalidate_all().await;
}
/// Everything [`invalidate_cluster_queries`] does bar the cluster list and the

View file

@ -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::<i64>);
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<i64> = sources.iter().map(|source| source.id).collect();
let names: Vec<String> = 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<String> = 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)

View file

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

View file

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

View file

@ -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::<Vec<_>>(), [2]);
}
}