feat: Onboarding Migration page
This commit is contained in:
parent
7def9a9c8c
commit
f2bc2d888f
21 changed files with 1157 additions and 130 deletions
|
|
@ -89,4 +89,10 @@ pub enum BridgeCommand {
|
|||
},
|
||||
ApplyBundleUpdates { cluster_id: ClusterId },
|
||||
SyncBundles,
|
||||
|
||||
ImportLauncher {
|
||||
source: oneclient_core::MigrationSource,
|
||||
folder_name: String,
|
||||
target: oneclient_core::ImportTarget,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ pub struct OnboardingSelectionState {
|
|||
pub reduce_motion: State<bool>,
|
||||
pub predownload: State<bool>,
|
||||
pub setup_started: State<bool>,
|
||||
/// v1-migration: source folder to import files from (`None` = don't import).
|
||||
pub import_folder: State<Option<String>>,
|
||||
/// v1-migration: import into the matching cluster's own dir instead of shared.
|
||||
pub import_dedicated: State<bool>,
|
||||
}
|
||||
|
||||
pub fn use_provide_onboarding_selection(state: OnboardingSelectionState) {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,19 @@ impl BridgeDispatch {
|
|||
self.send(BridgeCommand::MarkOnboardingSeen);
|
||||
}
|
||||
|
||||
pub fn import_launcher(
|
||||
&self,
|
||||
source: oneclient_core::MigrationSource,
|
||||
folder_name: impl Into<String>,
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<String>);
|
||||
|
||||
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();
|
||||
|
|
|
|||
35
packages/oneclient_app/src/hooks/queries/migration.rs
Normal file
35
packages/oneclient_app/src/hooks/queries/migration.rs
Normal file
|
|
@ -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<MigrationDetection>;
|
||||
type Err = LauncherError;
|
||||
type Keys = MigrationKeys;
|
||||
|
||||
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
detect_migration().await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn use_migration() -> UseQuery<MigrationQuery> {
|
||||
use_query(Query::new(MigrationKeys, MigrationQuery))
|
||||
}
|
||||
|
||||
pub fn migration_detection(query: &UseQuery<MigrationQuery>) -> Option<MigrationDetection> {
|
||||
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<MigrationQuery>) -> bool {
|
||||
migration_detection(query).is_some_and(|d| !d.instances.is_empty())
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
45
packages/oneclient_app/src/hooks/queries/skin.rs
Normal file
45
packages/oneclient_app/src/hooks/queries/skin.rs
Normal file
|
|
@ -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::<String>);
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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::<String>);
|
||||
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::<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())
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub(super) fn summary_view(
|
|||
reduce_motion: bool,
|
||||
parallax: bool,
|
||||
account_name: String,
|
||||
migration: Option<(String, String, String)>,
|
||||
predownload: State<bool>,
|
||||
on_setup: impl FnMut(Event<PressEventData>) + '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,
|
||||
|
|
|
|||
|
|
@ -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<Element> = 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
488
packages/oneclient_app/src/view/onboarding/migration.rs
Normal file
488
packages/oneclient_app/src/view/onboarding/migration.rs
Normal file
|
|
@ -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<String> {
|
||||
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<i64> {
|
||||
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<Element> = 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<bool>,
|
||||
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<IconType>,
|
||||
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()
|
||||
}
|
||||
|
|
@ -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::<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(|| {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
packages/oneclient_core/examples/detect_v1.rs
Normal file
27
packages/oneclient_core/examples/detect_v1.rs
Normal file
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
51
packages/oneclient_core/src/migration/fs.rs
Normal file
51
packages/oneclient_core/src/migration/fs.rs
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
91
packages/oneclient_core/src/migration/mod.rs
Normal file
91
packages/oneclient_core/src/migration/mod.rs
Normal file
|
|
@ -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<MigrationSource> {
|
||||
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<String>,
|
||||
pub has_game_dir: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MigrationDetection {
|
||||
pub source: MigrationSource,
|
||||
pub root: PathBuf,
|
||||
pub instances: Vec<SourceInstance>,
|
||||
}
|
||||
|
||||
/// Where an imported game directory should land in *this* launcher.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ImportTarget {
|
||||
/// Copy into the shared `<launcher_dir>/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<Option<MigrationDetection>> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
191
packages/oneclient_core/src/migration/oneclient_v1.rs
Normal file
191
packages/oneclient_core/src/migration/oneclient_v1.rs
Normal file
|
|
@ -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<PathBuf> {
|
||||
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<String> {
|
||||
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<Option<MigrationDetection>> {
|
||||
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<MigrationDetection> {
|
||||
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<Vec<String>> {
|
||||
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<String> = 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue