parent
7696d80fe8
commit
2718105944
4 changed files with 383 additions and 55 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
240
packages/oneclient_core/src/migration/vanilla.rs
Normal file
240
packages/oneclient_core/src/migration/vanilla.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue