revert lunar

This commit is contained in:
Julian Chang 2026-07-16 15:33:28 -04:00
parent a4753fb06b
commit 7696d80fe8
No known key found for this signature in database
19 changed files with 170 additions and 1313 deletions

View file

@ -20,9 +20,7 @@ pub enum BridgeCommand {
SetSeenChangelogVersion {
version: String,
},
SetOnboardingSeen {
seen: bool,
},
MarkOnboardingSeen,
AcceptTos {
terms_version: u32,
privacy_version: u32,

View file

@ -332,9 +332,9 @@ impl CoreBridgeRuntime {
}
}
}
BridgeCommand::SetOnboardingSeen { seen } => {
BridgeCommand::MarkOnboardingSeen => {
mutate_settings(snapshots, |settings| {
settings.seen_onboarding = seen;
settings.seen_onboarding = true;
})?;
let state = LauncherState::get()?;
let snapshot = state.settings.read().clone();
@ -453,11 +453,6 @@ impl CoreBridgeRuntime {
let state = LauncherState::get()?;
tokio::spawn(async move {
let notifier = state.services.notifier.clone();
if let Err(err) = oneclient_core::import_migration_settings(source).await {
tracing::warn!(error = %err, "failed to import launcher settings");
}
match oneclient_core::import_migration_game_dir(source, &folder_name, target)
.await
{

View file

@ -1,16 +1,9 @@
use std::collections::{HashMap, HashSet};
use freya::prelude::*;
use oneclient_core::MigrationSource;
use oneclient_core::packages::ProviderId;
use oneclient_db::models::ClusterId;
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ImportSelection {
pub source: MigrationSource,
pub folder_name: String,
}
#[derive(Clone)]
pub struct ActiveClusterState(pub State<Option<ClusterId>>);
@ -81,12 +74,16 @@ pub struct OnboardingSelectionState {
/// keeps re-deriving `selected` from the catalog, so a bundle list that
/// settles late still gets the right defaults.
pub user_touched: State<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>,
pub import_selection: State<Option<ImportSelection>>,
/// v1-migration: source folder to import files from (`None` = don't import).
pub import_folder: State<Option<String>>,
/// v1-migration: import into the matching cluster's own dir instead of shared.
pub import_dedicated: State<bool>,
}

View file

@ -44,8 +44,8 @@ impl BridgeDispatch {
});
}
pub fn set_onboarding_seen(&self, seen: bool) {
self.send(BridgeCommand::SetOnboardingSeen { seen });
pub fn mark_onboarding_seen(&self) {
self.send(BridgeCommand::MarkOnboardingSeen);
}
pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) {

View file

@ -6,11 +6,11 @@ mod view_state;
pub use view_state::{PersistedView, use_view_state};
pub use active_cluster::{
ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, ImportSelection,
LinkConfirmState, OnboardingSelectionState, use_active_cluster_id, use_browser_compat,
use_browser_state_store, use_link_confirm, use_onboarding_selection,
use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state,
use_provide_link_confirm, use_provide_onboarding_selection,
ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState,
OnboardingSelectionState, use_active_cluster_id, use_browser_compat, use_browser_state_store,
use_link_confirm, use_onboarding_selection, use_provide_active_cluster,
use_provide_browser_compat, use_provide_browser_state, use_provide_link_confirm,
use_provide_onboarding_selection,
};
pub use dispatch::BridgeDispatch;
@ -26,7 +26,7 @@ pub use queries::{
content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries,
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
java_runtimes, latest_changelog_version, loader_versions, login_code_already_handled,
migration_detections,
migration_detection,
mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch,
pick_version_metadata, project_detail, provider_versions, reset_login_code_dedup, search_items,
search_pending, search_total, terms_document, terms_error, terms_is_loading, try_account,

View file

@ -1,7 +1,5 @@
use std::time::Duration;
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
use oneclient_core::{LauncherError, MigrationDetection, detect_migrations};
use oneclient_core::{LauncherError, MigrationDetection, detect_migration};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct MigrationQuery;
@ -10,34 +8,28 @@ pub struct MigrationQuery;
pub struct MigrationKeys;
impl QueryCapability for MigrationQuery {
type Ok = Vec<MigrationDetection>;
type Ok = Option<MigrationDetection>;
type Err = LauncherError;
type Keys = MigrationKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
detect_migrations().await
detect_migration().await
}
}
pub fn use_migration() -> UseQuery<MigrationQuery> {
use_query(
Query::new(MigrationKeys, MigrationQuery)
.stale_time(Duration::from_secs(60 * 60))
.clean_time(Duration::from_secs(6 * 60 * 60)),
)
use_query(Query::new(MigrationKeys, MigrationQuery))
}
pub fn migration_detections(query: &UseQuery<MigrationQuery>) -> Vec<MigrationDetection> {
pub fn migration_detection(query: &UseQuery<MigrationQuery>) -> Option<MigrationDetection> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(found), .. } => found.clone(),
QueryStateData::Loading { res: Some(Ok(found)) } => found.clone(),
_ => Vec::new(),
QueryStateData::Settled { res: Ok(opt), .. } => opt.clone(),
QueryStateData::Loading { res: Some(Ok(opt)) } => opt.clone(),
_ => None,
}
}
pub fn has_migration_data(query: &UseQuery<MigrationQuery>) -> bool {
migration_detections(query)
.iter()
.any(|d| !d.instances.is_empty())
migration_detection(query).is_some_and(|d| !d.instances.is_empty())
}

View file

@ -52,7 +52,7 @@ pub use logs::{
UseUploadLog, invalidate_logs_queries, try_cluster_logs, try_log_content, use_cluster_logs,
use_log_action, use_log_content, use_upload_log,
};
pub use migration::{MigrationQuery, has_migration_data, migration_detections, use_migration};
pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration};
pub use mutations::{
ClusterAction, invalidate_cluster_queries, invalidate_profile_queries, use_cluster_mutation,
};

View file

@ -6,8 +6,8 @@ use freya::router::{Outlet, use_route};
use crate::Route;
use crate::components::OnboardingNavbar;
use crate::hooks::{
ImportSelection, OnboardingSelectionState, has_migration_data, onboarding_bundles_items,
use_migration, use_onboarding_bundles, use_provide_onboarding_selection,
OnboardingSelectionState, has_migration_data, onboarding_bundles_items, use_migration,
use_onboarding_bundles, use_provide_onboarding_selection,
};
use crate::theme::colors;
use crate::view::onboarding::{
@ -26,7 +26,7 @@ impl Component for OnboardingShell {
let reduce_motion = use_state(|| false);
let predownload = use_state(|| true);
let setup_started = use_state(|| false);
let import_selection = use_state(|| None::<ImportSelection>);
let import_folder = use_state(|| None::<String>);
let import_dedicated = use_state(|| false);
use_provide_onboarding_selection(OnboardingSelectionState {
selected,
@ -36,7 +36,7 @@ impl Component for OnboardingShell {
reduce_motion,
predownload,
setup_started,
import_selection,
import_folder,
import_dedicated,
});

View file

@ -2,7 +2,7 @@ use freya::prelude::*;
use freya::router::RouterContext;
use crate::components::{Button, Icon, IconType, TextInput, toggle};
use crate::hooks::{BridgeDispatch, use_dispatch};
use crate::hooks::use_dispatch;
use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind};
use crate::routes::Route;
use crate::theme::colors;
@ -12,7 +12,6 @@ pub struct Debug;
impl Component for Debug {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let log_debug_info = use_state(|| false);
let show_dev_stuff = use_state(|| false);
let seen_onboarding = use_state(|| true);
@ -66,7 +65,7 @@ impl Component for Debug {
.child(divider())
.child(section(
"Other",
vec![action_row(dispatch, vec![
vec![action_row(vec![
("Open Dev Tools", IconType::CodeSnippet02),
("Open Onboarding", IconType::Rocket02),
("Open Launcher Data", IconType::Folder),
@ -337,7 +336,7 @@ fn toggle_row(title: &'static str, on: State<bool>) -> Element {
.into_element()
}
fn action_row(dispatch: BridgeDispatch, buttons: Vec<(&'static str, IconType)>) -> Element {
fn action_row(buttons: Vec<(&'static str, IconType)>) -> Element {
let mut row = rect().horizontal().width(Size::fill()).spacing(12.);
for (text, icon) in buttons {
@ -347,9 +346,7 @@ fn action_row(dispatch: BridgeDispatch, buttons: Vec<(&'static str, IconType)>)
.text(text);
if text == "Open Onboarding" {
let dispatch = dispatch.clone();
button = button.on_press(move |_| {
dispatch.set_onboarding_seen(false);
button = button.on_press(|_| {
let _ = RouterContext::get().replace(Route::OnboardingWelcome {});
});
}

View file

@ -13,14 +13,14 @@ use tokio::sync::mpsc;
use uuid::Uuid;
use crate::hooks::{
BridgeDispatch, ClusterBundles, invalidate_cluster_queries, migration_detections,
BridgeDispatch, ClusterBundles, invalidate_cluster_queries, migration_detection,
onboarding_bundles_items, try_default_account, use_current_account, use_dispatch,
use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot,
use_versions, versions_metadata,
};
use crate::routes::Route;
use crate::theme::colors;
use crate::view::onboarding::{find_instance, matching_new_cluster_id, pkg_key, sources_sentence};
use crate::view::onboarding::{matching_new_cluster_id, pkg_key};
mod backdrop;
mod progress;
@ -319,17 +319,20 @@ impl Component for OnboardingDownloading {
.unwrap_or_else(|| "Not signed in".to_string());
let import_dispatch = dispatch.clone();
let import_selection = selection.import_selection;
let import_folder = selection.import_folder;
let import_dedicated = selection.import_dedicated;
let import_detections = migration_detections(&migration_query);
let import_detection = migration_detection(&migration_query);
let import_items = items.clone();
let migration_summary =
(!import_detections.is_empty()).then(|| match import_selection.read().clone() {
Some(chosen) => {
let source_name = chosen.source.display_name().to_string();
let migration_summary = import_detection.as_ref().map(|detection| {
let source_name = detection.source.display_name().to_string();
match import_folder.read().clone() {
Some(folder) => {
let dedicated = *import_dedicated.read()
&& find_instance(&import_detections, &chosen)
&& detection
.instances
.iter()
.find(|c| c.folder_name == folder)
.and_then(|inst| matching_new_cluster_id(inst, &items))
.is_some();
let target = if dedicated {
@ -337,14 +340,11 @@ impl Component for OnboardingDownloading {
} else {
"Shared game directory"
};
(source_name, chosen.folder_name, target.to_string())
(source_name, folder, target.to_string())
}
None => (
sources_sentence(&import_detections),
"No files imported".to_string(),
String::new(),
),
});
None => (source_name, "No files imported".to_string(), String::new()),
}
});
return summary_view(
&items,
@ -357,16 +357,21 @@ impl Component for OnboardingDownloading {
migration_summary,
predownload_state,
move |_| {
if let Some(chosen) = import_selection.peek().clone() {
if let (Some(detection), Some(folder)) =
(import_detection.as_ref(), import_folder.peek().clone())
{
let target = if *import_dedicated.peek() {
find_instance(&import_detections, &chosen)
detection
.instances
.iter()
.find(|c| c.folder_name == folder)
.and_then(|inst| matching_new_cluster_id(inst, &import_items))
.map(|new_cluster_id| ImportTarget::Dedicated { new_cluster_id })
.unwrap_or(ImportTarget::Shared)
} else {
ImportTarget::Shared
};
import_dispatch.import_launcher(chosen.source, chosen.folder_name, target);
import_dispatch.import_launcher(detection.source, folder, target);
}
setup_started.set(true);
@ -862,7 +867,7 @@ fn finish_onboarding(
dispatch.record_seen_version(version);
}
dispatch.set_onboarding_seen(true);
dispatch.mark_onboarding_seen();
let _ = RouterContext::get().replace(Route::Home {});
});
}

View file

@ -1,10 +1,10 @@
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_core::{MigrationDetection, SourceInstance};
use oneclient_core::SourceInstance;
use crate::components::{Button, Icon, IconType, ScrollArea};
use crate::hooks::{
ClusterBundles, ImportSelection, migration_detections, onboarding_bundles_items, use_migration,
ClusterBundles, migration_detection, onboarding_bundles_items, use_migration,
use_onboarding_bundles, use_onboarding_selection,
};
use crate::routes::Route;
@ -14,9 +14,9 @@ use crate::view::onboarding::{
choice_row, onboarding_illustration, onboarding_slide, step_heading,
};
fn migrated_categories(detections: &[MigrationDetection]) -> Vec<String> {
fn migrated_categories(source: &[SourceInstance]) -> Vec<String> {
let mut categories: Vec<String> = Vec::new();
for instance in detections.iter().flat_map(|d| &d.instances) {
for instance in source {
for category in &instance.categories {
if !categories.iter().any(|c| c.eq_ignore_ascii_case(category)) {
categories.push(category.clone());
@ -26,28 +26,8 @@ fn migrated_categories(detections: &[MigrationDetection]) -> Vec<String> {
categories
}
pub(crate) fn find_instance<'a>(
detections: &'a [MigrationDetection],
chosen: &ImportSelection,
) -> Option<&'a SourceInstance> {
detections
.iter()
.find(|d| d.source == chosen.source)?
.instances
.iter()
.find(|i| i.folder_name == chosen.folder_name)
}
pub(crate) fn sources_sentence(detections: &[MigrationDetection]) -> String {
let names: Vec<&str> = detections.iter().map(|d| d.source.display_name()).collect();
match names.split_last() {
None => String::new(),
Some((last, [])) => (*last).to_string(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
}
}
/// Whether a new cluster exists matching this source instance's version+loader
/// (needed for the "dedicated dir" import option to have a target).
pub(crate) fn matching_new_cluster_id(
instance: &SourceInstance,
new: &[ClusterBundles],
@ -69,60 +49,42 @@ impl Component for OnboardingMigration {
let bundles_query = use_onboarding_bundles();
let selection = use_onboarding_selection();
let mut categories = selection.migrated_categories;
let mut import_selection = selection.import_selection;
// Shared across the flow; the file import is dispatched later, on Setup.
let mut import_folder = selection.import_folder;
let import_dedicated = selection.import_dedicated;
let detections = migration_detections(&migration_query);
if detections.is_empty() {
// No detection yet -> render nothing meaningful; the shell only routes
// here when a source was detected, so this is a transient loading state.
let Some(detection) = migration_detection(&migration_query) else {
return rect()
.width(Size::fill())
.height(Size::fill())
.into_element();
}
};
let source_name = detection.source.display_name();
let new_clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default();
let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some();
let chosen = import_selection.read().clone();
let any_importable = detections
.iter()
.flat_map(|d| &d.instances)
.any(|c| c.has_game_dir);
let chosen_folder = import_folder.read().clone();
let any_importable = detection.instances.iter().any(|c| c.has_game_dir);
let mut source_groups: Vec<Element> = Vec::new();
for detection in &detections {
let source = detection.source;
let mut version_cards: Vec<Element> = Vec::new();
// One card per detected version. Importable cards double as the file-
// import selector; non-importable ones stay informational.
let mut version_cards: Vec<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();
for instance in &detection.instances {
let selected_import = chosen
.as_ref()
.is_some_and(|c| c.source == source && c.folder_name == instance.folder_name);
let dedicated_available =
matching_new_cluster_id(instance, &new_clusters).is_some();
version_cards.push(version_card(
instance,
selected_import,
*import_dedicated.read(),
dedicated_available,
import_dedicated,
{
let folder_name = instance.folder_name.clone();
move |_| {
import_selection.set(Some(ImportSelection {
source,
folder_name: folder_name.clone(),
}))
}
},
));
}
source_groups.push(source_group(
source.display_name(),
detection.instances.len(),
version_cards,
version_cards.push(version_card(
instance,
selected_import,
*import_dedicated.read(),
dedicated_available,
import_dedicated,
{
let folder = instance.folder_name.clone();
move |_| import_folder.set(Some(folder.clone()))
},
));
}
@ -133,27 +95,40 @@ impl Component for OnboardingMigration {
.child(step_heading(
"Bring over your setup",
&format!(
"We found data from {}. Your installed categories are reselected \
automatically. Optionally, pick one version to copy its files \
(worlds, config, and packs).",
sources_sentence(&detections)
"We found data from your previous {source_name} install. Your installed \
categories are reselected automatically. Optionally, pick one version to \
copy its files (worlds, config, and packs)."
),
))
// "Don't import files" is the default choice, pinned above the list.
.maybe_child(any_importable.then(|| {
import_choice_card(
"Don't import files",
"Start fresh — only your bundle selection carries over.",
None,
chosen.is_none(),
move |_| import_selection.set(None),
chosen_folder.is_none(),
move |_| import_folder.set(None),
)
}))
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(16.)
.children(source_groups),
.spacing(10.)
.child(
label()
.text("Detected versions")
.font_size(13.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_secondary()),
)
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(8.)
.children(version_cards),
),
)
.into_element();
@ -192,7 +167,7 @@ impl Component for OnboardingMigration {
),
))
.child(migration_nav(bundles_loaded, move || {
categories.set(Some(migrated_categories(&detections)));
categories.set(Some(migrated_categories(&detection.instances)));
let _ = RouterContext::get().replace(Route::OnboardingLanguage {});
}))
@ -200,86 +175,11 @@ impl Component for OnboardingMigration {
}
}
pub fn source_group(name: &str, instance_count: usize, cards: Vec<Element>) -> Element {
let counted = if instance_count == 1 {
"1 version".to_string()
} else {
format!("{instance_count} versions")
};
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.child(
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(8.)
.child(
label()
.text(name.to_string())
.font_size(13.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_primary()),
)
.child(
rect()
.width(Size::flex(1.0))
.height(Size::px(1.))
.background(colors::component_border()),
)
.child(
label()
.text(counted)
.font_size(11.)
.color(colors::fg_secondary()),
),
)
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(8.)
.children(cards),
)
.into_element()
}
pub fn chip(text: String, on_brand: bool) -> Element {
let (bg, border, fg) = if on_brand {
(
colors::fg_primary().with_a(38),
colors::fg_primary().with_a(70),
colors::fg_primary(),
)
} else {
(
colors::component_bg(),
colors::component_border(),
colors::fg_secondary(),
)
};
rect()
.center()
.padding(Gaps::new_symmetric(2., 8.))
.corner_radius(CornerRadius::new_all(999.))
.background(bg)
.border(border_all_color(1., border))
.child(
label()
.text(text)
.font_size(10.)
.font_weight(FontWeight::MEDIUM)
.color(fg),
)
.into_element()
}
pub fn version_card(
/// A detected source instance rendered as a card. Shows version + categories
/// (always-reselected info); when the instance has a game dir it also acts as
/// the file-import selector and, when selected, exposes the shared/dedicated
/// target choice inline.
fn version_card(
instance: &SourceInstance,
selected: bool,
dedicated: bool,
@ -290,38 +190,18 @@ pub fn version_card(
let importable = instance.has_game_dir;
let mut on_select = on_select;
let categories = if instance.categories.is_empty() {
"No bundle categories".to_string()
} else {
instance.categories.join(", ")
};
let subtitle = format!("{} · {}", instance.mc_version, instance.mc_loader);
let (bg, border_color) = if selected {
(colors::brand(), colors::brand())
} else {
(colors::page_elevated(), colors::component_border())
};
let subtitle_color = if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
};
let icon_tile = rect()
.width(Size::px(34.))
.height(Size::px(34.))
.center()
.corner_radius(CornerRadius::new_all(8.))
.background(if selected {
colors::fg_primary().with_a(38)
} else {
colors::component_bg()
})
.child(
Icon::new(if !importable {
IconType::FileX02
} else if selected {
IconType::FolderCheck
} else {
IconType::Folder
})
.size(17.)
.color(subtitle_color),
);
let header = rect()
.horizontal()
@ -329,7 +209,6 @@ pub fn version_card(
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(12.)
.child(icon_tile)
.child(
rect()
.vertical()
@ -342,61 +221,39 @@ pub fn version_card(
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_primary()),
)
.child(
label()
.text(format!("{} · {}", instance.mc_version, instance.mc_loader))
.font_size(11.)
.color(subtitle_color),
),
.child(label().text(subtitle).font_size(11.).color(if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
}))
.child(label().text(categories).font_size(11.).color(if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
})),
)
.child(if !importable {
label()
.text("No files")
.font_size(11.)
.color(colors::fg_secondary())
.into_element()
} else if selected {
Icon::new(IconType::Check).size(18.).into_element()
} else {
label()
.text("Import files")
.font_size(12.)
.color(colors::fg_secondary())
.into_element()
});
let categories = if instance.categories.is_empty() {
label()
.text("No bundle categories")
.font_size(11.)
.color(subtitle_color)
.into_element()
} else {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(6.)
.children(
instance
.categories
.iter()
.map(|c| chip(c.clone(), selected))
.collect::<Vec<_>>(),
)
.into_element()
};
// Selection affordance only when the instance actually has files.
.maybe_child(importable.then(|| {
if selected {
Icon::new(IconType::Check).size(18.).into_element()
} else {
label()
.text("Import files")
.font_size(12.)
.color(colors::fg_secondary())
.into_element()
}
}));
let mut card = rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.spacing(12.)
.padding(Gaps::new_symmetric(12., 14.))
.corner_radius(CornerRadius::new_all(10.))
.background(bg)
.border(border_all_color(1., border_color))
.child(header)
.child(categories);
.child(header);
if importable {
card = card
@ -531,96 +388,3 @@ fn migration_nav(next_enabled: bool, on_next: impl FnMut() + 'static) -> impl In
)
.into_element()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use oneclient_core::MigrationSource;
use oneclient_core::packages::domain::GameLoader;
use super::*;
fn instance(folder: &str, categories: &[&str]) -> SourceInstance {
SourceInstance {
instance_id: 0,
folder_name: folder.to_string(),
mc_version: "1.21.10".to_string(),
mc_loader: GameLoader::Fabric,
categories: categories.iter().map(|c| c.to_string()).collect(),
has_game_dir: true,
}
}
fn detection(source: MigrationSource, instances: Vec<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());
}
}

View file

@ -20,7 +20,7 @@ pub use bundles::OnboardingBundles;
pub use downloading::{LoadingBackdrop, OnboardingDownloading};
pub use language::OnboardingLanguage;
pub use migration::OnboardingMigration;
pub(crate) use migration::{find_instance, matching_new_cluster_id, sources_sentence};
pub(crate) use migration::matching_new_cluster_id;
pub use preferences::OnboardingPreferences;
pub use terms::OnboardingTerms;
pub use welcome::OnboardingWelcome;

View file

@ -1,29 +0,0 @@
use oneclient_core::migration::lunar_client;
#[tokio::main]
async fn main() {
match lunar_client::detect().await {
Ok(Some(detection)) => {
println!(
"source: {} ({})",
detection.source.display_name(),
detection.source.id()
);
println!("root: {}", detection.root.display());
println!("instances: {}", detection.instances.len());
for i in &detection.instances {
println!(
" [{}] {} ({} {:?}) game_dir={} categories={:?}",
i.instance_id,
i.folder_name,
i.mc_version,
i.mc_loader,
i.has_game_dir,
i.categories,
);
}
}
Ok(None) => println!("no lunar client install detected"),
Err(err) => eprintln!("detection error: {err}"),
}
}

View file

@ -1,26 +0,0 @@
#[tokio::main]
async fn main() {
match oneclient_core::detect_migrations().await {
Ok(detections) if detections.is_empty() => {
println!("no migratable launcher detected");
}
Ok(detections) => {
println!("sources: {}", detections.len());
for detection in &detections {
println!(
"\n{} ({}) root={}",
detection.source.display_name(),
detection.source.id(),
detection.root.display()
);
for i in &detection.instances {
println!(
" {} ({} {:?}) game_dir={} categories={:?}",
i.folder_name, i.mc_version, i.mc_loader, i.has_game_dir, i.categories,
);
}
}
}
Err(err) => eprintln!("detection error: {err}"),
}
}

View file

@ -1,8 +1,6 @@
use oneclient_core::migration::oneclient_v1;
#[tokio::main]
async fn main() {
match oneclient_v1::detect().await {
match oneclient_core::detect_migration().await {
Ok(Some(detection)) => {
println!(
"source: {} ({})",
@ -23,7 +21,7 @@ async fn main() {
);
}
}
Ok(None) => println!("no v1 install detected"),
Ok(None) => println!("no migratable launcher detected"),
Err(err) => eprintln!("detection error: {err}"),
}
}

View file

@ -31,27 +31,29 @@ pub mod recovery;
pub mod reporting;
pub mod screenshots;
pub mod settings;
mod state;
pub mod status;
pub mod tos;
mod state;
pub mod version;
pub mod versions;
pub use bundles::{
ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, BundleFileKind,
BundleManifest, BundleUpdateCheckResult, BundleWithUpdateStatus, BundlesManager,
FileUpdateStatus, apply_bundle_updates, check_bundle_updates, effective_enabled,
get_bundles_with_update_status, install_bundle, install_cluster_bundles,
apply_bundle_updates, check_bundle_updates, effective_enabled, install_bundle,
install_cluster_bundles,
install_package_from_bundle, is_bundle_syncing, list_cluster_bundle_overrides,
set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override,
set_bundle_package_overrides,
ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile,
BundleFileKind, BundleManifest, BundlesManager, BundleUpdateCheckResult,
BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status,
};
pub use changelog::{ChangelogGroup, fetch_changelog, parse_changelog};
pub use changelog::{fetch_changelog, parse_changelog, ChangelogGroup};
pub use tos::{fetch_terms, TermsDocument};
pub use discord::{DiscordRpc, Presence};
pub use clusters::{
Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions,
ensure_from_bundles, ensure_from_versions, estimate_cluster_download,
};
pub use discord::{DiscordRpc, Presence};
pub use error::{LauncherError, LauncherResult};
pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster};
pub use images::ImageCacheStore;
@ -61,19 +63,17 @@ pub use logs::{
};
pub use metadata::{MetadataError, MetadataStore};
pub use migration::{
ImportTarget, MigrationDetection, MigrationSource, SourceInstance,
detect_all as detect_migrations, import_game_dir as import_migration_game_dir,
import_settings as import_migration_settings,
detect as detect_migration, import_game_dir as import_migration_game_dir, ImportTarget,
MigrationDetection, MigrationSource, SourceInstance,
};
pub use screenshots::{
ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot,
};
pub use notification::{GroupedProgressChild, GroupedProgressEvent, GroupedProgressSession};
pub use packages::LinkedArtifactInfo;
pub use patch::Patch;
pub use screenshots::{
ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot,
};
pub use settings::ProfileUpdate;
pub use state::LauncherServices;
pub use state::LauncherState;
pub use tos::{TermsDocument, fetch_terms};
pub use version::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version};
pub use versions::{RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest};

View file

@ -1,559 +0,0 @@
use std::path::{Path, PathBuf};
use serde::Deserialize;
use sqlx::Row;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use crate::LauncherResult;
use crate::packages::domain::GameLoader;
use crate::paths;
use crate::settings::{GameSettingsProfile, Resolution, store};
use super::fs::{copy_tree, dir_has_content};
use super::{ImportTarget, MigrationDetection, MigrationSource, SourceInstance};
const IMPORT_EXCLUDE_TOP: &[&str] = &[
"mods",
"logs",
"optionsLC.txt",
"optionsof.txt",
"base-modpack.mrpack",
"servers.dat_old",
];
pub fn old_root() -> Option<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));
}
}

View file

@ -1,254 +0,0 @@
use std::path::Path;
use serde_json::{Map, Value, json};
use crate::LauncherResult;
#[derive(Debug, PartialEq)]
pub(super) struct ConfigPatch {
pub path: &'static str,
pub values: Map<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));
}
}

View file

@ -1,6 +1,4 @@
mod fs;
pub mod lunar_client;
mod lunar_mods;
pub mod oneclient_v1;
use std::path::PathBuf;
@ -15,24 +13,20 @@ pub use fs::copy_tree;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MigrationSource {
OneClientV1,
LunarClient,
}
impl MigrationSource {
pub const ALL: &'static [MigrationSource] =
&[MigrationSource::OneClientV1, MigrationSource::LunarClient];
pub const ALL: &'static [MigrationSource] = &[MigrationSource::OneClientV1];
pub fn id(self) -> &'static str {
match self {
MigrationSource::OneClientV1 => "oneclient_v1",
MigrationSource::LunarClient => "lunar_client",
}
}
pub fn display_name(self) -> &'static str {
match self {
MigrationSource::OneClientV1 => "OneClient",
MigrationSource::LunarClient => "Lunar Client",
}
}
@ -71,24 +65,20 @@ pub enum ImportTarget {
Dedicated { new_cluster_id: i64 },
}
#[tracing::instrument(level = "debug")]
pub async fn detect_all() -> LauncherResult<Vec<MigrationDetection>> {
let mut detections = Vec::new();
#[tracing::instrument]
pub async fn detect() -> LauncherResult<Option<MigrationDetection>> {
for source in MigrationSource::ALL.iter().copied() {
let detection = match source {
MigrationSource::OneClientV1 => oneclient_v1::detect().await?,
MigrationSource::LunarClient => lunar_client::detect().await?,
};
if let Some(detection) = detection
&& !detection.instances.is_empty()
{
detections.push(detection);
return Ok(Some(detection));
}
}
Ok(detections)
Ok(None)
}
#[tracing::instrument]
@ -99,16 +89,5 @@ pub async fn import_game_dir(
) -> LauncherResult<()> {
match source {
MigrationSource::OneClientV1 => oneclient_v1::import_game_dir(folder_name, target).await,
MigrationSource::LunarClient => lunar_client::import_game_dir(folder_name, target).await,
}
}
/// Copies launcher-wide settings (memory, resolution)
#[tracing::instrument]
pub async fn import_settings(source: MigrationSource) -> LauncherResult<()> {
match source {
// v1 shares this launcher's settings file already.
MigrationSource::OneClientV1 => Ok(()),
MigrationSource::LunarClient => lunar_client::import_settings().await,
}
}