diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index ef0126e1..5db872fa 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -7,7 +7,9 @@ use freya::router::RouterContext; use oneclient_core::notification::{ GroupedProgressEvent, GroupedProgressSession, Notification, NotificationService, }; -use oneclient_core::{BundleArchive, BundleFile, Cluster, ImportTarget, VersionMetadata}; +use oneclient_core::{ + BundleArchive, BundleFile, Cluster, ImportTarget, MigrationSource, VersionMetadata, +}; use oneclient_db::models::OverrideType; use tokio::sync::mpsc; use uuid::Uuid; @@ -326,9 +328,11 @@ impl Component for OnboardingDownloading { let migration_summary = import_detection.as_ref().map(|detection| { let source_name = detection.source.display_name().to_string(); + let is_vanilla = detection.source == MigrationSource::Vanilla; match import_folder.read().clone() { Some(folder) => { - let dedicated = *import_dedicated.read() + let dedicated = !is_vanilla + && *import_dedicated.read() && detection .instances .iter() @@ -340,7 +344,12 @@ impl Component for OnboardingDownloading { } else { "Shared game directory" }; - (source_name, folder, target.to_string()) + let files = if is_vanilla { + "Your Minecraft folder".to_string() + } else { + folder + }; + (source_name, files, target.to_string()) } None => (source_name, "No files imported".to_string(), String::new()), } @@ -360,7 +369,9 @@ impl Component for OnboardingDownloading { if let (Some(detection), Some(folder)) = (import_detection.as_ref(), import_folder.peek().clone()) { - let target = if *import_dedicated.peek() { + let target = if detection.source != MigrationSource::Vanilla + && *import_dedicated.peek() + { detection .instances .iter() diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs index 7df6d269..16d8cbd1 100644 --- a/packages/oneclient_app/src/view/onboarding/migration.rs +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -1,6 +1,6 @@ use freya::prelude::*; use freya::router::RouterContext; -use oneclient_core::SourceInstance; +use oneclient_core::{MigrationDetection, MigrationSource, SourceInstance}; use crate::components::{Button, Icon, IconType, ScrollArea}; use crate::hooks::{ @@ -62,74 +62,60 @@ impl Component for OnboardingMigration { .into_element(); }; let source_name = detection.source.display_name(); + let is_vanilla = detection.source == MigrationSource::Vanilla; let new_clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some(); let chosen_folder = import_folder.read().clone(); let any_importable = detection.instances.iter().any(|c| c.has_game_dir); - // One card per detected version. Importable cards double as the file- - // import selector; non-importable ones stay informational. - let mut version_cards: Vec = Vec::new(); - for instance in &detection.instances { - let selected_import = chosen_folder.as_deref() == Some(instance.folder_name.as_str()); - let dedicated_available = matching_new_cluster_id(instance, &new_clusters).is_some(); - - version_cards.push(version_card( - instance, - selected_import, - *import_dedicated.read(), - dedicated_available, - import_dedicated, - { - let folder = instance.folder_name.clone(); - move |_| import_folder.set(Some(folder.clone())) - }, - )); - } + let (title, subtitle) = if is_vanilla { + ( + "Bring over your Minecraft files", + "We found a Minecraft installation on this PC. We can copy your worlds, \ + settings, screenshots and servers over. Mods, resource packs and shaders stay \ + behind — OneClient manages those for you." + .to_string(), + ) + } else { + ( + "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)." + ), + ) + }; + let heading = step_heading(title, &subtitle); let content = rect() .vertical() .width(Size::fill()) .spacing(16.) - .child(step_heading( - "Bring over your setup", - &format!( - "We found data from your previous {source_name} install. Your installed \ - categories are reselected automatically. Optionally, pick one version to \ - copy its files (worlds, config, and packs)." - ), - )) + .child(heading) // "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.", + if is_vanilla { + "Start fresh — nothing is copied over." + } else { + "Start fresh — only your bundle selection carries over." + }, None, chosen_folder.is_none(), move |_| import_folder.set(None), ) })) - .child( - rect() - .vertical() - .width(Size::fill()) - .spacing(10.) - .child( - label() - .text("Detected versions") - .font_size(13.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_secondary()), - ) - .child( - rect() - .vertical() - .width(Size::fill()) - .spacing(8.) - .children(version_cards), - ), - ) + .child(import_body( + &detection, + is_vanilla, + chosen_folder.as_deref(), + &new_clusters, + import_folder, + import_dedicated, + )) .into_element(); rect() @@ -149,7 +135,11 @@ impl Component for OnboardingMigration { .height(Size::fill()) .center() .padding(Gaps::new_all(48.)) - .child(onboarding_illustration(IconType::ClockRewind)), + .child(onboarding_illustration(if is_vanilla { + IconType::FolderDownload + } else { + IconType::ClockRewind + })), ) .child( rect() @@ -175,6 +165,69 @@ impl Component for OnboardingMigration { } } +fn import_body( + detection: &MigrationDetection, + is_vanilla: bool, + chosen_folder: Option<&str>, + new_clusters: &[ClusterBundles], + mut import_folder: State>, + import_dedicated: State, +) -> Element { + if is_vanilla { + let folder = detection + .instances + .first() + .map(|instance| instance.folder_name.clone()) + .unwrap_or_default(); + + return import_choice_card( + "Copy my Minecraft files", + "Worlds, settings, screenshots and servers move to your shared game directory.", + None, + chosen_folder.is_some(), + move |_| import_folder.set(Some(folder.clone())), + ); + } + + let mut version_cards: Vec = Vec::new(); + for instance in &detection.instances { + let selected_import = chosen_folder == Some(instance.folder_name.as_str()); + let dedicated_available = matching_new_cluster_id(instance, new_clusters).is_some(); + + version_cards.push(version_card( + instance, + selected_import, + *import_dedicated.read(), + dedicated_available, + import_dedicated, + { + let folder = instance.folder_name.clone(); + move |_| import_folder.set(Some(folder.clone())) + }, + )); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Detected versions") + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .children(version_cards), + ) + .into_element() +} + /// 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 diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs index 99561532..3667254d 100644 --- a/packages/oneclient_core/src/migration/mod.rs +++ b/packages/oneclient_core/src/migration/mod.rs @@ -1,5 +1,6 @@ mod fs; pub mod oneclient_v1; +pub mod vanilla; use std::path::PathBuf; @@ -13,20 +14,24 @@ pub use fs::copy_tree; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum MigrationSource { OneClientV1, + Vanilla, } impl MigrationSource { - pub const ALL: &'static [MigrationSource] = &[MigrationSource::OneClientV1]; + pub const ALL: &'static [MigrationSource] = + &[MigrationSource::OneClientV1, MigrationSource::Vanilla]; pub fn id(self) -> &'static str { match self { MigrationSource::OneClientV1 => "oneclient_v1", + MigrationSource::Vanilla => "vanilla", } } pub fn display_name(self) -> &'static str { match self { MigrationSource::OneClientV1 => "OneClient", + MigrationSource::Vanilla => "Minecraft", } } @@ -70,6 +75,7 @@ pub async fn detect() -> LauncherResult> { for source in MigrationSource::ALL.iter().copied() { let detection = match source { MigrationSource::OneClientV1 => oneclient_v1::detect().await?, + MigrationSource::Vanilla => vanilla::detect().await?, }; if let Some(detection) = detection @@ -89,5 +95,23 @@ pub async fn import_game_dir( ) -> LauncherResult<()> { match source { MigrationSource::OneClientV1 => oneclient_v1::import_game_dir(folder_name, target).await, + MigrationSource::Vanilla => vanilla::import_game_dir(target).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vanilla_is_the_last_resort() { + assert_eq!(MigrationSource::ALL.last(), Some(&MigrationSource::Vanilla)); + } + + #[test] + fn ids_round_trip() { + for source in MigrationSource::ALL.iter().copied() { + assert_eq!(MigrationSource::from_id(source.id()), Some(source)); + } } } diff --git a/packages/oneclient_core/src/migration/vanilla.rs b/packages/oneclient_core/src/migration/vanilla.rs new file mode 100644 index 00000000..64c16921 --- /dev/null +++ b/packages/oneclient_core/src/migration/vanilla.rs @@ -0,0 +1,240 @@ +use std::path::PathBuf; + +use crate::LauncherResult; +use crate::packages::domain::GameLoader; +use crate::paths; + +use super::fs::{copy_tree, dir_has_content}; +use super::{ImportTarget, MigrationDetection, MigrationSource, SourceInstance}; + +const ROOT_INSTANCE_NAME: &str = ".minecraft"; + +const IMPORT_EXCLUDE_TOP: &[&str] = &[ + "mods", + "resourcepacks", + "shaderpacks", + "datapacks", + "versions", + "libraries", + "assets", + "bin", + "jre", + "logs", + "crash-reports", + "launcher_accounts.json", + "launcher_accounts_microsoft_store.json", + "launcher_msa_credentials.bin", + "launcher_profiles.json", + "launcher_settings.json", + "usercache.json", + "webcache", + "webcache2", + ".mojang", + "cheatbreaker_accounts.json", + "feather", + "feather-mods", + "feather-versions", + "optionsLC.txt", + "servers.dat_old", + "cache", + "downloads", + "jfr", + "skyblock-repo-cache", + "meowdding-repo-cache", +]; + +pub fn old_root() -> Option { + let base = directories::BaseDirs::new().map(|d| { + // Windows: `%APPDATA%\.minecraft` (data_dir is Roaming). + #[cfg(target_os = "windows")] + { + d.data_dir().join(".minecraft") + } + // macOS: `~/Library/Application Support/minecraft` — no leading dot. + #[cfg(target_os = "macos")] + { + d.data_dir().join("minecraft") + } + // Linux and friends: `~/.minecraft`. Not data_dir(), which is + // `~/.local/share`. + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + d.home_dir().join(".minecraft") + } + }); + + let root = match std::env::var("VANILLA_MC_DIR") { + Ok(dir) => PathBuf::from(dir), + Err(_) => base?, + }; + + let exists = root.is_dir(); + tracing::debug!(root = %root.display(), exists, "vanilla migration: resolved old root"); + exists.then_some(root) +} + +#[tracing::instrument] +pub async fn detect() -> LauncherResult> { + let Some(root) = old_root() else { + return Ok(None); + }; + + if !dir_has_content(&root).await { + tracing::debug!(root = %root.display(), "vanilla migration: empty install, skipping"); + return Ok(None); + } + + tracing::info!(root = %root.display(), "vanilla migration: detected install"); + + Ok(Some(MigrationDetection { + source: MigrationSource::Vanilla, + instances: vec![SourceInstance { + instance_id: 0, + folder_name: ROOT_INSTANCE_NAME.to_string(), + mc_version: String::new(), + mc_loader: GameLoader::Vanilla, + categories: Vec::new(), + has_game_dir: true, + }], + root, + })) +} + +#[tracing::instrument(skip(target))] +pub async fn import_game_dir(target: ImportTarget) -> LauncherResult<()> { + let Some(src) = old_root() else { + 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?; + polyio::write(cluster.dedicated_marker()?, Vec::new()).await?; + dir + } + }; + + polyio::create_dir_all(&dest).await?; + copy_tree(&src, &dest, IMPORT_EXCLUDE_TOP).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn excluded(name: &str) -> bool { + IMPORT_EXCLUDE_TOP + .iter() + .any(|e| e.eq_ignore_ascii_case(name)) + } + + #[test] + fn excludes_package_and_launcher_managed_dirs() { + for name in [ + "mods", + "resourcepacks", + "shaderpacks", + "datapacks", + "versions", + "libraries", + "assets", + "logs", + ] { + assert!(excluded(name), "{name} should be excluded"); + } + } + + #[test] + fn excludes_mojang_credentials() { + assert!(excluded("launcher_accounts.json")); + assert!(excluded("launcher_msa_credentials.bin")); + } + + #[test] + fn excludes_third_party_launcher_data() { + for name in ["cheatbreaker_accounts.json", "feather-mods", "jre"] { + assert!(excluded(name), "{name} should be excluded"); + } + } + + #[test] + fn keeps_user_data() { + for name in [ + "saves", + "config", + "options.txt", + "screenshots", + "servers.dat", + ] { + assert!(!excluded(name), "{name} must not be excluded"); + } + } + + #[tokio::test] + async fn import_copies_user_data_and_skips_the_rest() { + let src = polyio::tempdir().await.expect("src dir"); + let dst = polyio::tempdir().await.expect("dst dir"); + let (src, dst) = (src.dir_path(), dst.dir_path()); + + for file in [ + "options.txt", + "servers.dat", + "launcher_accounts.json", + "launcher_msa_credentials.bin", + ] { + polyio::write(src.join(file), b"x".to_vec()).await.unwrap(); + } + for (dir, file) in [ + ("saves", "world/level.dat"), + ("config", "sodium.json"), + ("screenshots", "shot.png"), + ("mods", "sodium.jar"), + ("resourcepacks", "faithful.zip"), + ("shaderpacks", "bsl.zip"), + ("versions", "1.21.1/1.21.1.jar"), + ("libraries", "com/foo/foo.jar"), + ("assets", "objects/ab/abcdef"), + ("logs", "latest.log"), + ] { + let path = src.join(dir).join(file); + polyio::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + polyio::write(path, b"x".to_vec()).await.unwrap(); + } + + copy_tree(src, dst, IMPORT_EXCLUDE_TOP).await.unwrap(); + + for kept in [ + "options.txt", + "servers.dat", + "saves/world/level.dat", + "config/sodium.json", + "screenshots/shot.png", + ] { + assert!(dst.join(kept).exists(), "{kept} should have been copied"); + } + for skipped in [ + "mods", + "resourcepacks", + "shaderpacks", + "versions", + "libraries", + "assets", + "logs", + "launcher_accounts.json", + "launcher_msa_credentials.bin", + ] { + assert!( + !dst.join(skipped).exists(), + "{skipped} should not have been copied" + ); + } + } +}