vanilla migration

#546
This commit is contained in:
Julian Chang 2026-07-16 16:28:21 -04:00
parent 7696d80fe8
commit 2718105944
No known key found for this signature in database
4 changed files with 383 additions and 55 deletions

View file

@ -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()

View file

@ -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<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();
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<Option<String>>,
import_dedicated: State<bool>,
) -> 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<Element> = 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

View file

@ -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<Option<MigrationDetection>> {
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));
}
}
}

View file

@ -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<PathBuf> {
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<Option<MigrationDetection>> {
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"
);
}
}
}