feat: Onboarding Migration page

This commit is contained in:
LynithDev 2026-07-10 20:53:24 +02:00
parent 7def9a9c8c
commit f2bc2d888f
21 changed files with 1157 additions and 130 deletions

View file

@ -89,4 +89,10 @@ pub enum BridgeCommand {
},
ApplyBundleUpdates { cluster_id: ClusterId },
SyncBundles,
ImportLauncher {
source: oneclient_core::MigrationSource,
folder_name: String,
target: oneclient_core::ImportTarget,
},
}

View file

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

View file

@ -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) {

View file

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

View file

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

View 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())
}

View file

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

View 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
}

View file

@ -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())

View file

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

View file

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

View file

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

View file

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

View 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()
}

View file

@ -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(|| {

View file

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

View 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}"),
}
}

View file

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

View 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,
}
}

View 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,
}
}

View 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())
);
}
}