Reapply "feat: Added migration from Lunar Client"
This reverts commit 1243dbcd75.
This commit is contained in:
parent
1243dbcd75
commit
7f8b62eb15
19 changed files with 1313 additions and 170 deletions
|
|
@ -17,7 +17,9 @@ pub enum BridgeCommand {
|
|||
RecordSeenVersion {
|
||||
version: String,
|
||||
},
|
||||
MarkOnboardingSeen,
|
||||
SetOnboardingSeen {
|
||||
seen: bool,
|
||||
},
|
||||
AcceptTos {
|
||||
terms_version: u32,
|
||||
privacy_version: u32,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<Option<ClusterId>>);
|
||||
|
||||
|
|
@ -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<bool>,
|
||||
/// 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<Option<Vec<String>>>,
|
||||
pub language: State<String>,
|
||||
pub reduce_motion: State<bool>,
|
||||
pub predownload: State<bool>,
|
||||
pub setup_started: State<bool>,
|
||||
/// v1-migration: source folder to import files from (`None` = don't import).
|
||||
pub import_folder: State<Option<String>>,
|
||||
/// v1-migration: import into the matching cluster's own dir instead of shared.
|
||||
pub import_selection: State<Option<ImportSelection>>,
|
||||
pub import_dedicated: State<bool>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<MigrationDetection>;
|
||||
type Ok = Vec<MigrationDetection>;
|
||||
type Err = LauncherError;
|
||||
type Keys = MigrationKeys;
|
||||
|
||||
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
detect_migration().await
|
||||
detect_migrations().await
|
||||
}
|
||||
}
|
||||
|
||||
pub fn use_migration() -> UseQuery<MigrationQuery> {
|
||||
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<MigrationQuery>) -> Option<MigrationDetection> {
|
||||
pub fn migration_detections(query: &UseQuery<MigrationQuery>) -> Vec<MigrationDetection> {
|
||||
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<MigrationQuery>) -> bool {
|
||||
migration_detection(query).is_some_and(|d| !d.instances.is_empty())
|
||||
migration_detections(query)
|
||||
.iter()
|
||||
.any(|d| !d.instances.is_empty())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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::<String>);
|
||||
let import_selection = use_state(|| None::<ImportSelection>);
|
||||
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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<bool>) -> 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 {});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
fn migrated_categories(detections: &[MigrationDetection]) -> Vec<String> {
|
||||
let mut categories: Vec<String> = 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<String> {
|
|||
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<Element> = Vec::new();
|
||||
for instance in &detection.instances {
|
||||
let selected_import = chosen_folder.as_deref() == Some(instance.folder_name.as_str());
|
||||
let dedicated_available = matching_new_cluster_id(instance, &new_clusters).is_some();
|
||||
let mut source_groups: Vec<Element> = Vec::new();
|
||||
for detection in &detections {
|
||||
let source = detection.source;
|
||||
let mut version_cards: Vec<Element> = 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>) -> 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::<Vec<_>>(),
|
||||
)
|
||||
.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<SourceInstance>) -> MigrationDetection {
|
||||
MigrationDetection {
|
||||
source,
|
||||
root: PathBuf::from("/tmp"),
|
||||
instances,
|
||||
}
|
||||
}
|
||||
|
||||
fn both() -> Vec<MigrationDetection> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
29
packages/oneclient_core/examples/detect_lunar.rs
Normal file
29
packages/oneclient_core/examples/detect_lunar.rs
Normal file
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
26
packages/oneclient_core/examples/detect_migrations.rs
Normal file
26
packages/oneclient_core/examples/detect_migrations.rs
Normal file
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
559
packages/oneclient_core/src/migration/lunar_client.rs
Normal file
559
packages/oneclient_core/src/migration/lunar_client.rs
Normal file
|
|
@ -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<PathBuf> {
|
||||
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<String> {
|
||||
let path = root
|
||||
.join("settings")
|
||||
.join("game")
|
||||
.join("profile_manager.json");
|
||||
let raw = polyio::read(&path).await.ok()?;
|
||||
let profiles: Vec<LunarSettingsProfile> = 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<serde_json::Map<String, serde_json::Value>> {
|
||||
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<String> {
|
||||
let settings_dir = root.join("settings").join("game");
|
||||
let mut categories: Vec<String> = 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<String, serde_json::Value> = 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::<LunarModEntry>(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<String> = 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<Option<MigrationDetection>> {
|
||||
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<MigrationDetection> {
|
||||
let opts = SqliteConnectOptions::new()
|
||||
.filename(db_path)
|
||||
.read_only(true)
|
||||
.immutable(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(opts)
|
||||
.await?;
|
||||
|
||||
let 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<u32>,
|
||||
game_resolution: Option<LunarResolution>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LunarResolution {
|
||||
width: Option<u32>,
|
||||
height: Option<u32>,
|
||||
#[serde(default)]
|
||||
fullscreen: Option<bool>,
|
||||
}
|
||||
|
||||
#[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<String, serde_json::Value> = 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, serde_json::Value>,
|
||||
) -> 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));
|
||||
}
|
||||
}
|
||||
254
packages/oneclient_core/src/migration/lunar_mods.rs
Normal file
254
packages/oneclient_core/src/migration/lunar_mods.rs
Normal file
|
|
@ -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<String, Value>,
|
||||
}
|
||||
|
||||
/// 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<i64> {
|
||||
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::<f64>().ok().map(|f| f as i64)
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn loose_bool(value: Option<&Value>) -> Option<bool> {
|
||||
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<String, Value>);
|
||||
|
||||
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<String, Value>, id: &str) -> Option<LunarMod<'a>> {
|
||||
Some(LunarMod(mods.get(id)?.as_object()?))
|
||||
}
|
||||
|
||||
pub(super) fn translate(mods: &Map<String, Value>) -> Vec<ConfigPatch> {
|
||||
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<String, Value>) -> LauncherResult<()> {
|
||||
let mut existing: Map<String, Value> = 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<String, Value>, 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<String, Value> {
|
||||
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<String, Value> = serde_json::from_slice(&raw).unwrap();
|
||||
|
||||
assert_eq!(out["scrollZoom"], json!(true));
|
||||
// Untouched key survives.
|
||||
assert_eq!(out["initialZoom"], json!(4));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Option<MigrationDetection>> {
|
||||
#[tracing::instrument(level = "debug")]
|
||||
pub async fn detect_all() -> LauncherResult<Vec<MigrationDetection>> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue