From 1243dbcd7560efe92c8cb09e106c699d153349ce Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Thu, 16 Jul 2026 13:31:58 -0400 Subject: [PATCH] Revert "feat: Added migration from Lunar Client" This reverts commit 08fe999691c38f09a4ae62ccfce48207375274e3. --- packages/oneclient_app/src/bridge/commands.rs | 4 +- packages/oneclient_app/src/bridge/runtime.rs | 9 +- .../oneclient_app/src/hooks/active_cluster.rs | 13 +- packages/oneclient_app/src/hooks/dispatch.rs | 4 +- packages/oneclient_app/src/hooks/mod.rs | 12 +- .../src/hooks/queries/migration.rs | 26 +- .../oneclient_app/src/hooks/queries/mod.rs | 2 +- .../src/layout/onboarding_shell.rs | 8 +- packages/oneclient_app/src/view/app/debug.rs | 11 +- .../src/view/onboarding/downloading/mod.rs | 45 +- .../src/view/onboarding/migration.rs | 414 +++---------- .../oneclient_app/src/view/onboarding/mod.rs | 2 +- .../oneclient_core/examples/detect_lunar.rs | 29 - .../examples/detect_migrations.rs | 26 - packages/oneclient_core/examples/detect_v1.rs | 6 +- packages/oneclient_core/src/lib.rs | 28 +- .../src/migration/lunar_client.rs | 559 ------------------ .../src/migration/lunar_mods.rs | 254 -------- packages/oneclient_core/src/migration/mod.rs | 31 +- 19 files changed, 170 insertions(+), 1313 deletions(-) delete mode 100644 packages/oneclient_core/examples/detect_lunar.rs delete mode 100644 packages/oneclient_core/examples/detect_migrations.rs delete mode 100644 packages/oneclient_core/src/migration/lunar_client.rs delete mode 100644 packages/oneclient_core/src/migration/lunar_mods.rs diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs index 1be97d5a..63637f45 100644 --- a/packages/oneclient_app/src/bridge/commands.rs +++ b/packages/oneclient_app/src/bridge/commands.rs @@ -17,9 +17,7 @@ pub enum BridgeCommand { RecordSeenVersion { version: String, }, - SetOnboardingSeen { - seen: bool, - }, + MarkOnboardingSeen, AcceptTos { terms_version: u32, privacy_version: u32, diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index d15afc11..85329ab9 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -315,9 +315,9 @@ impl CoreBridgeRuntime { } })?; } - BridgeCommand::SetOnboardingSeen { seen } => { + BridgeCommand::MarkOnboardingSeen => { mutate_settings(snapshots, |settings| { - settings.seen_onboarding = seen; + settings.seen_onboarding = true; })?; let state = LauncherState::get()?; let snapshot = state.settings.read().clone(); @@ -436,11 +436,6 @@ impl CoreBridgeRuntime { let state = LauncherState::get()?; tokio::spawn(async move { let notifier = state.services.notifier.clone(); - - if let Err(err) = oneclient_core::import_migration_settings(source).await { - tracing::warn!(error = %err, "failed to import launcher settings"); - } - match oneclient_core::import_migration_game_dir(source, &folder_name, target) .await { diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index 11f6f767..3694c0d8 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -1,16 +1,9 @@ use std::collections::{HashMap, HashSet}; use freya::prelude::*; -use oneclient_core::MigrationSource; use oneclient_core::packages::ProviderId; use oneclient_db::models::ClusterId; -#[derive(Clone, PartialEq, Eq, Debug)] -pub struct ImportSelection { - pub source: MigrationSource, - pub folder_name: String, -} - #[derive(Clone)] pub struct ActiveClusterState(pub State>); @@ -81,12 +74,16 @@ pub struct OnboardingSelectionState { /// keeps re-deriving `selected` from the catalog, so a bundle list that /// settles late still gets the right defaults. pub user_touched: State, + /// Bundle categories the user had in their v1 install, once the migration + /// step has been answered. `None` means "no migration decision yet". pub migrated_categories: State>>, pub language: State, pub reduce_motion: State, pub predownload: State, pub setup_started: State, - pub import_selection: State>, + /// v1-migration: source folder to import files from (`None` = don't import). + pub import_folder: State>, + /// v1-migration: import into the matching cluster's own dir instead of shared. pub import_dedicated: State, } diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs index 62bee53d..0b042bf0 100644 --- a/packages/oneclient_app/src/hooks/dispatch.rs +++ b/packages/oneclient_app/src/hooks/dispatch.rs @@ -38,8 +38,8 @@ impl BridgeDispatch { }); } - pub fn set_onboarding_seen(&self, seen: bool) { - self.send(BridgeCommand::SetOnboardingSeen { seen }); + pub fn mark_onboarding_seen(&self) { + self.send(BridgeCommand::MarkOnboardingSeen); } pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) { diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 9bc877bf..701bbfc1 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -6,11 +6,11 @@ mod view_state; pub use view_state::{PersistedView, use_view_state}; pub use active_cluster::{ - ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, ImportSelection, - LinkConfirmState, OnboardingSelectionState, use_active_cluster_id, use_browser_compat, - use_browser_state_store, use_link_confirm, use_onboarding_selection, - use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state, - use_provide_link_confirm, use_provide_onboarding_selection, + ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState, + OnboardingSelectionState, use_active_cluster_id, use_browser_compat, use_browser_state_store, + use_link_confirm, use_onboarding_selection, use_provide_active_cluster, + use_provide_browser_compat, use_provide_browser_state, use_provide_link_confirm, + use_provide_onboarding_selection, }; pub use dispatch::BridgeDispatch; @@ -25,7 +25,7 @@ pub use queries::{ 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_detections, + 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, reset_login_code_dedup, search_items, search_pending, search_total, terms_document, terms_error, terms_is_loading, try_account, diff --git a/packages/oneclient_app/src/hooks/queries/migration.rs b/packages/oneclient_app/src/hooks/queries/migration.rs index 0b64d42b..2ee691de 100644 --- a/packages/oneclient_app/src/hooks/queries/migration.rs +++ b/packages/oneclient_app/src/hooks/queries/migration.rs @@ -1,7 +1,5 @@ -use std::time::Duration; - use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; -use oneclient_core::{LauncherError, MigrationDetection, detect_migrations}; +use oneclient_core::{LauncherError, MigrationDetection, detect_migration}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct MigrationQuery; @@ -10,34 +8,28 @@ pub struct MigrationQuery; pub struct MigrationKeys; impl QueryCapability for MigrationQuery { - type Ok = Vec; + type Ok = Option; type Err = LauncherError; type Keys = MigrationKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - detect_migrations().await + detect_migration().await } } pub fn use_migration() -> UseQuery { - use_query( - Query::new(MigrationKeys, MigrationQuery) - .stale_time(Duration::from_secs(60 * 60)) - .clean_time(Duration::from_secs(6 * 60 * 60)), - ) + use_query(Query::new(MigrationKeys, MigrationQuery)) } -pub fn migration_detections(query: &UseQuery) -> Vec { +pub fn migration_detection(query: &UseQuery) -> Option { let reader = query.read(); match &*reader.state() { - QueryStateData::Settled { res: Ok(found), .. } => found.clone(), - QueryStateData::Loading { res: Some(Ok(found)) } => found.clone(), - _ => Vec::new(), + QueryStateData::Settled { res: Ok(opt), .. } => opt.clone(), + QueryStateData::Loading { res: Some(Ok(opt)) } => opt.clone(), + _ => None, } } pub fn has_migration_data(query: &UseQuery) -> bool { - migration_detections(query) - .iter() - .any(|d| !d.instances.is_empty()) + migration_detection(query).is_some_and(|d| !d.instances.is_empty()) } diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index f70c14e2..68947dd1 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -50,7 +50,7 @@ pub use logs::{ 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_detections, use_migration}; +pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration}; pub use mutations::{ ClusterAction, invalidate_cluster_queries, invalidate_profile_queries, use_cluster_mutation, }; diff --git a/packages/oneclient_app/src/layout/onboarding_shell.rs b/packages/oneclient_app/src/layout/onboarding_shell.rs index 77607193..0f6c489a 100644 --- a/packages/oneclient_app/src/layout/onboarding_shell.rs +++ b/packages/oneclient_app/src/layout/onboarding_shell.rs @@ -6,8 +6,8 @@ use freya::router::{Outlet, use_route}; use crate::Route; use crate::components::OnboardingNavbar; use crate::hooks::{ - ImportSelection, OnboardingSelectionState, has_migration_data, onboarding_bundles_items, - use_migration, 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::colors; use crate::view::onboarding::{ @@ -26,7 +26,7 @@ impl Component for OnboardingShell { let reduce_motion = use_state(|| false); let predownload = use_state(|| true); let setup_started = use_state(|| false); - let import_selection = use_state(|| None::); + let import_folder = use_state(|| None::); let import_dedicated = use_state(|| false); use_provide_onboarding_selection(OnboardingSelectionState { selected, @@ -36,7 +36,7 @@ impl Component for OnboardingShell { reduce_motion, predownload, setup_started, - import_selection, + import_folder, import_dedicated, }); diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 172dacd0..13e6b66b 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -2,7 +2,7 @@ use freya::prelude::*; use freya::router::RouterContext; use crate::components::{Button, Icon, IconType, TextInput, toggle}; -use crate::hooks::{BridgeDispatch, use_dispatch}; +use crate::hooks::use_dispatch; use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind}; use crate::routes::Route; use crate::theme::colors; @@ -12,7 +12,6 @@ pub struct Debug; impl Component for Debug { fn render(&self) -> impl IntoElement { - let dispatch = use_dispatch(); let log_debug_info = use_state(|| false); let show_dev_stuff = use_state(|| false); let seen_onboarding = use_state(|| true); @@ -66,7 +65,7 @@ impl Component for Debug { .child(divider()) .child(section( "Other", - vec![action_row(dispatch, vec![ + vec![action_row(vec![ ("Open Dev Tools", IconType::CodeSnippet02), ("Open Onboarding", IconType::Rocket02), ("Open Launcher Data", IconType::Folder), @@ -337,7 +336,7 @@ fn toggle_row(title: &'static str, on: State) -> Element { .into_element() } -fn action_row(dispatch: BridgeDispatch, buttons: Vec<(&'static str, IconType)>) -> Element { +fn action_row(buttons: Vec<(&'static str, IconType)>) -> Element { let mut row = rect().horizontal().width(Size::fill()).spacing(12.); for (text, icon) in buttons { @@ -347,9 +346,7 @@ fn action_row(dispatch: BridgeDispatch, buttons: Vec<(&'static str, IconType)>) .text(text); if text == "Open Onboarding" { - let dispatch = dispatch.clone(); - button = button.on_press(move |_| { - dispatch.set_onboarding_seen(false); + button = button.on_press(|_| { let _ = RouterContext::get().replace(Route::OnboardingWelcome {}); }); } diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index 12c52dbd..ef0126e1 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -13,14 +13,14 @@ use tokio::sync::mpsc; use uuid::Uuid; use crate::hooks::{ - BridgeDispatch, ClusterBundles, invalidate_cluster_queries, migration_detections, + 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_versions, versions_metadata, }; use crate::routes::Route; use crate::theme::colors; -use crate::view::onboarding::{find_instance, matching_new_cluster_id, pkg_key, sources_sentence}; +use crate::view::onboarding::{matching_new_cluster_id, pkg_key}; mod backdrop; mod progress; @@ -319,17 +319,20 @@ impl Component for OnboardingDownloading { .unwrap_or_else(|| "Not signed in".to_string()); let import_dispatch = dispatch.clone(); - let import_selection = selection.import_selection; + let import_folder = selection.import_folder; let import_dedicated = selection.import_dedicated; - let import_detections = migration_detections(&migration_query); + let import_detection = migration_detection(&migration_query); let import_items = items.clone(); - let migration_summary = - (!import_detections.is_empty()).then(|| match import_selection.read().clone() { - Some(chosen) => { - let source_name = chosen.source.display_name().to_string(); + 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() - && find_instance(&import_detections, &chosen) + && detection + .instances + .iter() + .find(|c| c.folder_name == folder) .and_then(|inst| matching_new_cluster_id(inst, &items)) .is_some(); let target = if dedicated { @@ -337,14 +340,11 @@ impl Component for OnboardingDownloading { } else { "Shared game directory" }; - (source_name, chosen.folder_name, target.to_string()) + (source_name, folder, target.to_string()) } - None => ( - sources_sentence(&import_detections), - "No files imported".to_string(), - String::new(), - ), - }); + None => (source_name, "No files imported".to_string(), String::new()), + } + }); return summary_view( &items, @@ -357,16 +357,21 @@ impl Component for OnboardingDownloading { migration_summary, predownload_state, move |_| { - if let Some(chosen) = import_selection.peek().clone() { + if let (Some(detection), Some(folder)) = + (import_detection.as_ref(), import_folder.peek().clone()) + { let target = if *import_dedicated.peek() { - find_instance(&import_detections, &chosen) + 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(chosen.source, chosen.folder_name, target); + import_dispatch.import_launcher(detection.source, folder, target); } setup_started.set(true); @@ -862,7 +867,7 @@ fn finish_onboarding( dispatch.record_seen_version(version); } - dispatch.set_onboarding_seen(true); + dispatch.mark_onboarding_seen(); let _ = RouterContext::get().replace(Route::Home {}); }); } diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs index 917f7b2e..7df6d269 100644 --- a/packages/oneclient_app/src/view/onboarding/migration.rs +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -1,10 +1,10 @@ use freya::prelude::*; use freya::router::RouterContext; -use oneclient_core::{MigrationDetection, SourceInstance}; +use oneclient_core::SourceInstance; use crate::components::{Button, Icon, IconType, ScrollArea}; use crate::hooks::{ - ClusterBundles, ImportSelection, migration_detections, onboarding_bundles_items, use_migration, + ClusterBundles, migration_detection, onboarding_bundles_items, use_migration, use_onboarding_bundles, use_onboarding_selection, }; use crate::routes::Route; @@ -14,9 +14,9 @@ use crate::view::onboarding::{ choice_row, onboarding_illustration, onboarding_slide, step_heading, }; -fn migrated_categories(detections: &[MigrationDetection]) -> Vec { +fn migrated_categories(source: &[SourceInstance]) -> Vec { let mut categories: Vec = Vec::new(); - for instance in detections.iter().flat_map(|d| &d.instances) { + for instance in source { for category in &instance.categories { if !categories.iter().any(|c| c.eq_ignore_ascii_case(category)) { categories.push(category.clone()); @@ -26,28 +26,8 @@ fn migrated_categories(detections: &[MigrationDetection]) -> Vec { categories } -pub(crate) fn find_instance<'a>( - detections: &'a [MigrationDetection], - chosen: &ImportSelection, -) -> Option<&'a SourceInstance> { - detections - .iter() - .find(|d| d.source == chosen.source)? - .instances - .iter() - .find(|i| i.folder_name == chosen.folder_name) -} - -pub(crate) fn sources_sentence(detections: &[MigrationDetection]) -> String { - let names: Vec<&str> = detections.iter().map(|d| d.source.display_name()).collect(); - - match names.split_last() { - None => String::new(), - Some((last, [])) => (*last).to_string(), - Some((last, rest)) => format!("{} and {last}", rest.join(", ")), - } -} - +/// 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], @@ -69,60 +49,42 @@ impl Component for OnboardingMigration { let bundles_query = use_onboarding_bundles(); let selection = use_onboarding_selection(); let mut categories = selection.migrated_categories; - let mut import_selection = selection.import_selection; + // 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; - let detections = migration_detections(&migration_query); - if detections.is_empty() { + // 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 = import_selection.read().clone(); - let any_importable = detections - .iter() - .flat_map(|d| &d.instances) - .any(|c| c.has_game_dir); + let chosen_folder = import_folder.read().clone(); + let any_importable = detection.instances.iter().any(|c| c.has_game_dir); - let mut source_groups: Vec = Vec::new(); - for detection in &detections { - let source = detection.source; - let mut version_cards: Vec = Vec::new(); + // One card per detected version. Importable cards double as the file- + // import selector; non-importable ones stay informational. + let mut version_cards: Vec = Vec::new(); + for instance in &detection.instances { + let selected_import = chosen_folder.as_deref() == Some(instance.folder_name.as_str()); + let dedicated_available = matching_new_cluster_id(instance, &new_clusters).is_some(); - for instance in &detection.instances { - let selected_import = chosen - .as_ref() - .is_some_and(|c| c.source == source && c.folder_name == instance.folder_name); - 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_name = instance.folder_name.clone(); - move |_| { - import_selection.set(Some(ImportSelection { - source, - folder_name: folder_name.clone(), - })) - } - }, - )); - } - - source_groups.push(source_group( - source.display_name(), - detection.instances.len(), - version_cards, + 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())) + }, )); } @@ -133,27 +95,40 @@ impl Component for OnboardingMigration { .child(step_heading( "Bring over your setup", &format!( - "We found data from {}. Your installed categories are reselected \ - automatically. Optionally, pick one version to copy its files \ - (worlds, config, and packs).", - sources_sentence(&detections) + "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.is_none(), - move |_| import_selection.set(None), + chosen_folder.is_none(), + move |_| import_folder.set(None), ) })) .child( rect() .vertical() .width(Size::fill()) - .spacing(16.) - .children(source_groups), + .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(); @@ -192,7 +167,7 @@ impl Component for OnboardingMigration { ), )) .child(migration_nav(bundles_loaded, move || { - categories.set(Some(migrated_categories(&detections))); + categories.set(Some(migrated_categories(&detection.instances))); let _ = RouterContext::get().replace(Route::OnboardingLanguage {}); })) @@ -200,86 +175,11 @@ impl Component for OnboardingMigration { } } -pub fn source_group(name: &str, instance_count: usize, cards: Vec) -> Element { - let counted = if instance_count == 1 { - "1 version".to_string() - } else { - format!("{instance_count} versions") - }; - - rect() - .vertical() - .width(Size::fill()) - .spacing(10.) - .child( - rect() - .horizontal() - .width(Size::fill()) - .content(Content::Flex) - .cross_align(Alignment::Center) - .spacing(8.) - .child( - label() - .text(name.to_string()) - .font_size(13.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_primary()), - ) - .child( - rect() - .width(Size::flex(1.0)) - .height(Size::px(1.)) - .background(colors::component_border()), - ) - .child( - label() - .text(counted) - .font_size(11.) - .color(colors::fg_secondary()), - ), - ) - .child( - rect() - .vertical() - .width(Size::fill()) - .spacing(8.) - .children(cards), - ) - .into_element() -} - -pub fn chip(text: String, on_brand: bool) -> Element { - let (bg, border, fg) = if on_brand { - ( - colors::fg_primary().with_a(38), - colors::fg_primary().with_a(70), - colors::fg_primary(), - ) - } else { - ( - colors::component_bg(), - colors::component_border(), - colors::fg_secondary(), - ) - }; - - rect() - .center() - .padding(Gaps::new_symmetric(2., 8.)) - .corner_radius(CornerRadius::new_all(999.)) - .background(bg) - .border(border_all_color(1., border)) - .child( - label() - .text(text) - .font_size(10.) - .font_weight(FontWeight::MEDIUM) - .color(fg), - ) - .into_element() -} - -pub fn version_card( +/// 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, @@ -290,38 +190,18 @@ pub fn version_card( 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 subtitle_color = if selected { - colors::fg_primary() - } else { - colors::fg_secondary() - }; - - let icon_tile = rect() - .width(Size::px(34.)) - .height(Size::px(34.)) - .center() - .corner_radius(CornerRadius::new_all(8.)) - .background(if selected { - colors::fg_primary().with_a(38) - } else { - colors::component_bg() - }) - .child( - Icon::new(if !importable { - IconType::FileX02 - } else if selected { - IconType::FolderCheck - } else { - IconType::Folder - }) - .size(17.) - .color(subtitle_color), - ); let header = rect() .horizontal() @@ -329,7 +209,6 @@ pub fn version_card( .content(Content::Flex) .cross_align(Alignment::Center) .spacing(12.) - .child(icon_tile) .child( rect() .vertical() @@ -342,61 +221,39 @@ pub fn version_card( .font_weight(FontWeight::MEDIUM) .color(colors::fg_primary()), ) - .child( - label() - .text(format!("{} · {}", instance.mc_version, instance.mc_loader)) - .font_size(11.) - .color(subtitle_color), - ), + .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() + })), ) - .child(if !importable { - label() - .text("No files") - .font_size(11.) - .color(colors::fg_secondary()) - .into_element() - } else 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 categories = if instance.categories.is_empty() { - label() - .text("No bundle categories") - .font_size(11.) - .color(subtitle_color) - .into_element() - } else { - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(6.) - .children( - instance - .categories - .iter() - .map(|c| chip(c.clone(), selected)) - .collect::>(), - ) - .into_element() - }; + // 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(10.) + .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) - .child(categories); + .child(header); if importable { card = card @@ -531,96 +388,3 @@ fn migration_nav(next_enabled: bool, on_next: impl FnMut() + 'static) -> impl In ) .into_element() } - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use oneclient_core::MigrationSource; - use oneclient_core::packages::domain::GameLoader; - - use super::*; - - fn instance(folder: &str, categories: &[&str]) -> SourceInstance { - SourceInstance { - instance_id: 0, - folder_name: folder.to_string(), - mc_version: "1.21.10".to_string(), - mc_loader: GameLoader::Fabric, - categories: categories.iter().map(|c| c.to_string()).collect(), - has_game_dir: true, - } - } - - fn detection(source: MigrationSource, instances: Vec) -> MigrationDetection { - MigrationDetection { - source, - root: PathBuf::from("/tmp"), - instances, - } - } - - fn both() -> Vec { - vec![ - detection( - MigrationSource::OneClientV1, - vec![instance("1.21", &["HUD", "QoL"])], - ), - detection( - MigrationSource::LunarClient, - vec![instance("1.21", &["QoL", "PvP"]), instance("1.8", &["PvP"])], - ), - ] - } - - #[test] - fn categories_pool_across_every_detected_launcher() { - let categories = migrated_categories(&both()); - - assert_eq!(categories, vec!["HUD", "QoL", "PvP"]); - } - - #[test] - fn categories_are_not_duplicated_across_launchers() { - // "QoL" is present in both detections above. - let categories = migrated_categories(&both()); - - assert_eq!(categories.iter().filter(|c| *c == "QoL").count(), 1); - } - - #[test] - fn same_folder_name_in_two_launchers_resolves_to_the_chosen_one() { - // Both launchers have a "1.21"; only the source tells them apart. - let chosen = ImportSelection { - source: MigrationSource::LunarClient, - folder_name: "1.21".to_string(), - }; - - let detections = both(); - let found = find_instance(&detections, &chosen).expect("instance"); - - assert_eq!(found.categories, vec!["QoL", "PvP"]); - } - - #[test] - fn a_selection_pointing_at_nothing_resolves_to_none() { - let chosen = ImportSelection { - source: MigrationSource::OneClientV1, - folder_name: "1.8".to_string(), - }; - - assert!(find_instance(&both(), &chosen).is_none()); - } - - #[test] - fn sources_read_as_a_sentence() { - let detections = both(); - - assert_eq!( - sources_sentence(&detections), - "OneClient and Lunar Client".to_string() - ); - assert_eq!(sources_sentence(&detections[..1]), "OneClient".to_string()); - assert_eq!(sources_sentence(&[]), String::new()); - } -} diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs index e774cc72..5f5a0458 100644 --- a/packages/oneclient_app/src/view/onboarding/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/mod.rs @@ -20,7 +20,7 @@ pub use bundles::OnboardingBundles; pub use downloading::{LoadingBackdrop, OnboardingDownloading}; pub use language::OnboardingLanguage; pub use migration::OnboardingMigration; -pub(crate) use migration::{find_instance, matching_new_cluster_id, sources_sentence}; +pub(crate) use migration::matching_new_cluster_id; pub use preferences::OnboardingPreferences; pub use terms::OnboardingTerms; pub use welcome::OnboardingWelcome; diff --git a/packages/oneclient_core/examples/detect_lunar.rs b/packages/oneclient_core/examples/detect_lunar.rs deleted file mode 100644 index a90cc28a..00000000 --- a/packages/oneclient_core/examples/detect_lunar.rs +++ /dev/null @@ -1,29 +0,0 @@ -use oneclient_core::migration::lunar_client; - -#[tokio::main] -async fn main() { - match lunar_client::detect().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 lunar client install detected"), - Err(err) => eprintln!("detection error: {err}"), - } -} diff --git a/packages/oneclient_core/examples/detect_migrations.rs b/packages/oneclient_core/examples/detect_migrations.rs deleted file mode 100644 index 702ca1fa..00000000 --- a/packages/oneclient_core/examples/detect_migrations.rs +++ /dev/null @@ -1,26 +0,0 @@ -#[tokio::main] -async fn main() { - match oneclient_core::detect_migrations().await { - Ok(detections) if detections.is_empty() => { - println!("no migratable launcher detected"); - } - Ok(detections) => { - println!("sources: {}", detections.len()); - for detection in &detections { - println!( - "\n{} ({}) root={}", - detection.source.display_name(), - detection.source.id(), - detection.root.display() - ); - for i in &detection.instances { - println!( - " {} ({} {:?}) game_dir={} categories={:?}", - i.folder_name, i.mc_version, i.mc_loader, i.has_game_dir, i.categories, - ); - } - } - } - Err(err) => eprintln!("detection error: {err}"), - } -} diff --git a/packages/oneclient_core/examples/detect_v1.rs b/packages/oneclient_core/examples/detect_v1.rs index 0f0ad79e..c6eee761 100644 --- a/packages/oneclient_core/examples/detect_v1.rs +++ b/packages/oneclient_core/examples/detect_v1.rs @@ -1,8 +1,6 @@ -use oneclient_core::migration::oneclient_v1; - #[tokio::main] async fn main() { - match oneclient_v1::detect().await { + match oneclient_core::detect_migration().await { Ok(Some(detection)) => { println!( "source: {} ({})", @@ -23,7 +21,7 @@ async fn main() { ); } } - Ok(None) => println!("no v1 install detected"), + Ok(None) => println!("no migratable launcher detected"), Err(err) => eprintln!("detection error: {err}"), } } diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 627608b6..1f16a66e 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -31,27 +31,29 @@ pub mod recovery; pub mod reporting; pub mod screenshots; pub mod settings; -mod state; pub mod status; pub mod tos; +mod state; pub mod version; pub mod versions; pub use bundles::{ - ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, BundleFileKind, - BundleManifest, BundleUpdateCheckResult, BundleWithUpdateStatus, BundlesManager, - FileUpdateStatus, apply_bundle_updates, check_bundle_updates, effective_enabled, - get_bundles_with_update_status, install_bundle, install_cluster_bundles, + apply_bundle_updates, check_bundle_updates, effective_enabled, install_bundle, + install_cluster_bundles, install_package_from_bundle, is_bundle_syncing, list_cluster_bundle_overrides, set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, set_bundle_package_overrides, + ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, + BundleFileKind, BundleManifest, BundlesManager, BundleUpdateCheckResult, + BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status, }; -pub use changelog::{ChangelogGroup, fetch_changelog, parse_changelog}; +pub use changelog::{fetch_changelog, parse_changelog, ChangelogGroup}; +pub use tos::{fetch_terms, TermsDocument}; +pub use discord::{DiscordRpc, Presence}; pub use clusters::{ Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, ensure_from_bundles, ensure_from_versions, estimate_cluster_download, }; -pub use discord::{DiscordRpc, Presence}; pub use error::{LauncherError, LauncherResult}; pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster}; pub use images::ImageCacheStore; @@ -61,19 +63,17 @@ pub use logs::{ }; pub use metadata::{MetadataError, MetadataStore}; pub use migration::{ - ImportTarget, MigrationDetection, MigrationSource, SourceInstance, - detect_all as detect_migrations, import_game_dir as import_migration_game_dir, - import_settings as import_migration_settings, + 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, }; pub use notification::{GroupedProgressChild, GroupedProgressEvent, GroupedProgressSession}; pub use packages::LinkedArtifactInfo; pub use patch::Patch; -pub use screenshots::{ - ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot, -}; pub use settings::ProfileUpdate; pub use state::LauncherServices; pub use state::LauncherState; -pub use tos::{TermsDocument, fetch_terms}; pub use version::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; pub use versions::{RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest}; diff --git a/packages/oneclient_core/src/migration/lunar_client.rs b/packages/oneclient_core/src/migration/lunar_client.rs deleted file mode 100644 index b42fde4f..00000000 --- a/packages/oneclient_core/src/migration/lunar_client.rs +++ /dev/null @@ -1,559 +0,0 @@ -use std::path::{Path, PathBuf}; - -use serde::Deserialize; -use sqlx::Row; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; - -use crate::LauncherResult; -use crate::packages::domain::GameLoader; -use crate::paths; -use crate::settings::{GameSettingsProfile, Resolution, store}; - -use super::fs::{copy_tree, dir_has_content}; -use super::{ImportTarget, MigrationDetection, MigrationSource, SourceInstance}; - -const IMPORT_EXCLUDE_TOP: &[&str] = &[ - "mods", - "logs", - "optionsLC.txt", - "optionsof.txt", - "base-modpack.mrpack", - "servers.dat_old", -]; - -pub fn old_root() -> Option { - let base = directories::BaseDirs::new().map(|d| d.home_dir().join(".lunarclient")); - - let root = match std::env::var("LUNAR_CLIENT_DIR") { - Ok(dir) => PathBuf::from(dir), - Err(_) => base?, - }; - - let exists = root.is_dir(); - tracing::debug!(root = %root.display(), exists, "lunar migration: resolved old root"); - exists.then_some(root) -} - -/// Lunar mod ids appear in two spellings across config versions (`MOTION_BLUR` and `motionBlur`) -fn normalize_mod_id(id: &str) -> String { - id.chars() - .filter(|c| c.is_ascii_alphanumeric()) - .flat_map(char::to_uppercase) - .collect() -} - -/// Maps a Lunar mod onto the OneClient bundle category that ships an equivalent. -/// Ids absent from this table have no counterpart and contribute no category. -fn category_for_mod(normalized_id: &str) -> Option<&'static str> { - let category = match normalized_id { - "FPS" | "CPS" | "PING" | "CLOCK" | "PLAYTIME" | "MEMORY" | "STOPWATCH" | "COORDINATES" - | "COORDS" | "DIRECTIONHUD" | "ARMORSTATUS" | "KEYSTROKES" | "SCOREBOARD" - | "POTIONEFFECTS" | "POTIONCOUNTER" | "ITEMCOUNTER" | "TOTEMCOUNTER" | "ITEMTRACKER" - | "DAYCOUNTER" | "F3DISPLAY" | "BOSSBAR" | "TAB" | "TITLES" | "TITLEMOD" - | "INVENTORYMOD" | "HORSESTATS" | "PACKDISPLAY" | "RESOURCEDISPLAY" | "SERVERADDRESS" - | "SERVERADDRESSMOD" | "TEAMVIEW" | "SHINYPOTS" => "HUD", - - "HITBOX" | "HITCOLOR" | "PVPINFO" | "TIERTAGGER" | "UHCOVERLAY" | "SNAPLOOK" - | "HURTCAM" | "DAMAGETINT" | "REACHDISPLAY" | "RANGE" | "COMBO" | "COOLDOWNS" - | "OVERLAYMOD" | "TNTCOUNTDOWN" => "PvP", - - "MOTIONBLUR" | "MENUBLUR" | "3DSKINS" | "SKINLAYERS3D" | "GLINTCOLORIZER" | "CROSSHAIR" - | "BLOCKOUTLINE" | "FOG" | "FOGMOD" | "FOV" | "FOVMOD" | "ITEMPHYSICS" | "ITEMPHYSIC" - | "SCROLLABLETOOLTIPS" | "CHAT" | "NAMETAG" | "NICKHIDER" | "TIMECHANGER" - | "WEATHERCHANGER" | "PARTICLECHANGER" | "PARTICLEMOD" | "SATURATION" | "SATURATIONMOD" - | "COLORSATURATION" | "LIGHTING" | "2DITEMS" | "ITEMS2D" | "SHULKERPREVIEW" - | "AUDIOSUBTITLES" | "SOUNDCHANGER" | "MOBSIZE" | "MOMENTUM" | "MOMENTUMMOD" - | "ONESEVENVISUALS" | "PACKORGANIZER" | "ITEMCUSTOMIZER" | "AUTOTEXTACTIONS" - | "AUTOTEXTHOTKEY" | "TEXTHOTKEY" => "QoL", - - "ZOOM" | "WAILA" | "FREELOOK" | "MUMBLELINK" | "WORLDEDITCUI" | "CHUNKBORDERS" - | "SCREENSHOT" | "REPLAYMOD" | "REWIND" | "RADIO" | "MINIMAP" | "WAYPOINTS" | "MARKERS" - | "KILLSOUNDS" | "QUICKPLAY" | "TOGGLESNEAK" | "GUISCALE" | "HYPIXELMOD" - | "HYPIXELBEDWARS" => "Utility", - - "SKYBLOCK" | "SKYBLOCKADDONS" | "NEU" => "SkyBlock", - - _ => return None, - }; - - Some(category) -} - -#[derive(Debug, Deserialize)] -struct LunarModEntry { - #[serde(default)] - enabled: bool, -} - -#[derive(Debug, Deserialize)] -struct LunarSettingsProfile { - name: String, - #[serde(default)] - default: bool, - #[serde(default)] - active: bool, -} - -#[tracing::instrument(level = "debug")] -async fn active_profile_name(root: &Path) -> Option { - let path = root - .join("settings") - .join("game") - .join("profile_manager.json"); - let raw = polyio::read(&path).await.ok()?; - let profiles: Vec = serde_json::from_slice(&raw) - .inspect_err(|err| { - tracing::warn!(error = %err, "malformed profile_manager.json; no mod settings"); - }) - .ok()?; - - let chosen = profiles - .iter() - .find(|p| p.active) - .or_else(|| profiles.iter().find(|p| p.default)) - .or_else(|| profiles.first())?; - - tracing::debug!(profile = %chosen.name, "lunar migration: active settings profile"); - Some(chosen.name.clone()) -} - -/// The active profile's `mods.json`, as a raw object. -async fn active_profile_mods(root: &Path) -> Option> { - let name = active_profile_name(root).await?; - let path = root - .join("settings") - .join("game") - .join(name) - .join("mods.json"); - let raw = polyio::read(&path).await.ok()?; - - serde_json::from_slice(&raw) - .inspect_err(|err| { - tracing::warn!(path = %path.display(), error = %err, "malformed mods.json"); - }) - .ok() -} - -#[tracing::instrument(level = "debug")] -async fn enabled_categories(root: &Path) -> Vec { - let settings_dir = root.join("settings").join("game"); - let mut categories: Vec = Vec::new(); - - let mut entries = match polyio::read_dir(&settings_dir).await { - Ok(entries) => entries, - Err(err) => { - tracing::debug!(error = %err, "lunar migration: no game settings dir; no categories"); - return categories; - } - }; - - while let Ok(Some(entry)) = entries.next_entry().await { - let mods_file = entry.path().join("mods.json"); - if !mods_file.is_file() { - continue; - } - - let raw = match polyio::read(&mods_file).await { - Ok(raw) => raw, - Err(err) => { - tracing::warn!(path = %mods_file.display(), error = %err, "unreadable mods.json"); - continue; - } - }; - - // Values are heterogeneous (`"version": 14` alongside mod objects), so - // anything that does not deserialize into a mod entry is skipped. - let parsed: serde_json::Map = match serde_json::from_slice(&raw) - { - Ok(parsed) => parsed, - Err(err) => { - tracing::warn!(path = %mods_file.display(), error = %err, "malformed mods.json"); - continue; - } - }; - - for (mod_id, value) in parsed { - let Ok(entry) = serde_json::from_value::(value) else { - continue; - }; - if !entry.enabled { - continue; - } - - let Some(category) = category_for_mod(&normalize_mod_id(&mod_id)) else { - continue; - }; - if !categories.iter().any(|c| c == category) { - categories.push(category.to_string()); - } - } - } - - categories.sort(); - categories -} - -fn loader_from_json(raw: &str) -> GameLoader { - let loaders: Vec = serde_json::from_str(raw).unwrap_or_default(); - - if loaders.iter().any(|l| l.eq_ignore_ascii_case("fabric")) { - GameLoader::Fabric - } else if loaders.iter().any(|l| l.eq_ignore_ascii_case("quilt")) { - GameLoader::Quilt - } else if loaders.iter().any(|l| l.eq_ignore_ascii_case("forge")) { - GameLoader::Forge - } else { - GameLoader::Vanilla - } -} - -#[tracing::instrument] -pub async fn detect() -> LauncherResult> { - let Some(root) = old_root() else { - return Ok(None); - }; - - let db_path = root.join("db").join("profiles.db"); - if !db_path.exists() { - tracing::debug!(db = %db_path.display(), "lunar migration: no profiles.db, skipping"); - return Ok(None); - } - - match detect_inner(&root, &db_path).await { - Ok(detection) => { - tracing::info!( - instances = detection.instances.len(), - "lunar migration: detected old install" - ); - Ok(Some(detection)) - } - Err(err) => { - tracing::warn!(error = %err, "failed to read lunar profiles.db; skipping migration"); - Ok(None) - } - } -} - -#[tracing::instrument(level = "debug")] -async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult { - let opts = SqliteConnectOptions::new() - .filename(db_path) - .read_only(true) - .immutable(true); - let pool = SqlitePoolOptions::new() - .max_connections(1) - .connect_with(opts) - .await?; - - let profile_rows = sqlx::query("SELECT path, game_version, loaders FROM profiles") - .fetch_all(&pool) - .await?; - - pool.close().await; - - let categories = enabled_categories(root).await; - - let mut instances = Vec::with_capacity(profile_rows.len()); - for (index, row) in profile_rows.into_iter().enumerate() { - let folder_name: String = row.try_get("path")?; - let mc_version: String = row.try_get("game_version")?; - let loaders_raw: String = row.try_get("loaders")?; - - let has_game_dir = dir_has_content(&root.join("profiles").join(&folder_name)).await; - - instances.push(SourceInstance { - // Lunar keys profiles by uuid; SourceInstance ids are informational. - instance_id: index as i64, - folder_name, - mc_version, - mc_loader: loader_from_json(&loaders_raw), - categories: categories.clone(), - has_game_dir, - }); - } - - Ok(MigrationDetection { - source: MigrationSource::LunarClient, - root: root.to_path_buf(), - instances, - }) -} - -#[derive(Debug, Deserialize)] -struct LunarLauncherFile { - settings: LunarLauncherSettings, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct LunarLauncherSettings { - allocated_memory: Option, - game_resolution: Option, -} - -#[derive(Debug, Deserialize)] -struct LunarResolution { - width: Option, - height: Option, - #[serde(default)] - fullscreen: Option, -} - -#[tracing::instrument] -pub async fn import_settings() -> LauncherResult<()> { - let Some(root) = old_root() else { - return Ok(()); - }; - - let path = root.join("settings").join("launcher.json"); - if !path.is_file() { - tracing::debug!(path = %path.display(), "lunar migration: no launcher.json"); - return Ok(()); - } - - let raw = polyio::read(&path).await?; - let parsed: LunarLauncherFile = match serde_json::from_slice(&raw) { - Ok(parsed) => parsed, - Err(err) => { - tracing::warn!(error = %err, "malformed lunar launcher.json; keeping current settings"); - return Ok(()); - } - }; - - let state = crate::state::LauncherState::get()?; - let mut global: GameSettingsProfile = state.settings.read().global_game_settings.clone(); - - apply_lunar_settings(&mut global, &parsed.settings); - - store::save_global_profile(&state.settings, global).await?; - tracing::info!("lunar migration: imported launcher settings"); - - Ok(()) -} - -fn apply_lunar_settings(global: &mut GameSettingsProfile, lunar: &LunarLauncherSettings) { - if let Some(memory) = lunar.allocated_memory { - global.mem_max = Some(memory); - } - - if let Some(resolution) = &lunar.game_resolution { - if let (Some(width), Some(height)) = (resolution.width, resolution.height) { - global.resolution = Some(Resolution::new(width, height)); - } - if let Some(fullscreen) = resolution.fullscreen { - global.force_fullscreen = Some(fullscreen); - } - } -} - -/// Lunar stores the vanilla options it manages in `optionsLC.txt`, pretty much the same as options.txt just in json -#[tracing::instrument(level = "debug")] -async fn merge_lunar_options(src: &Path, dest: &Path) -> LauncherResult<()> { - let lunar_options = src.join("optionsLC.txt"); - if !lunar_options.is_file() { - return Ok(()); - } - - let raw = polyio::read(&lunar_options).await?; - let parsed: serde_json::Map = match serde_json::from_slice(&raw) { - Ok(parsed) => parsed, - Err(err) => { - tracing::warn!(error = %err, "malformed optionsLC.txt; keeping vanilla options.txt"); - return Ok(()); - } - }; - - let options_path = dest.join("options.txt"); - let existing = match polyio::read(&options_path).await { - Ok(existing) => String::from_utf8_lossy(&existing).into_owned(), - Err(_) => String::new(), - }; - - let merged = merge_options_text(&existing, &parsed); - polyio::write(&options_path, merged.into_bytes()).await?; - - Ok(()) -} - -/// Overwrites `options.txt` entries Lunar owns, appends the ones it adds, and -/// leaves every other line untouched. -fn merge_options_text( - existing: &str, - lunar: &serde_json::Map, -) -> String { - let overrides: Vec<(String, String)> = lunar - .iter() - .filter_map(|(key, value)| { - // Lunar's own bookkeeping keys are not vanilla options. - if key == "lastLaunchedVersion" || key == "version" { - return None; - } - let rendered = match value { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - _ => return None, - }; - Some((key.clone(), rendered)) - }) - .collect(); - - let mut out = String::with_capacity(existing.len()); - let mut written: Vec<&str> = Vec::new(); - - for line in existing.lines() { - let key = line.split_once(':').map(|(k, _)| k); - match key.and_then(|k| overrides.iter().find(|(ok, _)| ok == k)) { - Some((k, v)) => { - out.push_str(&format!("{k}:{v}\n")); - written.push(k); - } - None => { - out.push_str(line); - out.push('\n'); - } - } - } - - for (key, value) in &overrides { - if !written.iter().any(|w| w == key) { - out.push_str(&format!("{key}:{value}\n")); - } - } - - out -} - -#[tracing::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("profiles").join(folder_name); - if !src.is_dir() { - tracing::warn!( - folder_name, - "lunar profile 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?; - merge_lunar_options(&src, &dest).await?; - - if let Some(mods) = active_profile_mods(&root).await { - super::lunar_mods::apply(&mods, &dest).await?; - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mod_ids_normalize_across_spellings() { - assert_eq!( - normalize_mod_id("MOTION_BLUR"), - normalize_mod_id("motionBlur") - ); - assert_eq!( - normalize_mod_id("TOGGLE_SNEAK"), - normalize_mod_id("toggleSneak") - ); - assert_eq!(normalize_mod_id("HIT_COLOR"), normalize_mod_id("hitColor")); - } - - #[test] - fn known_mods_map_to_bundle_categories() { - assert_eq!(category_for_mod(&normalize_mod_id("FPS")), Some("HUD")); - assert_eq!(category_for_mod(&normalize_mod_id("HITBOX")), Some("PvP")); - assert_eq!( - category_for_mod(&normalize_mod_id("motionBlur")), - Some("QoL") - ); - assert_eq!(category_for_mod(&normalize_mod_id("ZOOM")), Some("Utility")); - assert_eq!( - category_for_mod(&normalize_mod_id("SKYBLOCK")), - Some("SkyBlock") - ); - assert_eq!(category_for_mod(&normalize_mod_id("TOTALLY_UNKNOWN")), None); - } - - #[test] - fn ichor_only_profile_falls_back_to_vanilla() { - assert_eq!(loader_from_json(r#"["ichor"]"#), GameLoader::Vanilla); - assert_eq!( - loader_from_json(r#"["ichor","fabric"]"#), - GameLoader::Fabric - ); - assert_eq!(loader_from_json("not json"), GameLoader::Vanilla); - } - - #[test] - fn lunar_options_overwrite_matching_keys_and_keep_the_rest() { - let existing = "fov:0.0\nrenderDistance:12\nkeep:me\n"; - let mut lunar = serde_json::Map::new(); - lunar.insert("fov".into(), serde_json::json!("0.5")); - lunar.insert("maxFps".into(), serde_json::json!("170")); - lunar.insert("lastLaunchedVersion".into(), serde_json::json!("v1_8")); - - let merged = merge_options_text(existing, &lunar); - - assert!(merged.contains("fov:0.5")); - assert!(!merged.contains("fov:0.0")); - assert!(merged.contains("renderDistance:12")); - assert!(merged.contains("keep:me")); - assert!(merged.contains("maxFps:170")); - // Lunar bookkeeping is not a vanilla option. - assert!(!merged.contains("lastLaunchedVersion")); - } - - #[test] - fn allocated_memory_and_resolution_land_on_the_global_profile() { - let mut global = GameSettingsProfile { - name: "Global".to_string(), - java_path: None, - resolution: None, - force_fullscreen: None, - mem_max: None, - launch_args: None, - launch_env: None, - hook_pre: None, - hook_wrapper: None, - hook_post: None, - os_extra: None, - }; - - apply_lunar_settings( - &mut global, - &LunarLauncherSettings { - allocated_memory: Some(4195), - game_resolution: Some(LunarResolution { - width: Some(1280), - height: Some(720), - fullscreen: Some(true), - }), - }, - ); - - assert_eq!(global.mem_max, Some(4195)); - assert_eq!(global.resolution, Some(Resolution::new(1280, 720))); - assert_eq!(global.force_fullscreen, Some(true)); - } -} diff --git a/packages/oneclient_core/src/migration/lunar_mods.rs b/packages/oneclient_core/src/migration/lunar_mods.rs deleted file mode 100644 index 3eee627e..00000000 --- a/packages/oneclient_core/src/migration/lunar_mods.rs +++ /dev/null @@ -1,254 +0,0 @@ -use std::path::Path; - -use serde_json::{Map, Value, json}; - -use crate::LauncherResult; - -#[derive(Debug, PartialEq)] -pub(super) struct ConfigPatch { - pub path: &'static str, - pub values: Map, -} - -/// Lunar writes numbers as either JSON numbers or strings ("6847" vs 6329) -/// depending on how the value was last touched, so both have to be accepted. -fn loose_i64(value: Option<&Value>) -> Option { - match value? { - Value::Number(n) => n.as_i64(), - Value::String(s) => s.trim().parse().ok().or_else(|| { - // "0.0"-style values still carry an integer meaning. - s.trim().parse::().ok().map(|f| f as i64) - }), - _ => None, - } -} - -fn loose_bool(value: Option<&Value>) -> Option { - match value? { - Value::Bool(b) => Some(*b), - Value::String(s) => match s.trim() { - "true" => Some(true), - "false" => Some(false), - _ => None, - }, - _ => None, - } -} - -struct LunarMod<'a>(&'a Map); - -impl<'a> LunarMod<'a> { - fn enabled(&self) -> bool { - self.0.get("enabled").and_then(Value::as_bool).unwrap_or(false) - } - - fn option(&self, key: &str) -> Option<&'a Value> { - self.0.get("options")?.as_object()?.get(key) - } -} - -fn lunar_mod<'a>(mods: &'a Map, id: &str) -> Option> { - Some(LunarMod(mods.get(id)?.as_object()?)) -} - -pub(super) fn translate(mods: &Map) -> Vec { - let mut patches = Vec::new(); - - if let Some(m) = lunar_mod(mods, "TIME_CHANGER") { - let mut values = Map::new(); - values.insert("isEnabled".into(), json!(m.enabled())); - if let Some(time) = loose_i64(m.option("timeChangerTime")) { - values.insert("time".into(), json!(time)); - } - patches.push(ConfigPatch { - path: "config/polytime.json", - values, - }); - } - - if let Some(m) = lunar_mod(mods, "WEATHER_CHANGER") { - patches.push(ConfigPatch { - path: "config/polyweather.json", - values: [("isEnabled".to_string(), json!(m.enabled()))] - .into_iter() - .collect(), - }); - } - - if let Some(m) = lunar_mod(mods, "HURT_CAM") { - let suppressed = m.enabled() && loose_bool(m.option("disableHurtCam")).unwrap_or(false); - if suppressed { - patches.push(ConfigPatch { - path: "config/shaketweaks.json", - values: [ - ("disableScreenDamage".to_string(), json!(true)), - ("disableHandDamage".to_string(), json!(true)), - ] - .into_iter() - .collect(), - }); - } - } - - if let Some(m) = lunar_mod(mods, "ZOOM") - && let Some(variable) = loose_bool(m.option("variableZoom")) - { - patches.push(ConfigPatch { - path: "config/zoomify.json", - values: [("scrollZoom".to_string(), json!(variable))] - .into_iter() - .collect(), - }); - } - - patches -} - -#[tracing::instrument(level = "debug", skip(values))] -async fn merge_into(path: &Path, values: &Map) -> LauncherResult<()> { - let mut existing: Map = match polyio::read(path).await { - Ok(raw) => serde_json::from_slice(&raw).unwrap_or_else(|err| { - tracing::warn!(path = %path.display(), error = %err, "unparsable mod config; rewriting"); - Map::new() - }), - Err(_) => Map::new(), - }; - - for (key, value) in values { - existing.insert(key.clone(), value.clone()); - } - - if let Some(parent) = path.parent() { - polyio::create_dir_all(parent).await?; - } - - let rendered = serde_json::to_vec_pretty(&Value::Object(existing))?; - polyio::write(path, rendered).await?; - - Ok(()) -} - -#[tracing::instrument(level = "debug", skip(mods))] -pub(super) async fn apply(mods: &Map, dest: &Path) -> LauncherResult<()> { - let patches = translate(mods); - - for patch in &patches { - merge_into(&dest.join(patch.path), &patch.values).await?; - } - - tracing::info!(configs = patches.len(), "lunar migration: wrote mod settings"); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn mods(raw: serde_json::Value) -> Map { - raw.as_object().expect("object").clone() - } - - fn patch<'a>(patches: &'a [ConfigPatch], path: &str) -> Option<&'a ConfigPatch> { - patches.iter().find(|p| p.path == path) - } - - #[test] - fn time_changer_carries_enabled_state_and_tick() { - // Lunar writes this tick as a string in some profiles. - let patches = translate(&mods(json!({ - "TIME_CHANGER": { "enabled": true, "options": { "timeChangerTime": "6847" } } - }))); - - let p = patch(&patches, "config/polytime.json").expect("polytime"); - assert_eq!(p.values["isEnabled"], json!(true)); - assert_eq!(p.values["time"], json!(6847)); - } - - #[test] - fn numeric_and_string_ticks_agree() { - let as_number = translate(&mods(json!({ - "TIME_CHANGER": { "options": { "timeChangerTime": 6329 } } - }))); - let as_string = translate(&mods(json!({ - "TIME_CHANGER": { "options": { "timeChangerTime": "6329" } } - }))); - - assert_eq!( - patch(&as_number, "config/polytime.json").unwrap().values["time"], - patch(&as_string, "config/polytime.json").unwrap().values["time"], - ); - } - - #[test] - fn a_mod_lunar_never_enabled_is_carried_over_as_disabled() { - // Lunar omits `enabled` rather than writing false. - let patches = translate(&mods(json!({ - "TIME_CHANGER": { "options": { "timeChangerTime": "0" } } - }))); - - assert_eq!( - patch(&patches, "config/polytime.json").unwrap().values["isEnabled"], - json!(false) - ); - } - - #[test] - fn hurt_cam_only_suppresses_shake_when_lunar_did() { - let suppressing = translate(&mods(json!({ - "HURT_CAM": { "enabled": true, "options": { "disableHurtCam": true } } - }))); - let p = patch(&suppressing, "config/shaketweaks.json").expect("shaketweaks"); - assert_eq!(p.values["disableScreenDamage"], json!(true)); - assert_eq!(p.values["disableHandDamage"], json!(true)); - - // Enabled but not actually disabling the cam -> leave shaketweaks alone. - let passive = translate(&mods(json!({ - "HURT_CAM": { "enabled": true, "options": { "disableHurtCam": false } } - }))); - assert!(patch(&passive, "config/shaketweaks.json").is_none()); - } - - #[test] - fn zoom_maps_variable_zoom_to_scroll_zoom() { - let patches = translate(&mods(json!({ - "ZOOM": { "options": { "variableZoom": true } } - }))); - - assert_eq!( - patch(&patches, "config/zoomify.json").unwrap().values["scrollZoom"], - json!(true) - ); - } - - #[test] - fn a_mod_with_no_equivalent_produces_nothing() { - let patches = translate(&mods(json!({ - "TIER_TAGGER": { "enabled": true }, - "SKYBLOCK": { "enabled": true }, - }))); - - assert!(patches.is_empty()); - } - - #[tokio::test] - async fn merging_keeps_keys_the_patch_says_nothing_about() { - let dir = std::env::temp_dir().join(format!("oneclient-lunar-mods-{}", uuid::Uuid::new_v4())); - let path = dir.join("config/zoomify.json"); - polyio::create_dir_all(path.parent().unwrap()).await.unwrap(); - polyio::write(&path, br#"{"initialZoom":4,"scrollZoom":false}"#.to_vec()) - .await - .unwrap(); - - let values = [("scrollZoom".to_string(), json!(true))] - .into_iter() - .collect(); - merge_into(&path, &values).await.unwrap(); - - let raw = polyio::read(&path).await.unwrap(); - let out: Map = serde_json::from_slice(&raw).unwrap(); - - assert_eq!(out["scrollZoom"], json!(true)); - // Untouched key survives. - assert_eq!(out["initialZoom"], json!(4)); - } -} diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs index 80caacd8..99561532 100644 --- a/packages/oneclient_core/src/migration/mod.rs +++ b/packages/oneclient_core/src/migration/mod.rs @@ -1,6 +1,4 @@ mod fs; -pub mod lunar_client; -mod lunar_mods; pub mod oneclient_v1; use std::path::PathBuf; @@ -15,24 +13,20 @@ pub use fs::copy_tree; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum MigrationSource { OneClientV1, - LunarClient, } impl MigrationSource { - pub const ALL: &'static [MigrationSource] = - &[MigrationSource::OneClientV1, MigrationSource::LunarClient]; + pub const ALL: &'static [MigrationSource] = &[MigrationSource::OneClientV1]; pub fn id(self) -> &'static str { match self { MigrationSource::OneClientV1 => "oneclient_v1", - MigrationSource::LunarClient => "lunar_client", } } pub fn display_name(self) -> &'static str { match self { MigrationSource::OneClientV1 => "OneClient", - MigrationSource::LunarClient => "Lunar Client", } } @@ -71,24 +65,20 @@ pub enum ImportTarget { Dedicated { new_cluster_id: i64 }, } -#[tracing::instrument(level = "debug")] -pub async fn detect_all() -> LauncherResult> { - let mut detections = Vec::new(); - +#[tracing::instrument] +pub async fn detect() -> LauncherResult> { for source in MigrationSource::ALL.iter().copied() { let detection = match source { MigrationSource::OneClientV1 => oneclient_v1::detect().await?, - MigrationSource::LunarClient => lunar_client::detect().await?, }; if let Some(detection) = detection && !detection.instances.is_empty() { - detections.push(detection); + return Ok(Some(detection)); } } - - Ok(detections) + Ok(None) } #[tracing::instrument] @@ -99,16 +89,5 @@ pub async fn import_game_dir( ) -> LauncherResult<()> { match source { MigrationSource::OneClientV1 => oneclient_v1::import_game_dir(folder_name, target).await, - MigrationSource::LunarClient => lunar_client::import_game_dir(folder_name, target).await, - } -} - -/// Copies launcher-wide settings (memory, resolution) -#[tracing::instrument] -pub async fn import_settings(source: MigrationSource) -> LauncherResult<()> { - match source { - // v1 shares this launcher's settings file already. - MigrationSource::OneClientV1 => Ok(()), - MigrationSource::LunarClient => lunar_client::import_settings().await, } }