diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs index 35c3b0e2..5bcc310f 100644 --- a/packages/oneclient_app/src/bridge/commands.rs +++ b/packages/oneclient_app/src/bridge/commands.rs @@ -89,4 +89,10 @@ pub enum BridgeCommand { }, ApplyBundleUpdates { cluster_id: ClusterId }, SyncBundles, + + ImportLauncher { + source: oneclient_core::MigrationSource, + folder_name: String, + target: oneclient_core::ImportTarget, + }, } diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 668cf769..7729bd0c 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -402,6 +402,25 @@ impl CoreBridgeRuntime { bump_clusters_generation(snapshots); } + BridgeCommand::ImportLauncher { source, folder_name, target } => { + let state = LauncherState::get()?; + tokio::spawn(async move { + let notifier = state.services.notifier.clone(); + match oneclient_core::import_migration_game_dir(source, &folder_name, target).await { + Ok(()) => { + notifier.send_info( + "Import complete", + &format!("Copied your files from {}.", source.display_name()), + ); + + notifier.invalidate_clusters(); + } + Err(err) => { + notifier.send_error("Import failed", &err.to_string()); + } + } + }); + } BridgeCommand::InstallJavaRuntime { vendor, major } => { let state = LauncherState::get()?; tokio::spawn(async move { @@ -691,7 +710,7 @@ async fn install_package( let version = provider_impl .get_version(project_id, version_id, &state.services) .await?; - + PackageStore::install_to_cluster( provider, &project, diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index c22f35ed..e7eb3fc9 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -75,6 +75,10 @@ pub struct OnboardingSelectionState { pub reduce_motion: State, pub predownload: State, pub setup_started: State, + /// v1-migration: source folder to import files from (`None` = don't import). + pub import_folder: State>, + /// v1-migration: import into the matching cluster's own dir instead of shared. + pub import_dedicated: State, } pub fn use_provide_onboarding_selection(state: OnboardingSelectionState) { diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs index b0914e18..49a35d72 100644 --- a/packages/oneclient_app/src/hooks/dispatch.rs +++ b/packages/oneclient_app/src/hooks/dispatch.rs @@ -42,6 +42,19 @@ impl BridgeDispatch { self.send(BridgeCommand::MarkOnboardingSeen); } + pub fn import_launcher( + &self, + source: oneclient_core::MigrationSource, + folder_name: impl Into, + target: oneclient_core::ImportTarget, + ) { + self.send(BridgeCommand::ImportLauncher { + source, + folder_name: folder_name.into(), + target, + }); + } + pub fn save_global_profile(&self, profile: GameSettingsProfile) { self.send(BridgeCommand::SaveGlobalProfile { profile }); } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 25d18ef3..46593557 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -16,14 +16,15 @@ pub use active_cluster::{ pub use dispatch::BridgeDispatch; pub use queries::{ AddOfflineAccountKeys, BROWSE_PAGE_SIZE, CachedImageQuery, ClusterAction, ClusterBundles, - ClusterLogsQuery, LogAction, LogContentQuery, OnboardingBundlesQuery, RefreshAccountKeys, - RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, UploadLogKeys, UploadLogMutation, - UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, - UseUploadLog, VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, - bundles_with_status_items, category_list, changelog_error, changelog_groups, - changelog_is_loading, cluster_content_items, content_type_for_slug, invalidate_cluster_queries, - invalidate_java_queries, invalidate_logs_queries, invalidate_profile_queries, - invalidate_screenshots_queries, java_runtimes, loader_versions, login_code_already_handled, + ClusterLogsQuery, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery, + RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, UploadLogKeys, + UploadLogMutation, UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, + UseSetDefaultAccount, UseUploadLog, VERSIONS_PAGE_SIZE, accounts_have_microsoft, + bundle_overrides_map, bundles_with_status_items, category_list, changelog_error, + changelog_groups, changelog_is_loading, cluster_content_items, content_type_for_slug, + has_migration_data, invalidate_cluster_queries, invalidate_java_queries, + invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, + java_runtimes, loader_versions, login_code_already_handled, migration_detection, mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch, pick_version_metadata, project_detail, provider_versions, search_items, search_pending, search_total, try_account, try_accounts, try_cluster_analytics, try_cluster_logs, @@ -34,65 +35,19 @@ pub use queries::{ use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, use_cluster_settings, use_clusters, use_current_account, use_default_account, use_finish_microsoft_login, use_game_profile, use_global_analytics, use_java_runtimes, use_loader_versions, - use_local_image, use_log_action, use_log_content, use_named_profiles, use_onboarding_bundles, - use_package_categories, use_package_meta_batch, use_package_project, use_package_search, - use_package_versions, use_player_profile, use_provider_versions, use_refresh_account, - use_refresh_all_accounts, use_remove_account, use_screenshot_action, use_set_default_account, - use_upload_log, use_version_metadata, use_versions, version_list, versions_total, + use_local_image, use_log_action, use_log_content, use_migration, use_named_profiles, + use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project, + use_package_search, use_package_versions, use_player_profile, use_player_skin, + use_provider_versions, use_refresh_account, use_refresh_all_accounts, use_remove_account, + use_screenshot_action, use_set_default_account, use_upload_log, use_version_metadata, + use_versions, version_list, versions_total, }; -use crate::notifications::NotificationSnapshot; -use bytes::Bytes; -use freya::prelude::*; -use freya::query::QueryStateData; - -use crate::AppAssets; - -pub fn use_player_skin(uuid: String) -> (Bytes, bool) { - let profile = use_player_profile(uuid.clone(), None::); - - let (skin_url, is_slim) = match &*profile.read().state() { - QueryStateData::Settled { - res: Ok(profile), .. - } => (profile.skin_url.clone(), profile.is_slim), - _ => (None, false), - }; - - let skin_query = use_cached_image(skin_url.clone(), 256); - - let steve = use_memo(|| AppAssets::get_bytes("steve.png").unwrap_or_default()); - let alex = use_memo(|| AppAssets::get_bytes("alex.png").unwrap_or_default()); - - // No custom skin: pick alex (slim) or steve (classic) from the UUID. - let default_slim = (java_string_hash(&uuid) & 1) == 1; - - let reader = skin_query.read(); - match (&skin_url, &*reader.state()) { - (Some(_), QueryStateData::Settled { res: Ok(bytes), .. }) - | ( - Some(_), - QueryStateData::Loading { - res: Some(Ok(bytes)), - }, - ) => (bytes.clone(), is_slim), - _ if default_slim => (alex.read().clone(), true), - _ => (steve.read().clone(), false), - } -} - -/// Java `String.hashCode()`: h = h*31 + c over UTF-16 units, wrapping i32. -fn java_string_hash(s: &str) -> i32 { - let mut h: i32 = 0; - for c in s.encode_utf16() { - h = h.wrapping_mul(31).wrapping_add(c as i32); - } - h -} - -use crate::bridge::{ +use crate::{bridge::{ BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit, OneClientBridge, ProfilesSnapshot, SettingsSnapshot, use_bridge_snapshot, -}; +}, notifications::NotificationSnapshot}; +use freya::prelude::*; pub fn use_provide_bridge(bridge: &OneClientBridge) { let bridge = bridge.clone(); diff --git a/packages/oneclient_app/src/hooks/queries/migration.rs b/packages/oneclient_app/src/hooks/queries/migration.rs new file mode 100644 index 00000000..2ee691de --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/migration.rs @@ -0,0 +1,35 @@ +use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; +use oneclient_core::{LauncherError, MigrationDetection, detect_migration}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MigrationQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MigrationKeys; + +impl QueryCapability for MigrationQuery { + type Ok = Option; + type Err = LauncherError; + type Keys = MigrationKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + detect_migration().await + } +} + +pub fn use_migration() -> UseQuery { + use_query(Query::new(MigrationKeys, MigrationQuery)) +} + +pub fn migration_detection(query: &UseQuery) -> Option { + let reader = query.read(); + match &*reader.state() { + QueryStateData::Settled { res: Ok(opt), .. } => opt.clone(), + QueryStateData::Loading { res: Some(Ok(opt)) } => opt.clone(), + _ => None, + } +} + +pub fn has_migration_data(query: &UseQuery) -> bool { + migration_detection(query).is_some_and(|d| !d.instances.is_empty()) +} diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 784506e9..4b806c7a 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -4,68 +4,68 @@ mod bundles; mod changelog; mod cluster_content; mod clusters; -mod logs; -mod mutations; -mod packages; mod image; mod java; -mod screenshots; +mod logs; +mod migration; +mod mutations; +mod packages; mod player_profile; +mod screenshots; mod settings_profiles; +mod skin; mod version_metadata; mod versions; pub use analytics::{ try_cluster_analytics, try_global_analytics, use_cluster_analytics, use_global_analytics, }; +pub use auth::{ + AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, + UseRefreshAccount, UseRemoveAccount, UseSetDefaultAccount, accounts_have_microsoft, + login_code_already_handled, mutation_error, mutation_is_pending, try_account, try_accounts, + try_default_account, use_account, use_accounts, use_add_microsoft_account, + use_add_offline_account, use_begin_microsoft_login, use_current_account, use_default_account, + use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts, use_remove_account, + use_set_default_account, +}; pub use bundles::{ ClusterBundles, OnboardingBundlesQuery, bundle_overrides_map, bundles_with_status_items, onboarding_bundles_items, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_onboarding_bundles, }; -pub use changelog::{ - changelog_error, changelog_groups, changelog_is_loading, use_changelog, -}; +pub use changelog::{changelog_error, changelog_groups, changelog_is_loading, use_changelog}; pub use cluster_content::{cluster_content_items, use_cluster_content}; +pub use clusters::use_clusters; +pub use image::{CachedImageQuery, use_cached_image}; +pub use java::{ + invalidate_java_queries, java_runtimes, provider_versions, use_java_runtimes, + use_provider_versions, +}; +pub use logs::{ + ClusterLogsQuery, LogAction, LogContentQuery, UploadLogKeys, UploadLogMutation, UseLogAction, + UseUploadLog, invalidate_logs_queries, try_cluster_logs, try_log_content, use_cluster_logs, + use_log_action, use_log_content, use_upload_log, +}; +pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration}; +pub use mutations::{ + ClusterAction, invalidate_cluster_queries, invalidate_profile_queries, use_cluster_mutation, +}; pub use packages::{ BROWSE_PAGE_SIZE, VERSIONS_PAGE_SIZE, category_list, content_type_for_slug, package_meta_batch, project_detail, search_items, search_pending, search_total, use_package_categories, use_package_meta_batch, use_package_project, use_package_search, use_package_versions, version_list, versions_total, }; -pub use clusters::{use_clusters}; -pub use logs::{ - ClusterLogsQuery, LogAction, LogContentQuery, UploadLogKeys, UploadLogMutation, - UseLogAction, UseUploadLog, invalidate_logs_queries, - try_cluster_logs, try_log_content, use_cluster_logs, use_log_action, use_log_content, - use_upload_log, -}; -pub use mutations::{ - ClusterAction, invalidate_cluster_queries, invalidate_profile_queries, use_cluster_mutation, -}; -pub use image::{use_cached_image, CachedImageQuery}; -pub use java::{ - invalidate_java_queries, java_runtimes, provider_versions, use_java_runtimes, - use_provider_versions, -}; -pub use screenshots::{ - ScreenshotAction, UseScreenshotAction, invalidate_screenshots_queries, - try_cluster_screenshots, use_cluster_screenshots, use_local_image, use_screenshot_action, -}; -pub use version_metadata::{pick_version_metadata, use_version_metadata}; -pub use versions::{use_versions, use_loader_versions, loader_versions}; -pub use auth::{ - AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, - UseRefreshAccount, UseRemoveAccount, UseSetDefaultAccount, accounts_have_microsoft, - login_code_already_handled, - mutation_error, mutation_is_pending, try_account, try_accounts, try_default_account, - use_account, use_accounts, use_add_microsoft_account, use_add_offline_account, - use_begin_microsoft_login, use_current_account, use_default_account, - use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts, use_remove_account, - use_set_default_account, -}; pub use player_profile::use_player_profile; +pub use screenshots::{ + ScreenshotAction, UseScreenshotAction, invalidate_screenshots_queries, try_cluster_screenshots, + use_cluster_screenshots, use_local_image, use_screenshot_action, +}; pub use settings_profiles::{ try_game_profile, use_cluster_profile, use_cluster_settings, use_game_profile, use_named_profiles, }; +pub use skin::use_player_skin; +pub use version_metadata::{pick_version_metadata, use_version_metadata}; +pub use versions::{loader_versions, use_loader_versions, use_versions}; diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs new file mode 100644 index 00000000..6d3b1a8d --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/skin.rs @@ -0,0 +1,45 @@ +use bytes::Bytes; +use freya::prelude::*; +use freya::query::QueryStateData; + +use crate::AppAssets; + +pub fn use_player_skin(uuid: String) -> (Bytes, bool) { + let profile = super::use_player_profile(uuid.clone(), None::); + + let (skin_url, is_slim) = match &*profile.read().state() { + QueryStateData::Settled { + res: Ok(profile), .. + } => (profile.skin_url.clone(), profile.is_slim), + _ => (None, false), + }; + + let skin_query = super::use_cached_image(skin_url.clone(), 256); + + let steve = use_memo(|| AppAssets::get_bytes("steve.png").unwrap_or_default()); + let alex = use_memo(|| AppAssets::get_bytes("alex.png").unwrap_or_default()); + + // No custom skin: pick alex (slim) or steve (classic) from the UUID. + let default_slim = (java_string_hash(&uuid) & 1) == 1; + + let reader = skin_query.read(); + match (&skin_url, &*reader.state()) { + (Some(_), QueryStateData::Settled { res: Ok(bytes), .. }) + | ( + Some(_), + QueryStateData::Loading { + res: Some(Ok(bytes)), + }, + ) => (bytes.clone(), is_slim), + _ if default_slim => (alex.read().clone(), true), + _ => (steve.read().clone(), false), + } +} + +fn java_string_hash(s: &str) -> i32 { + let mut h: i32 = 0; + for c in s.encode_utf16() { + h = h.wrapping_mul(31).wrapping_add(c as i32); + } + h +} \ No newline at end of file diff --git a/packages/oneclient_app/src/layout/onboarding_shell.rs b/packages/oneclient_app/src/layout/onboarding_shell.rs index 6e0117ce..4d50ff02 100644 --- a/packages/oneclient_app/src/layout/onboarding_shell.rs +++ b/packages/oneclient_app/src/layout/onboarding_shell.rs @@ -6,11 +6,11 @@ use freya::router::{Outlet, use_route}; use crate::Route; use crate::components::window_controls; use crate::hooks::{ - OnboardingSelectionState, onboarding_bundles_items, use_onboarding_bundles, - use_provide_onboarding_selection, + OnboardingSelectionState, has_migration_data, onboarding_bundles_items, use_migration, + use_onboarding_bundles, use_provide_onboarding_selection, }; use crate::theme::{self, colors}; -use crate::view::onboarding::{LoadingBackdrop, ONBOARDING_TOTAL, onboarding_step_index}; +use crate::view::onboarding::{LoadingBackdrop, onboarding_step_index, onboarding_total}; #[derive(PartialEq)] pub struct OnboardingShell; @@ -23,6 +23,8 @@ impl Component for OnboardingShell { let reduce_motion = use_state(|| false); let predownload = use_state(|| true); let setup_started = use_state(|| false); + let import_folder = use_state(|| None::); + let import_dedicated = use_state(|| false); use_provide_onboarding_selection(OnboardingSelectionState { selected, seeded, @@ -30,10 +32,15 @@ impl Component for OnboardingShell { reduce_motion, predownload, setup_started, + import_folder, + import_dedicated, }); + let migration_query = use_migration(); + let detected_migration = has_migration_data(&migration_query); + let route = use_route::(); - let step_index = onboarding_step_index(&route); + let step_index = onboarding_step_index(&route, detected_migration); let bundles = use_onboarding_bundles(); let show_backdrop = @@ -60,7 +67,10 @@ impl Component for OnboardingShell { .height(Size::fill()) .content(Content::Flex) .layer(Layer::Relative(30)) - .child(progress_bar(step_index)) + .child(progress_bar( + step_index, + onboarding_total(detected_migration), + )) .child(header()) .child( rect() @@ -74,8 +84,8 @@ impl Component for OnboardingShell { } } -fn progress_bar(step_index: usize) -> impl IntoElement { - let filled = ((step_index + 1) as f32 / ONBOARDING_TOTAL as f32) * 100.; +fn progress_bar(step_index: usize, total: usize) -> impl IntoElement { + let filled = ((step_index + 1) as f32 / total as f32) * 100.; rect() .width(Size::fill()) diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index 8323c48c..ba20fc95 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -23,7 +23,7 @@ use crate::view::{ }, onboarding::{ OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage, - OnboardingPreferences, OnboardingWelcome, + OnboardingMigration, OnboardingPreferences, OnboardingWelcome, }, }; @@ -37,6 +37,8 @@ pub enum Route { #[layout(OnboardingShell)] #[route("/onboarding")] OnboardingWelcome {}, + #[route("/onboarding/migration")] + OnboardingMigration {}, #[route("/onboarding/language")] OnboardingLanguage {}, #[route("/onboarding/account")] @@ -118,6 +120,7 @@ impl Route { match self { Route::Startup { .. } => "Startup".to_string(), Route::OnboardingWelcome { .. } => "Welcome".to_string(), + Route::OnboardingMigration { .. } => "Migration".to_string(), Route::OnboardingLanguage { .. } => "Language".to_string(), Route::OnboardingAccount { .. } => "Account".to_string(), Route::OnboardingPreferences { .. } => "Accessibility".to_string(), diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index 732d316e..09b49b8b 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -4,6 +4,7 @@ use std::time::{Duration, Instant}; use freya::animation::*; use freya::prelude::*; use freya::router::RouterContext; +use oneclient_core::ImportTarget; use oneclient_core::notification::{ GroupedProgressEvent, GroupedProgressSession, Notification, NotificationService, }; @@ -11,12 +12,13 @@ use tokio::sync::mpsc; use uuid::Uuid; use crate::hooks::{ - BridgeDispatch, ClusterBundles, invalidate_cluster_queries, onboarding_bundles_items, - try_default_account, use_current_account, use_dispatch, use_onboarding_bundles, - use_onboarding_selection, use_settings_snapshot, + BridgeDispatch, ClusterBundles, invalidate_cluster_queries, migration_detection, + onboarding_bundles_items, try_default_account, use_current_account, use_dispatch, + use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot, }; use crate::routes::Route; use crate::theme::colors; +use crate::view::onboarding::matching_new_cluster_id; mod backdrop; mod progress; @@ -185,6 +187,7 @@ impl Component for OnboardingDownloading { fn render(&self) -> impl IntoElement { let dispatch = use_dispatch(); let bundles_query = use_onboarding_bundles(); + let migration_query = use_migration(); let selection = use_onboarding_selection(); let settings = use_settings_snapshot().settings; let account_query = use_current_account(); @@ -298,6 +301,34 @@ impl Component for OnboardingDownloading { .map(|account| account.username.clone()) .unwrap_or_else(|| "Not signed in".to_string()); + let import_dispatch = dispatch.clone(); + let import_folder = selection.import_folder; + let import_dedicated = selection.import_dedicated; + let import_detection = migration_detection(&migration_query); + let import_items = items.clone(); + + let migration_summary = import_detection.as_ref().map(|detection| { + let source_name = detection.source.display_name().to_string(); + match import_folder.read().clone() { + Some(folder) => { + let dedicated = *import_dedicated.read() + && detection + .instances + .iter() + .find(|c| c.folder_name == folder) + .and_then(|inst| matching_new_cluster_id(inst, &items)) + .is_some(); + let target = if dedicated { + "This version only" + } else { + "Shared game directory" + }; + (source_name, folder, target.to_string()) + } + None => (source_name, "No files imported".to_string(), String::new()), + } + }); + return summary_view( &items, &selected, @@ -305,8 +336,26 @@ impl Component for OnboardingDownloading { reduce_motion, settings.dynamic_background_enabled, account_name, + migration_summary, predownload_state, move |_| { + if let (Some(detection), Some(folder)) = + (import_detection.as_ref(), import_folder.peek().clone()) + { + let target = if *import_dedicated.peek() { + detection + .instances + .iter() + .find(|c| c.folder_name == folder) + .and_then(|inst| matching_new_cluster_id(inst, &import_items)) + .map(|new_cluster_id| ImportTarget::Dedicated { new_cluster_id }) + .unwrap_or(ImportTarget::Shared) + } else { + ImportTarget::Shared + }; + import_dispatch.import_launcher(detection.source, folder, target); + } + setup_started.set(true); confirmed.set(true); }, @@ -722,7 +771,7 @@ fn finish_onboarding( .iter() .map(|cb| cb.cluster.mc_version.clone()) .collect(); - + versions.sort(); versions.dedup(); versions diff --git a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs index 753209ab..5e9dcc93 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs @@ -18,6 +18,7 @@ pub(super) fn summary_view( reduce_motion: bool, parallax: bool, account_name: String, + migration: Option<(String, String, String)>, predownload: State, on_setup: impl FnMut(Event) + 'static, ) -> impl IntoElement { @@ -43,6 +44,17 @@ pub(super) fn summary_view( "Content is downloaded the first time you launch each version.".to_string() }; + let migration_section: Element = match migration { + Some((source, folder, target)) => { + let mut rows = vec![summary_line("From", &source), summary_line("Files", &folder)]; + if !target.is_empty() { + rows.push(summary_line("Destination", &target)); + } + summary_section("Migration", None, rows).into_element() + } + None => rect().into_element(), + }; + let content = rect() .vertical() .width(Size::fill()) @@ -66,6 +78,7 @@ pub(super) fn summary_view( summary_line("Account", &account_name), ], )) + .child(migration_section) .child(summary_section( "Accessibility", None, diff --git a/packages/oneclient_app/src/view/onboarding/language.rs b/packages/oneclient_app/src/view/onboarding/language.rs index e49a90fd..54379950 100644 --- a/packages/oneclient_app/src/view/onboarding/language.rs +++ b/packages/oneclient_app/src/view/onboarding/language.rs @@ -1,7 +1,7 @@ use freya::prelude::*; use crate::components::IconType; -use crate::hooks::use_onboarding_selection; +use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection}; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; @@ -25,6 +25,12 @@ impl Component for OnboardingLanguage { fn render(&self) -> impl IntoElement { let selected = use_state(|| 0usize); let language = use_onboarding_selection().language; + let migration_query = use_migration(); + let back = if has_migration_data(&migration_query) { + Route::OnboardingMigration {} + } else { + Route::OnboardingWelcome {} + }; let rows: Vec = LANGUAGES .iter() @@ -55,11 +61,7 @@ impl Component for OnboardingLanguage { onboarding_page( onboarding_illustration(IconType::OnboardingLanguage), content, - onboarding_nav( - Some(Route::OnboardingWelcome {}), - Route::OnboardingAccount {}, - true, - ), + onboarding_nav(Some(back), Route::OnboardingAccount {}, true), ) } } diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs new file mode 100644 index 00000000..aca061c5 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -0,0 +1,488 @@ +use std::collections::HashSet; + +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::SourceInstance; + +use crate::components::{Button, Icon, IconType, ScrollArea}; +use crate::hooks::{ + ClusterBundles, migration_detection, onboarding_bundles_items, use_migration, + use_onboarding_bundles, use_onboarding_selection, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::onboarding::{onboarding_illustration, onboarding_slide, step_heading}; + +/// Bundle-selection key, identical to `bundles::pkg_key` and the format the +/// downloading step reads. Must stay in sync with those. +fn pkg_key(cluster_id: i64, bundle_name: &str, package_id: &str) -> String { + format!("{cluster_id}|{bundle_name}|{package_id}") +} + +/// Build the pre-selected bundle set from the source install: for each new +/// cluster, match a source instance by version+loader, then select every +/// enabled/visible file of bundles whose category the user had installed. +fn build_migrated_selection(source: &[SourceInstance], new: &[ClusterBundles]) -> HashSet { + let mut selected = HashSet::new(); + + for cb in new { + let Some(instance) = source.iter().find(|o| { + o.mc_version == cb.cluster.mc_version && o.mc_loader == cb.cluster.mc_loader + }) else { + continue; + }; + + for archive in &cb.archives { + let category = archive.manifest.category.trim(); + let wanted = instance + .categories + .iter() + .any(|c| c.eq_ignore_ascii_case(category)); + if !wanted { + continue; + } + + for file in &archive.manifest.files { + if file.enabled && !file.hidden { + selected.insert(pkg_key( + cb.cluster.id, + &archive.manifest.name, + &file.kind.package_id(), + )); + } + } + } + } + + selected +} + +/// Whether a new cluster exists matching this source instance's version+loader +/// (needed for the "dedicated dir" import option to have a target). +pub(crate) fn matching_new_cluster_id( + instance: &SourceInstance, + new: &[ClusterBundles], +) -> Option { + new.iter() + .find(|cb| { + cb.cluster.mc_version == instance.mc_version + && cb.cluster.mc_loader == instance.mc_loader + }) + .map(|cb| cb.cluster.id) +} + +#[derive(PartialEq)] +pub struct OnboardingMigration; + +impl Component for OnboardingMigration { + fn render(&self) -> impl IntoElement { + let migration_query = use_migration(); + let bundles_query = use_onboarding_bundles(); + let selection = use_onboarding_selection(); + let mut selected = selection.selected; + let mut seeded = selection.seeded; + // Shared across the flow; the file import is dispatched later, on Setup. + let mut import_folder = selection.import_folder; + let import_dedicated = selection.import_dedicated; + + // No detection yet -> render nothing meaningful; the shell only routes + // here when a source was detected, so this is a transient loading state. + let Some(detection) = migration_detection(&migration_query) else { + return rect().width(Size::fill()).height(Size::fill()).into_element(); + }; + let source_name = detection.source.display_name(); + let new_clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); + let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some(); + + let chosen_folder = import_folder.read().clone(); + let any_importable = detection.instances.iter().any(|c| c.has_game_dir); + + // One card per detected version. Importable cards double as the file- + // import selector; non-importable ones stay informational. + let mut version_cards: Vec = Vec::new(); + for instance in &detection.instances { + let selected_import = chosen_folder.as_deref() == Some(instance.folder_name.as_str()); + let dedicated_available = + matching_new_cluster_id(instance, &new_clusters).is_some(); + + version_cards.push(version_card( + instance, + selected_import, + *import_dedicated.read(), + dedicated_available, + import_dedicated, + { + let folder = instance.folder_name.clone(); + move |_| import_folder.set(Some(folder.clone())) + }, + )); + } + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(step_heading( + "Bring over your setup", + &format!( + "We found data from your previous {source_name} install. Your installed \ + categories are reselected automatically. Optionally, pick one version to \ + copy its files (worlds, config, and packs)." + ), + )) + // "Don't import files" is the default choice, pinned above the list. + .maybe_child(any_importable.then(|| { + import_choice_card( + "Don't import files", + "Start fresh — only your bundle selection carries over.", + None, + chosen_folder.is_none(), + move |_| import_folder.set(None), + ) + })) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Detected versions") + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .children(version_cards), + ), + ) + .into_element(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child(onboarding_slide( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .center() + .padding(Gaps::new_all(48.)) + .child(onboarding_illustration(IconType::ClockRewind)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(48., 80., 24., 24.)) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(content), + ), + ), + )) + .child(migration_nav(bundles_loaded, move || { + // Apply migrated category selection and mark seeded so the + // Bundles page does not overwrite it with "everything on". The + // file import itself is deferred to the Setup step, like every + // other onboarding action; the chosen folder/mode lives in the + // shared selection state. + let migrated = build_migrated_selection(&detection.instances, &new_clusters); + selected.set(migrated); + seeded.set(true); + + let _ = RouterContext::get().replace(Route::OnboardingLanguage {}); + })) + .into_element() + } +} + +/// A detected source instance rendered as a card. Shows version + categories +/// (always-reselected info); when the instance has a game dir it also acts as +/// the file-import selector and, when selected, exposes the shared/dedicated +/// target choice inline. +fn version_card( + instance: &SourceInstance, + selected: bool, + dedicated: bool, + dedicated_available: bool, + mut import_dedicated: State, + on_select: impl FnMut(()) + 'static, +) -> Element { + let importable = instance.has_game_dir; + let mut on_select = on_select; + + let categories = if instance.categories.is_empty() { + "No bundle categories".to_string() + } else { + instance.categories.join(", ") + }; + let subtitle = format!("{} · {}", instance.mc_version, instance.mc_loader); + + let (bg, border_color) = if selected { + (colors::brand(), colors::brand()) + } else { + (colors::page_elevated(), colors::component_border()) + }; + + let header = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(instance.folder_name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(11.) + .color(if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ) + .child( + label() + .text(categories) + .font_size(11.) + .color(if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ), + ) + // Selection affordance only when the instance actually has files. + .maybe_child(importable.then(|| { + if selected { + Icon::new(IconType::Check).size(18.).into_element() + } else { + label() + .text("Import files") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + } + })); + + let mut card = rect() + .vertical() + .width(Size::fill()) + .spacing(12.) + .padding(Gaps::new_symmetric(12., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(bg) + .border(border_all_color(1., border_color)) + .child(header); + + if importable { + card = card + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_press(move |_| on_select(())); + } + + // When this version is the chosen import, offer where its files should go. + if selected { + let mut targets = rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text("Where should these files go?") + .font_size(12.) + .color(colors::fg_primary()), + ) + .child(target_option( + "Shared game directory", + "Available to every version.", + !dedicated, + move |_| import_dedicated.set(false), + )); + + if dedicated_available { + targets = targets.child(target_option( + "This version only", + "Keep the files isolated to the matching version.", + dedicated, + move |_| import_dedicated.set(true), + )); + } + + card = card.child(targets); + } + + card.into_element() +} + +/// A standalone import choice with no associated version (e.g. "Don't import"). +fn import_choice_card( + title: &str, + subtitle: &str, + trailing: Option, + active: bool, + on_press: impl FnMut(()) + 'static, +) -> Element { + let mut on_press = on_press; + let (bg, border_color) = if active { + (colors::brand(), colors::brand()) + } else { + (colors::page_elevated(), colors::component_border()) + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(12., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(bg) + .border(border_all_color(1., border_color)) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_press(move |_| on_press(())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title.to_string()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle.to_string()) + .font_size(11.) + .color(if active { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ), + ) + .maybe_child( + active + .then(|| Icon::new(IconType::Check).size(16.)) + .or_else(|| trailing.map(|i| Icon::new(i).size(16.))), + ) + .into_element() +} + +/// A shared/dedicated radio row nested inside a selected version card. +fn target_option( + title: &str, + subtitle: &str, + active: bool, + on_press: impl FnMut(()) + 'static, +) -> Element { + let mut on_press = on_press; + let border_color = if active { + colors::fg_primary() + } else { + colors::component_border() + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., border_color)) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_press(move |_| on_press(())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title.to_string()) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle.to_string()) + .font_size(11.) + .color(colors::fg_primary()), + ), + ) + .maybe_child(active.then(|| Icon::new(IconType::Check).size(15.))) + .into_element() +} + +/// Custom nav: like `onboarding_nav` but the Next button runs `on_next` (which +/// applies the migration + navigates) instead of a plain route replace. +fn migration_nav(next_enabled: bool, on_next: impl FnMut() + 'static) -> impl IntoElement { + let mut on_next = on_next; + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new(0., 40., 32., 40.)) + .child( + Button::new() + .secondary() + .width(Size::px(128.)) + .on_press(move |_| { + let _ = RouterContext::get().replace(Route::OnboardingWelcome {}); + }) + .text("Back"), + ) + .child( + Button::new() + .primary() + .width(Size::px(140.)) + .enabled(next_enabled) + .on_press(move |_| on_next()) + .text("Next") + .child(Icon::new(IconType::ArrowRight).size(16.)), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs index f044f03c..d9e14f98 100644 --- a/packages/oneclient_app/src/view/onboarding/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/mod.rs @@ -2,6 +2,7 @@ mod account; mod bundles; mod downloading; mod language; +mod migration; mod preferences; mod welcome; @@ -9,6 +10,8 @@ pub use account::OnboardingAccount; pub use bundles::OnboardingBundles; pub use downloading::{LoadingBackdrop, OnboardingDownloading}; pub use language::OnboardingLanguage; +pub use migration::OnboardingMigration; +pub(crate) use migration::matching_new_cluster_id; pub use preferences::OnboardingPreferences; pub use welcome::OnboardingWelcome; @@ -20,20 +23,27 @@ use freya::router::{RouterContext, use_route}; use crate::Route; use crate::components::{Button, Icon, IconType, toggle}; -use crate::hooks::use_onboarding_selection; +use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection}; use crate::theme::colors; use crate::ui::{border_all_color, entrance_motion_layer}; -pub const ONBOARDING_TOTAL: usize = 6; +/// Number of onboarding steps. The v1-migration step only exists (and is only +/// counted) when old launcher data was detected. +pub fn onboarding_total(has_migration: bool) -> usize { + if has_migration { 7 } else { 6 } +} + +pub fn onboarding_step_index(route: &Route, has_migration: bool) -> usize { + let shift = if has_migration { 1 } else { 0 }; -pub fn onboarding_step_index(route: &Route) -> usize { match route { Route::OnboardingWelcome {} => 0, - Route::OnboardingLanguage {} => 1, - Route::OnboardingAccount {} => 2, - Route::OnboardingBundles {} => 3, - Route::OnboardingPreferences {} => 4, - Route::OnboardingDownloading {} => 5, + Route::OnboardingMigration {} => 1, + Route::OnboardingLanguage {} => 1 + shift, + Route::OnboardingAccount {} => 2 + shift, + Route::OnboardingBundles {} => 3 + shift, + Route::OnboardingPreferences {} => 4 + shift, + Route::OnboardingDownloading {} => 5 + shift, _ => 0, } } @@ -44,7 +54,8 @@ const SLIDE_DISTANCE: f32 = 44.; pub(crate) fn onboarding_slide(content: impl IntoElement) -> impl IntoElement { let route = use_route::(); - let step = onboarding_step_index(&route); + let migration_query = use_migration(); + let step = onboarding_step_index(&route, has_migration_data(&migration_query)); let reduce_motion = use_onboarding_selection().reduce_motion; let direction = use_hook(|| { diff --git a/packages/oneclient_app/src/view/onboarding/welcome.rs b/packages/oneclient_app/src/view/onboarding/welcome.rs index 6d2e5a7c..90049759 100644 --- a/packages/oneclient_app/src/view/onboarding/welcome.rs +++ b/packages/oneclient_app/src/view/onboarding/welcome.rs @@ -1,6 +1,7 @@ use freya::prelude::*; use crate::components::IconType; +use crate::hooks::{has_migration_data, use_migration}; use crate::routes::Route; use crate::theme::colors; use crate::view::onboarding::{ @@ -12,6 +13,14 @@ pub struct OnboardingWelcome; impl Component for OnboardingWelcome { fn render(&self) -> impl IntoElement { + let migration_query = use_migration(); + + let next = if has_migration_data(&migration_query) { + Route::OnboardingMigration {} + } else { + Route::OnboardingLanguage {} + }; + let content = rect() .vertical() .width(Size::fill()) @@ -31,7 +40,7 @@ impl Component for OnboardingWelcome { onboarding_page( onboarding_illustration(IconType::OnboardingWelcome), content, - onboarding_nav(None, Route::OnboardingLanguage {}, true), + onboarding_nav(None, next, true), ) } } diff --git a/packages/oneclient_core/examples/detect_v1.rs b/packages/oneclient_core/examples/detect_v1.rs new file mode 100644 index 00000000..c6eee761 --- /dev/null +++ b/packages/oneclient_core/examples/detect_v1.rs @@ -0,0 +1,27 @@ +#[tokio::main] +async fn main() { + match oneclient_core::detect_migration().await { + Ok(Some(detection)) => { + println!( + "source: {} ({})", + detection.source.display_name(), + detection.source.id() + ); + println!("root: {}", detection.root.display()); + println!("instances: {}", detection.instances.len()); + for i in &detection.instances { + println!( + " [{}] {} ({} {:?}) game_dir={} categories={:?}", + i.instance_id, + i.folder_name, + i.mc_version, + i.mc_loader, + i.has_game_dir, + i.categories, + ); + } + } + Ok(None) => println!("no migratable launcher detected"), + Err(err) => eprintln!("detection error: {err}"), + } +} diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index dfd8ed29..90acc440 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -16,6 +16,7 @@ pub mod java; pub mod logger; pub mod logs; pub mod metadata; +pub mod migration; pub mod minecraft; pub mod notification; pub mod os_ext; @@ -51,6 +52,10 @@ pub use logs::{ delete_log_at, list_cluster_logs, read_log_at, upload_log_at, }; pub use metadata::{MetadataError, MetadataStore}; +pub use migration::{ + detect as detect_migration, import_game_dir as import_migration_game_dir, ImportTarget, + MigrationDetection, MigrationSource, SourceInstance, +}; pub use screenshots::{ ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot, }; diff --git a/packages/oneclient_core/src/migration/fs.rs b/packages/oneclient_core/src/migration/fs.rs new file mode 100644 index 00000000..1b9cbb82 --- /dev/null +++ b/packages/oneclient_core/src/migration/fs.rs @@ -0,0 +1,51 @@ +use std::path::{Path, PathBuf}; + +use crate::LauncherResult; + +pub async fn copy_tree(src: &Path, dst: &Path, exclude_top: &[&str]) -> LauncherResult<()> { + let mut stack: Vec<(PathBuf, PathBuf, bool)> = + vec![(src.to_path_buf(), dst.to_path_buf(), true)]; + + while let Some((cur_src, cur_dst, is_top)) = stack.pop() { + let mut entries = polyio::read_dir(&cur_src).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + + if is_top + && let Some(name_str) = name.to_str() + && exclude_top.iter().any(|e| e.eq_ignore_ascii_case(name_str)) + { + continue; + } + + let child_src = entry.path(); + let child_dst = cur_dst.join(&name); + let file_type = entry.file_type().await?; + + if file_type.is_dir() { + polyio::create_dir_all(&child_dst).await?; + + stack.push((child_src, child_dst, false)); + } else if file_type.is_file() { + if let Some(parent) = child_dst.parent() { + polyio::create_dir_all(parent).await?; + } + + polyio::copy(&child_src, &child_dst).await?; + } + } + } + + Ok(()) +} + +pub async fn dir_has_content(dir: &Path) -> bool { + if !dir.is_dir() { + return false; + } + + match polyio::read_dir(dir).await { + Ok(mut entries) => matches!(entries.next_entry().await, Ok(Some(_))), + Err(_) => false, + } +} diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs new file mode 100644 index 00000000..ca20034a --- /dev/null +++ b/packages/oneclient_core/src/migration/mod.rs @@ -0,0 +1,91 @@ +mod fs; +pub mod oneclient_v1; + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::LauncherResult; +use crate::packages::domain::GameLoader; + +pub use fs::copy_tree; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum MigrationSource { + OneClientV1, +} + +impl MigrationSource { + pub const ALL: &'static [MigrationSource] = &[MigrationSource::OneClientV1]; + + pub fn id(self) -> &'static str { + match self { + MigrationSource::OneClientV1 => "oneclient_v1", + } + } + + pub fn display_name(self) -> &'static str { + match self { + MigrationSource::OneClientV1 => "OneClient", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.iter().copied().find(|s| s.id() == id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceInstance { + /// Source-local identifier (v1: the cluster row id). Purely informational. + pub instance_id: i64, + pub folder_name: String, + pub mc_version: String, + pub mc_loader: GameLoader, + /// Bundle categories the user had installed on this instance, e.g. + /// `["HUD", "Performance"]`. Empty when the source has no category concept. + pub categories: Vec, + pub has_game_dir: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MigrationDetection { + pub source: MigrationSource, + pub root: PathBuf, + pub instances: Vec, +} + +/// Where an imported game directory should land in *this* launcher. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImportTarget { + /// Copy into the shared `/minecraft`. + Shared, + /// Copy into a specific new cluster's own folder and mark it dedicated so + /// `Cluster::game_dir()` resolves there instead of the shared dir. + Dedicated { new_cluster_id: i64 }, +} + +pub async fn detect() -> LauncherResult> { + for source in MigrationSource::ALL.iter().copied() { + let detection = match source { + MigrationSource::OneClientV1 => oneclient_v1::detect().await?, + }; + + if let Some(detection) = detection + && !detection.instances.is_empty() + { + return Ok(Some(detection)); + } + } + Ok(None) +} + +pub async fn import_game_dir( + source: MigrationSource, + folder_name: &str, + target: ImportTarget, +) -> LauncherResult<()> { + match source { + MigrationSource::OneClientV1 => oneclient_v1::import_game_dir(folder_name, target).await, + } +} diff --git a/packages/oneclient_core/src/migration/oneclient_v1.rs b/packages/oneclient_core/src/migration/oneclient_v1.rs new file mode 100644 index 00000000..e554995f --- /dev/null +++ b/packages/oneclient_core/src/migration/oneclient_v1.rs @@ -0,0 +1,191 @@ +use std::path::{Path, PathBuf}; + +use sqlx::Row; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use tracing::instrument; + +use crate::LauncherResult; +use crate::packages::domain::GameLoader; +use crate::paths; + +use super::fs::{copy_tree, dir_has_content}; +use super::{ImportTarget, MigrationDetection, MigrationSource, SourceInstance}; + +const IMPORT_EXCLUDE_TOP: &[&str] = &["mods", "logs"]; + +pub fn old_root() -> Option { + let base = directories::BaseDirs::new().map(|d| d.data_dir().join("OneClient")); + + let root = match std::env::var("ONECLIENT_V1_DIR") { + Ok(dir) => PathBuf::from(dir), + Err(_) => base?, + }; + + let exists = root.is_dir(); + tracing::debug!(root = %root.display(), exists, "v1 migration: resolved old root"); + exists.then_some(root) +} + +pub fn category_from_bundle_name(name: &str) -> Option { + let start = name.find('[')?; + let end = name[start + 1..].find(']')? + start + 1; + let inner = name[start + 1..end].trim(); + (!inner.is_empty()).then(|| inner.to_string()) +} + +#[instrument] +pub async fn detect() -> LauncherResult> { + let Some(root) = old_root() else { + return Ok(None); + }; + + let db_path = root.join("user_data.db"); + if !db_path.exists() { + tracing::debug!(db = %db_path.display(), "v1 migration: no user_data.db, skipping"); + return Ok(None); + } + + match detect_inner(&root, &db_path).await { + Ok(detection) => { + tracing::info!( + instances = detection.instances.len(), + "v1 migration: detected old install" + ); + Ok(Some(detection)) + } + Err(err) => { + tracing::warn!(error = %err, "failed to read old launcher database; skipping v1 migration"); + Ok(None) + } + } +} + +async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult { + let opts = SqliteConnectOptions::new() + .filename(db_path) + .read_only(true) + .immutable(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await?; + + let cluster_rows = sqlx::query("SELECT id, folder_name, mc_version, mc_loader FROM clusters") + .fetch_all(&pool) + .await?; + + let mut instances = Vec::with_capacity(cluster_rows.len()); + for row in cluster_rows { + let cluster_id: i64 = row.try_get("id")?; + let folder_name: String = row.try_get("folder_name")?; + let mc_version: String = row.try_get("mc_version")?; + let mc_loader_raw: i64 = row.try_get("mc_loader")?; + + let Some(mc_loader) = GameLoader::from_repr(mc_loader_raw as u8) else { + tracing::warn!( + cluster_id, + mc_loader_raw, + "old cluster has unknown loader; skipping" + ); + continue; + }; + + let categories = fetch_categories(&pool, cluster_id).await?; + + let has_game_dir = dir_has_content(&root.join("clusters").join(&folder_name)).await; + + instances.push(SourceInstance { + instance_id: cluster_id, + folder_name, + mc_version, + mc_loader, + categories, + has_game_dir, + }); + } + + pool.close().await; + + Ok(MigrationDetection { + source: MigrationSource::OneClientV1, + root: root.to_path_buf(), + instances, + }) +} + +async fn fetch_categories(pool: &sqlx::SqlitePool, cluster_id: i64) -> LauncherResult> { + let rows = sqlx::query( + "SELECT DISTINCT bundle_name FROM cluster_packages \ + WHERE cluster_id = ? AND bundle_name IS NOT NULL AND bundle_name <> ''", + ) + .bind(cluster_id) + .fetch_all(pool) + .await?; + + let mut categories: Vec = Vec::new(); + for row in rows { + let bundle_name: String = row.try_get("bundle_name")?; + let Some(category) = category_from_bundle_name(&bundle_name) else { + continue; + }; + + if !categories.iter().any(|c| c.eq_ignore_ascii_case(&category)) { + categories.push(category); + } + } + + Ok(categories) +} + +#[instrument(skip(target))] +pub async fn import_game_dir(folder_name: &str, target: ImportTarget) -> LauncherResult<()> { + let Some(root) = old_root() else { + return Ok(()); + }; + let src = root.join("clusters").join(folder_name); + if !src.is_dir() { + tracing::warn!(folder_name, "old cluster folder missing; nothing to import"); + return Ok(()); + } + + let dest = match &target { + ImportTarget::Shared => paths::shared_minecraft_dir()?, + ImportTarget::Dedicated { new_cluster_id } => { + let state = crate::state::LauncherState::get()?; + let cluster = crate::clusters::ClusterManager::get(&state, *new_cluster_id).await?; + let dir = cluster.dir()?; + polyio::create_dir_all(&dir).await?; + // Mark dedicated so game_dir() resolves to this cluster's own dir. + polyio::write(cluster.dedicated_marker()?, Vec::new()).await?; + dir + } + }; + + polyio::create_dir_all(&dest).await?; + copy_tree(&src, &dest, IMPORT_EXCLUDE_TOP).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn category_extraction() { + assert_eq!( + category_from_bundle_name("OneClient 1.21.1 Fabric [HUD]"), + Some("HUD".to_string()) + ); + assert_eq!( + category_from_bundle_name("OneClient 1.21.11 Fabric [QoL]"), + Some("QoL".to_string()) + ); + assert_eq!(category_from_bundle_name("no brackets here"), None); + assert_eq!(category_from_bundle_name("empty []"), None); + assert_eq!( + category_from_bundle_name("trims [ Performance ]"), + Some("Performance".to_string()) + ); + } +}