From 7f8b62eb15d1bdd7dc1bd1ff668aa1b7c0518f55 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Thu, 16 Jul 2026 13:40:37 -0400 Subject: [PATCH] Reapply "feat: Added migration from Lunar Client" This reverts commit 1243dbcd7560efe92c8cb09e106c699d153349ce. --- 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, 1313 insertions(+), 170 deletions(-) create mode 100644 packages/oneclient_core/examples/detect_lunar.rs create mode 100644 packages/oneclient_core/examples/detect_migrations.rs create mode 100644 packages/oneclient_core/src/migration/lunar_client.rs create 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 63637f45..1be97d5a 100644 --- a/packages/oneclient_app/src/bridge/commands.rs +++ b/packages/oneclient_app/src/bridge/commands.rs @@ -17,7 +17,9 @@ pub enum BridgeCommand { RecordSeenVersion { version: String, }, - MarkOnboardingSeen, + SetOnboardingSeen { + seen: bool, + }, 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 85329ab9..d15afc11 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -315,9 +315,9 @@ impl CoreBridgeRuntime { } })?; } - BridgeCommand::MarkOnboardingSeen => { + BridgeCommand::SetOnboardingSeen { seen } => { mutate_settings(snapshots, |settings| { - settings.seen_onboarding = true; + settings.seen_onboarding = seen; })?; let state = LauncherState::get()?; let snapshot = state.settings.read().clone(); @@ -436,6 +436,11 @@ 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 3694c0d8..11f6f767 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -1,9 +1,16 @@ 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>); @@ -74,16 +81,12 @@ 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, - /// 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_selection: State>, pub import_dedicated: State, } diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs index 0b042bf0..62bee53d 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 mark_onboarding_seen(&self) { - self.send(BridgeCommand::MarkOnboardingSeen); + pub fn set_onboarding_seen(&self, seen: bool) { + self.send(BridgeCommand::SetOnboardingSeen { seen }); } 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 701bbfc1..9bc877bf 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, 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, 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, }; 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_detection, + java_runtimes, loader_versions, login_code_already_handled, migration_detections, 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 2ee691de..0b64d42b 100644 --- a/packages/oneclient_app/src/hooks/queries/migration.rs +++ b/packages/oneclient_app/src/hooks/queries/migration.rs @@ -1,5 +1,7 @@ +use std::time::Duration; + use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; -use oneclient_core::{LauncherError, MigrationDetection, detect_migration}; +use oneclient_core::{LauncherError, MigrationDetection, detect_migrations}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct MigrationQuery; @@ -8,28 +10,34 @@ pub struct MigrationQuery; pub struct MigrationKeys; impl QueryCapability for MigrationQuery { - type Ok = Option; + type Ok = Vec; type Err = LauncherError; type Keys = MigrationKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - detect_migration().await + detect_migrations().await } } pub fn use_migration() -> UseQuery { - use_query(Query::new(MigrationKeys, MigrationQuery)) + use_query( + Query::new(MigrationKeys, MigrationQuery) + .stale_time(Duration::from_secs(60 * 60)) + .clean_time(Duration::from_secs(6 * 60 * 60)), + ) } -pub fn migration_detection(query: &UseQuery) -> Option { +pub fn migration_detections(query: &UseQuery) -> Vec { let reader = query.read(); match &*reader.state() { - QueryStateData::Settled { res: Ok(opt), .. } => opt.clone(), - QueryStateData::Loading { res: Some(Ok(opt)) } => opt.clone(), - _ => None, + QueryStateData::Settled { res: Ok(found), .. } => found.clone(), + QueryStateData::Loading { res: Some(Ok(found)) } => found.clone(), + _ => Vec::new(), } } pub fn has_migration_data(query: &UseQuery) -> bool { - migration_detection(query).is_some_and(|d| !d.instances.is_empty()) + migration_detections(query) + .iter() + .any(|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 68947dd1..f70c14e2 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_detection, use_migration}; +pub use migration::{MigrationQuery, has_migration_data, migration_detections, 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 0f6c489a..77607193 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::{ - OnboardingSelectionState, has_migration_data, onboarding_bundles_items, use_migration, - use_onboarding_bundles, use_provide_onboarding_selection, + ImportSelection, 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_folder = use_state(|| None::); + let import_selection = 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_folder, + import_selection, import_dedicated, }); diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 13e6b66b..172dacd0 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::use_dispatch; +use crate::hooks::{BridgeDispatch, use_dispatch}; use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind}; use crate::routes::Route; use crate::theme::colors; @@ -12,6 +12,7 @@ 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); @@ -65,7 +66,7 @@ impl Component for Debug { .child(divider()) .child(section( "Other", - vec![action_row(vec![ + vec![action_row(dispatch, vec![ ("Open Dev Tools", IconType::CodeSnippet02), ("Open Onboarding", IconType::Rocket02), ("Open Launcher Data", IconType::Folder), @@ -336,7 +337,7 @@ fn toggle_row(title: &'static str, on: State) -> Element { .into_element() } -fn action_row(buttons: Vec<(&'static str, IconType)>) -> Element { +fn action_row(dispatch: BridgeDispatch, buttons: Vec<(&'static str, IconType)>) -> Element { let mut row = rect().horizontal().width(Size::fill()).spacing(12.); for (text, icon) in buttons { @@ -346,7 +347,9 @@ fn action_row(buttons: Vec<(&'static str, IconType)>) -> Element { .text(text); if text == "Open Onboarding" { - button = button.on_press(|_| { + let dispatch = dispatch.clone(); + button = button.on_press(move |_| { + dispatch.set_onboarding_seen(false); 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 ef0126e1..12c52dbd 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_detection, + BridgeDispatch, ClusterBundles, invalidate_cluster_queries, migration_detections, 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::{matching_new_cluster_id, pkg_key}; +use crate::view::onboarding::{find_instance, matching_new_cluster_id, pkg_key, sources_sentence}; mod backdrop; mod progress; @@ -319,20 +319,17 @@ impl Component for OnboardingDownloading { .unwrap_or_else(|| "Not signed in".to_string()); let import_dispatch = dispatch.clone(); - let import_folder = selection.import_folder; + let import_selection = selection.import_selection; let import_dedicated = selection.import_dedicated; - let import_detection = migration_detection(&migration_query); + let import_detections = migration_detections(&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 migration_summary = + (!import_detections.is_empty()).then(|| match import_selection.read().clone() { + Some(chosen) => { + let source_name = chosen.source.display_name().to_string(); let dedicated = *import_dedicated.read() - && detection - .instances - .iter() - .find(|c| c.folder_name == folder) + && find_instance(&import_detections, &chosen) .and_then(|inst| matching_new_cluster_id(inst, &items)) .is_some(); let target = if dedicated { @@ -340,11 +337,14 @@ impl Component for OnboardingDownloading { } else { "Shared game directory" }; - (source_name, folder, target.to_string()) + (source_name, chosen.folder_name, target.to_string()) } - None => (source_name, "No files imported".to_string(), String::new()), - } - }); + None => ( + sources_sentence(&import_detections), + "No files imported".to_string(), + String::new(), + ), + }); return summary_view( &items, @@ -357,21 +357,16 @@ impl Component for OnboardingDownloading { migration_summary, predownload_state, move |_| { - if let (Some(detection), Some(folder)) = - (import_detection.as_ref(), import_folder.peek().clone()) - { + if let Some(chosen) = import_selection.peek().clone() { let target = if *import_dedicated.peek() { - detection - .instances - .iter() - .find(|c| c.folder_name == folder) + find_instance(&import_detections, &chosen) .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); + import_dispatch.import_launcher(chosen.source, chosen.folder_name, target); } setup_started.set(true); @@ -867,7 +862,7 @@ fn finish_onboarding( dispatch.record_seen_version(version); } - dispatch.mark_onboarding_seen(); + dispatch.set_onboarding_seen(true); 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 7df6d269..917f7b2e 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::SourceInstance; +use oneclient_core::{MigrationDetection, SourceInstance}; use crate::components::{Button, Icon, IconType, ScrollArea}; use crate::hooks::{ - ClusterBundles, migration_detection, onboarding_bundles_items, use_migration, + ClusterBundles, ImportSelection, migration_detections, 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(source: &[SourceInstance]) -> Vec { +fn migrated_categories(detections: &[MigrationDetection]) -> Vec { let mut categories: Vec = Vec::new(); - for instance in source { + for instance in detections.iter().flat_map(|d| &d.instances) { for category in &instance.categories { if !categories.iter().any(|c| c.eq_ignore_ascii_case(category)) { categories.push(category.clone()); @@ -26,8 +26,28 @@ fn migrated_categories(source: &[SourceInstance]) -> Vec { categories } -/// 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 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(", ")), + } +} + pub(crate) fn matching_new_cluster_id( instance: &SourceInstance, new: &[ClusterBundles], @@ -49,42 +69,60 @@ impl Component for OnboardingMigration { let bundles_query = use_onboarding_bundles(); let selection = use_onboarding_selection(); let mut categories = selection.migrated_categories; - // Shared across the flow; the file import is dispatched later, on Setup. - let mut import_folder = selection.import_folder; + let mut import_selection = selection.import_selection; 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 { + let detections = migration_detections(&migration_query); + if detections.is_empty() { 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); + let chosen = import_selection.read().clone(); + let any_importable = detections + .iter() + .flat_map(|d| &d.instances) + .any(|c| c.has_game_dir); - // One card per detected version. Importable cards double as the file- - // import selector; non-importable ones stay informational. - let mut version_cards: Vec = Vec::new(); - for instance in &detection.instances { - let selected_import = chosen_folder.as_deref() == Some(instance.folder_name.as_str()); - let dedicated_available = matching_new_cluster_id(instance, &new_clusters).is_some(); + let mut source_groups: Vec = Vec::new(); + for detection in &detections { + let source = detection.source; + let mut version_cards: Vec = Vec::new(); - 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())) - }, + 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, )); } @@ -95,40 +133,27 @@ impl Component for OnboardingMigration { .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)." + "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) ), )) - // "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), + chosen.is_none(), + move |_| import_selection.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), - ), + .spacing(16.) + .children(source_groups), ) .into_element(); @@ -167,7 +192,7 @@ impl Component for OnboardingMigration { ), )) .child(migration_nav(bundles_loaded, move || { - categories.set(Some(migrated_categories(&detection.instances))); + categories.set(Some(migrated_categories(&detections))); let _ = RouterContext::get().replace(Route::OnboardingLanguage {}); })) @@ -175,11 +200,86 @@ impl Component for OnboardingMigration { } } -/// 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( +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( instance: &SourceInstance, selected: bool, dedicated: bool, @@ -190,18 +290,38 @@ 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() @@ -209,6 +329,7 @@ fn version_card( .content(Content::Flex) .cross_align(Alignment::Center) .spacing(12.) + .child(icon_tile) .child( rect() .vertical() @@ -221,39 +342,61 @@ fn version_card( .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() - })), + .child( + label() + .text(format!("{} · {}", instance.mc_version, instance.mc_loader)) + .font_size(11.) + .color(subtitle_color), + ), ) - // 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() - } - })); + .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() + }; let mut card = rect() .vertical() .width(Size::fill()) - .spacing(12.) + .spacing(10.) .padding(Gaps::new_symmetric(12., 14.)) .corner_radius(CornerRadius::new_all(10.)) .background(bg) .border(border_all_color(1., border_color)) - .child(header); + .child(header) + .child(categories); if importable { card = card @@ -388,3 +531,96 @@ 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 5f5a0458..e774cc72 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::matching_new_cluster_id; +pub(crate) use migration::{find_instance, matching_new_cluster_id, sources_sentence}; 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 new file mode 100644 index 00000000..a90cc28a --- /dev/null +++ b/packages/oneclient_core/examples/detect_lunar.rs @@ -0,0 +1,29 @@ +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 new file mode 100644 index 00000000..702ca1fa --- /dev/null +++ b/packages/oneclient_core/examples/detect_migrations.rs @@ -0,0 +1,26 @@ +#[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 c6eee761..0f0ad79e 100644 --- a/packages/oneclient_core/examples/detect_v1.rs +++ b/packages/oneclient_core/examples/detect_v1.rs @@ -1,6 +1,8 @@ +use oneclient_core::migration::oneclient_v1; + #[tokio::main] async fn main() { - match oneclient_core::detect_migration().await { + match oneclient_v1::detect().await { Ok(Some(detection)) => { println!( "source: {} ({})", @@ -21,7 +23,7 @@ async fn main() { ); } } - Ok(None) => println!("no migratable launcher detected"), + Ok(None) => println!("no v1 install detected"), Err(err) => eprintln!("detection error: {err}"), } } diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 1f16a66e..627608b6 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -31,29 +31,27 @@ 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::{ - apply_bundle_updates, check_bundle_updates, effective_enabled, install_bundle, - install_cluster_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, 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::{fetch_changelog, parse_changelog, ChangelogGroup}; -pub use tos::{fetch_terms, TermsDocument}; -pub use discord::{DiscordRpc, Presence}; +pub use changelog::{ChangelogGroup, fetch_changelog, parse_changelog}; 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; @@ -63,17 +61,19 @@ pub use logs::{ }; 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, + ImportTarget, MigrationDetection, MigrationSource, SourceInstance, + detect_all as detect_migrations, import_game_dir as import_migration_game_dir, + import_settings as import_migration_settings, }; 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 new file mode 100644 index 00000000..b42fde4f --- /dev/null +++ b/packages/oneclient_core/src/migration/lunar_client.rs @@ -0,0 +1,559 @@ +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 new file mode 100644 index 00000000..3eee627e --- /dev/null +++ b/packages/oneclient_core/src/migration/lunar_mods.rs @@ -0,0 +1,254 @@ +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 99561532..80caacd8 100644 --- a/packages/oneclient_core/src/migration/mod.rs +++ b/packages/oneclient_core/src/migration/mod.rs @@ -1,4 +1,6 @@ mod fs; +pub mod lunar_client; +mod lunar_mods; pub mod oneclient_v1; use std::path::PathBuf; @@ -13,20 +15,24 @@ 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]; + pub const ALL: &'static [MigrationSource] = + &[MigrationSource::OneClientV1, MigrationSource::LunarClient]; 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", } } @@ -65,20 +71,24 @@ pub enum ImportTarget { Dedicated { new_cluster_id: i64 }, } -#[tracing::instrument] -pub async fn detect() -> LauncherResult> { +#[tracing::instrument(level = "debug")] +pub async fn detect_all() -> LauncherResult> { + let mut detections = Vec::new(); + 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() { - return Ok(Some(detection)); + detections.push(detection); } } - Ok(None) + + Ok(detections) } #[tracing::instrument] @@ -89,5 +99,16 @@ 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, } }