From 5cac13a54cf266a139b34142090641a743874bb5 Mon Sep 17 00:00:00 2001 From: Wyvest Date: Mon, 30 Mar 2026 00:28:14 -0400 Subject: [PATCH] Add cluster migration stuff Implement cluster content migration and local versions override. Adds a new IPC API (copy_cluster_content) and CopyClusterResult, plus copy_dir_all helper to recursively copy files. Introduces migrate_non_bundle_packages and migrate_bundles_to_cluster to map and install provider-managed packages for the target MC version and to translate/copy bundle overrides; files without a compatible provider version are copied as .disabled and returned to the caller. Prefer a local versions_override.json when loading the online manifest (useful for testing) and adapt MC version parsing/formatting for year-based majors. Frontend: update generated bindings/types, add MigrationModal UI and useVersionMigration hook, integrate the migration modal into the root and debug screens, and persist seen_versions in settings. Misc: propagate manifest/versions into several UI routes and adjust bundle/package handling accordingly. --- apps/oneclient/desktop/src/api/commands.rs | 161 ++++++++- .../desktop/src/oneclient/bundle_updates.rs | 328 +++++++++++++++++- .../desktop/src/oneclient/clusters.rs | 58 +++- apps/oneclient/frontend/src/bindings.gen.ts | 45 ++- .../src/components/overlay/MigrationModal.tsx | 235 +++++++++++++ .../frontend/src/hooks/useVersionMigration.ts | 93 +++++ apps/oneclient/frontend/src/routes/__root.tsx | 16 + .../frontend/src/routes/app/cluster/route.tsx | 4 +- .../frontend/src/routes/app/clusters.tsx | 16 +- .../frontend/src/routes/app/debug.tsx | 3 + .../frontend/src/routes/app/index.tsx | 18 +- .../frontend/src/routes/app/route.tsx | 2 +- .../routes/onboarding/preferences/version.tsx | 11 +- .../preferences/versionCategory.tsx | 9 +- .../frontend/src/stores/migrationStore.ts | 55 +++ .../frontend/src/utils/versionMap.ts | 112 +++--- packages/core/src/api/cluster/dao.rs | 12 + packages/core/src/store/settings.rs | 4 + 18 files changed, 1065 insertions(+), 117 deletions(-) create mode 100644 apps/oneclient/frontend/src/components/overlay/MigrationModal.tsx create mode 100644 apps/oneclient/frontend/src/hooks/useVersionMigration.ts create mode 100644 apps/oneclient/frontend/src/stores/migrationStore.ts diff --git a/apps/oneclient/desktop/src/api/commands.rs b/apps/oneclient/desktop/src/api/commands.rs index cbcf55ee..bf3eb4e8 100644 --- a/apps/oneclient/desktop/src/api/commands.rs +++ b/apps/oneclient/desktop/src/api/commands.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use onelauncher_core::api; use onelauncher_core::api::cluster::dao::ClusterId; @@ -13,6 +13,32 @@ use crate::oneclient::bundles::BundlesManager; use crate::oneclient::clusters::{OnlineClusterManifest, get_data_storage_versions}; use tauri::{AppHandle, Runtime}; +const BUNDLE_DIRS: &[&str] = &["mods", "resourcepacks", "shaderpacks"]; + +/// Recursively copy all files from `src` into `dst`, creating directories as needed. +async fn copy_dir_all(src: &Path, dst: &Path) -> onelauncher_core::error::LauncherResult<()> { + tokio::fs::create_dir_all(dst) + .await + .map_err(anyhow::Error::from)?; + + let mut entries = tokio::fs::read_dir(src) + .await + .map_err(anyhow::Error::from)?; + while let Some(entry) = entries.next_entry().await.map_err(anyhow::Error::from)? { + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + Box::pin(copy_dir_all(&src_path, &dst_path)).await?; + } else { + tokio::fs::copy(&src_path, &dst_path) + .await + .map_err(anyhow::Error::from)?; + } + } + + Ok(()) +} + #[taurpc::procedures(path = "oneclient", export_to = "../frontend/src/bindings.gen.ts")] pub trait OneClientApi { #[taurpc(alias = "getClustersGroupedByMajor")] @@ -59,6 +85,18 @@ pub trait OneClientApi { #[taurpc(alias = "refreshArt")] async fn refresh_art(path: String) -> LauncherResult<()>; + + #[taurpc(alias = "copyClusterContent")] + async fn copy_cluster_content( + source_id: ClusterId, + target_id: ClusterId, + ) -> LauncherResult; +} + +#[taurpc::ipc_type] +pub struct CopyClusterResult { + /// Files that had no compatible provider version and were copied verbatim from the source. + pub fallback_files: Vec, } #[taurpc::ipc_type] @@ -74,16 +112,21 @@ impl OneClientApi for OneClientApiImpl { let mut mapped: HashMap> = HashMap::new(); for cluster in clusters { - let split = &mut cluster.mc_version.split('.'); - split.next(); - if let Some(major) = split.next() { - let major: u32 = match major.parse() { + let mut parts = cluster.mc_version.splitn(3, '.'); + let major: u32 = match parts.next() { + // Old format: 1.X[.Y] + Some("1") => match parts.next().and_then(|v| v.parse().ok()) { + Some(v) => v, + None => continue, + }, + // New format: YY.N[.P] + Some(year) => match year.parse() { Ok(v) => v, Err(_) => continue, - }; - - mapped.entry(major).or_default().push(cluster); - } + }, + None => continue, + }; + mapped.entry(major).or_default().push(cluster); } Ok(mapped) @@ -224,4 +267,104 @@ impl OneClientApi for OneClientApiImpl { }); Ok(()) } + + async fn copy_cluster_content( + self, + source_id: ClusterId, + target_id: ClusterId, + ) -> LauncherResult { + let source = api::cluster::dao::get_cluster_by_id(source_id) + .await? + .ok_or_else(|| anyhow::anyhow!("source cluster {} not found", source_id))?; + let target = api::cluster::dao::get_cluster_by_id(target_id) + .await? + .ok_or_else(|| anyhow::anyhow!("target cluster {} not found", target_id))?; + + let clusters_dir = onelauncher_core::store::Dirs::get_clusters_dir().await?; + let source_path = clusters_dir.join(&source.folder_name); + let target_path = clusters_dir.join(&target.folder_name); + + // Mods, resource packs, and shader packs are version-dependent and bundle-managed. + // Detect the source cluster's bundle subscriptions, map them to the target version, + // copy overrides, and install the correctly-versioned packages into the target. + crate::oneclient::bundle_updates::migrate_bundles_to_cluster(source_id, target_id).await?; + + // Migrate non-bundle (custom) packages: update via provider for the target MC version + // where possible, and get back a list of files that had no compatible version and + // need to be copied verbatim. + let fallback_files: std::collections::HashSet = + crate::oneclient::bundle_updates::migrate_non_bundle_packages(source_id, target_id) + .await? + .into_iter() + .collect(); + + // File-copy everything from the source folder. For bundle-managed dirs, only copy + // files in the fallback list (everything else was handled version-correctly above). + if source_path.exists() { + let mut entries = tokio::fs::read_dir(&source_path) + .await + .map_err(anyhow::Error::from)?; + while let Some(entry) = entries.next_entry().await.map_err(anyhow::Error::from)? { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + let src = entry.path(); + let dst = target_path.join(&name); + if BUNDLE_DIRS.contains(&name_str.as_ref()) { + // Only copy files that weren't handled by migrate_non_bundle_packages + // or migrate_bundles_to_cluster (i.e., provider lookup failed). + if src.is_dir() { + let mut sub = tokio::fs::read_dir(&src) + .await + .map_err(anyhow::Error::from)?; + while let Some(sub_entry) = + sub.next_entry().await.map_err(anyhow::Error::from)? + { + let sub_name = sub_entry.file_name(); + if !fallback_files.contains(sub_name.to_string_lossy().as_ref()) { + continue; + } + tokio::fs::create_dir_all(&dst) + .await + .map_err(anyhow::Error::from)?; + let sub_src = sub_entry.path(); + // Copy as disabled — no compatible version exists for the new MC + // version, so mark it disabled rather than silently breaking the game. + let disabled_name = { + let s = sub_name.to_string_lossy(); + if s.ends_with(".disabled") { + sub_name.clone() + } else { + std::ffi::OsString::from(format!("{s}.disabled")) + } + }; + let sub_dst = dst.join(&disabled_name); + if sub_src.is_dir() { + copy_dir_all(&sub_src, &sub_dst).await?; + } else { + tokio::fs::copy(&sub_src, &sub_dst) + .await + .map_err(anyhow::Error::from)?; + } + } + } + } else if src.is_dir() { + copy_dir_all(&src, &dst).await?; + } else { + tokio::fs::copy(&src, &dst) + .await + .map_err(anyhow::Error::from)?; + } + } + } + + onelauncher_core::api::cluster::sync_cluster_by_id(target_id).await?; + + // Stamp the target cluster's created_at with the actual migration time so the + // home page can correctly identify it as a newly-migrated cluster. + onelauncher_core::api::cluster::dao::touch_cluster_created_at(target_id).await?; + + Ok(CopyClusterResult { + fallback_files: fallback_files.into_iter().collect(), + }) + } } diff --git a/apps/oneclient/desktop/src/oneclient/bundle_updates.rs b/apps/oneclient/desktop/src/oneclient/bundle_updates.rs index ae0d0166..35ac61d8 100644 --- a/apps/oneclient/desktop/src/oneclient/bundle_updates.rs +++ b/apps/oneclient/desktop/src/oneclient/bundle_updates.rs @@ -7,8 +7,9 @@ use onelauncher_core::api::packages::bundle_dao; use onelauncher_core::api::packages::modpack::data::{ ModpackArchive, ModpackFile, ModpackFileKind, }; +use onelauncher_core::api::packages::provider::ProviderExt; use onelauncher_core::entity::cluster_packages; -use onelauncher_core::entity::package::Provider; +use onelauncher_core::entity::package::{PackageType, Provider}; use onelauncher_core::error::LauncherResult; use onelauncher_core::{api, send_error}; @@ -34,6 +35,12 @@ fn external_bundle_key(sha1: &str) -> String { format!("e:{sha1}") } +const VERSIONED_TYPES: &[PackageType] = &[ + PackageType::Mod, + PackageType::ResourcePack, + PackageType::Shader, +]; + fn collect_inferable_managed_keys( bundle_files_map: &HashMap, hidden_dependency_keys: &HashSet, @@ -1237,6 +1244,325 @@ pub async fn apply_bundle_updates( }) } +/// Migrates non-bundle (custom) packages from `source_cluster_id` to `target_cluster_id`. +/// +/// For packages tracked on Modrinth or `CurseForge`, attempts to find and install a version +/// compatible with the target cluster's MC version. Packages with no provider match are +/// returned as a list of file names to copy verbatim. +pub async fn migrate_non_bundle_packages( + source_cluster_id: ClusterId, + target_cluster_id: ClusterId, +) -> LauncherResult> { + let source = api::cluster::dao::get_cluster_by_id(source_cluster_id) + .await? + .ok_or_else(|| anyhow::anyhow!("source cluster {} not found", source_cluster_id))?; + let target = api::cluster::dao::get_cluster_by_id(target_cluster_id) + .await? + .ok_or_else(|| anyhow::anyhow!("target cluster {} not found", target_cluster_id))?; + + // All packages linked to source cluster. + let all_linked = api::packages::dao::get_linked_packages(&source).await?; + + // Set of hashes that are bundle-tracked. + let bundle_packages = bundle_dao::get_bundle_packages_for_cluster(source_cluster_id).await?; + let bundle_hashes: HashSet<&str> = bundle_packages + .iter() + .map(|bp| bp.package_hash.as_str()) + .collect(); + + let non_bundle: Vec<_> = all_linked + .iter() + .filter(|p| { + !bundle_hashes.contains(p.hash.as_str()) && VERSIONED_TYPES.contains(&p.package_type) + }) + .collect(); + + let mut fallback_file_names = Vec::new(); + + for pkg in non_bundle { + if matches!(pkg.provider, Provider::Modrinth | Provider::CurseForge) { + let versions = pkg + .provider + .get_versions_paginated( + &pkg.package_id, + Some(target.mc_version.clone()), + Some(target.mc_loader), + 0, + 1, + ) + .await; + + match versions { + Ok(page) if !page.items.is_empty() => { + let version = &page.items[0]; + match pkg.provider.get(&pkg.package_id).await { + Ok(managed_pkg) => { + match api::packages::download_package(&managed_pkg, version, None, None) + .await + { + Ok(dl) => { + if let Err(e) = + api::packages::link_package(&dl, &target, Some(true)).await + { + tracing::warn!( + package_id = %pkg.package_id, + "Failed to link migrated non-bundle package: {e}" + ); + fallback_file_names.push(pkg.file_name.clone()); + } + } + Err(e) => { + tracing::warn!( + package_id = %pkg.package_id, + "Failed to download migrated non-bundle package: {e}" + ); + fallback_file_names.push(pkg.file_name.clone()); + } + } + } + Err(e) => { + tracing::warn!( + package_id = %pkg.package_id, + "Failed to fetch package metadata for migration: {e}" + ); + fallback_file_names.push(pkg.file_name.clone()); + } + } + } + _ => { + // No compatible version found — fall back to file copy. + fallback_file_names.push(pkg.file_name.clone()); + } + } + } else { + // Local/unknown provider — always file-copy. + fallback_file_names.push(pkg.file_name.clone()); + } + } + + Ok(fallback_file_names) +} + +/// Migrates bundle packages from `source_cluster_id` to `target_cluster_id` by: +/// 1. Detecting which bundles the source is subscribed to. +/// 2. Mapping those to equivalent bundles for the target's MC version (by matching the +/// category suffix, e.g. `[HUD]`). +/// 3. Copying the source's bundle overrides (disabled/removed) to the target, translating +/// bundle names to the target version's equivalent. +/// 4. Installing all enabled packages from the matched bundles into the target cluster, +/// respecting those overrides. +#[allow(clippy::too_many_lines)] +pub async fn migrate_bundles_to_cluster( + source_cluster_id: ClusterId, + target_cluster_id: ClusterId, +) -> LauncherResult<()> { + let cluster_lock = get_cluster_lock(target_cluster_id); + let _guard = cluster_lock.lock().await; + + // 1. Find which bundle names the source cluster is explicitly subscribed to. + let source_bundle_packages = + bundle_dao::get_bundle_packages_for_cluster(source_cluster_id).await?; + let source_subscribed_names: HashSet = source_bundle_packages + .iter() + .filter_map(|bp| bp.bundle_name.clone()) + .collect(); + + if source_subscribed_names.is_empty() { + tracing::debug!( + source_cluster_id = %source_cluster_id, + "Source cluster has no tracked bundle subscriptions; skipping bundle migration" + ); + return Ok(()); + } + + let target_cluster = api::cluster::dao::get_cluster_by_id(target_cluster_id) + .await? + .ok_or_else(|| anyhow::anyhow!("target cluster {} not found", target_cluster_id))?; + + // 2. Fetch bundles available for the target version. + let target_bundles = BundlesManager::get() + .await + .get_bundles_for(&target_cluster.mc_version, target_cluster.mc_loader) + .await?; + + // Build a map: source bundle name → target bundle name, matched by category suffix + // (the `[Category]` part at the end of the name, e.g. `[HUD]`). + let bundle_name_map: HashMap = target_bundles + .iter() + .filter_map(|bundle| { + let target_name = &bundle.manifest.name; + let matched_src = source_subscribed_names.iter().find(|src_name| { + src_name + .rfind('[') + .is_some_and(|i| target_name.ends_with(&src_name[i..])) + })?; + Some((matched_src.clone(), target_name.clone())) + }) + .collect(); + + if bundle_name_map.is_empty() { + tracing::debug!( + source_cluster_id = %source_cluster_id, + target_cluster_id = %target_cluster_id, + "No matching target bundles found for source subscriptions; skipping bundle migration" + ); + return Ok(()); + } + + tracing::info!( + source_cluster_id = %source_cluster_id, + target_cluster_id = %target_cluster_id, + bundle_map = ?bundle_name_map, + "Migrating bundles to target cluster" + ); + + // 3. Copy source overrides to target, translating bundle names to the target version. + let source_overrides = bundle_dao::get_bundle_overrides(source_cluster_id).await?; + for ov in &source_overrides { + if let Some(target_bundle_name) = bundle_name_map.get(&ov.bundle_name) { + let _ = bundle_dao::save_bundle_override( + target_cluster_id, + target_bundle_name, + &ov.package_id, + ov.override_type.clone(), + ) + .await; + } + } + + // 4. Install all enabled packages from matched target bundles. + let target_overrides = bundle_dao::get_bundle_overrides(target_cluster_id).await?; + let mut additions_applied: HashSet = HashSet::new(); + + for bundle in &target_bundles { + if !bundle_name_map.values().any(|n| n == &bundle.manifest.name) { + continue; + } + for file in &bundle.manifest.files { + if !file.enabled { + continue; + } + let file_id = match &file.kind { + ModpackFileKind::Managed(box_) => box_.0.id.clone(), + ModpackFileKind::External(ext) => ext.sha1.clone(), + }; + if target_overrides.iter().any(|o| { + o.bundle_name == bundle.manifest.name + && o.package_id == file_id + && o.override_type + == onelauncher_core::entity::cluster_bundle_overrides::OverrideType::Removed + }) { + tracing::info!( + bundle_name = %bundle.manifest.name, + file_id = %file_id, + "Skipping package migration due to user override 'Removed'" + ); + continue; + } + let addition = BundlePackageAddition { + cluster_id: target_cluster_id, + bundle_name: bundle.manifest.name.clone(), + new_file: file.clone(), + }; + match apply_single_addition(&addition, &target_overrides).await { + Ok(_) => { + additions_applied.insert(bundle.manifest.name.clone()); + } + Err(e) => { + send_error!( + "Failed to install bundle package '{}' during migration: {}", + file_id, + e + ); + } + } + } + } + + // Re-extract bundle overrides (configs, resource packs, etc.) for affected bundles. + for bundle in &target_bundles { + if additions_applied.contains(&bundle.manifest.name) + && let Err(e) = + onelauncher_core::api::packages::modpack::mrpack::copy_overrides_folder_no_overwrite( + &target_cluster, + &bundle.path, + ) + .await + { + send_error!( + "Failed to extract overrides from bundle '{}' during migration: {}", + bundle.manifest.name, + e + ); + } + } + + // Ensure disabled overrides are reflected on disk. Packages may have been pre-installed + // by startup bundle sync (before overrides were copied), so we retroactively disable any + // that the user had disabled in the source cluster. + let current_overrides = bundle_dao::get_bundle_overrides(target_cluster_id).await?; + if !current_overrides.is_empty() { + let target_bundle_packages = + bundle_dao::get_bundle_packages_for_cluster(target_cluster_id).await?; + let all_linked = api::packages::dao::get_linked_packages(&target_cluster).await?; + let linked_by_hash: HashMap<&str, &onelauncher_core::entity::packages::Model> = + all_linked.iter().map(|p| (p.hash.as_str(), p)).collect(); + + for bp in &target_bundle_packages { + let Some(bundle_name) = &bp.bundle_name else { + continue; + }; + let Some(package_id) = &bp.package_id else { + continue; + }; + let should_be_disabled = current_overrides.iter().any(|o| { + &o.bundle_name == bundle_name + && &o.package_id == package_id + && o.override_type + == onelauncher_core::entity::cluster_bundle_overrides::OverrideType::Disabled + }); + let Some(pkg) = linked_by_hash.get(bp.package_hash.as_str()) else { + continue; + }; + + // Keep override metadata and on-disk state aligned after migration: + // - if file is already `.disabled`, ensure Disabled override exists + // - if Disabled override exists but file is enabled, disable it on disk + if pkg.file_name.ends_with(".disabled") { + if !should_be_disabled + && let Err(e) = bundle_dao::save_bundle_override( + target_cluster_id, + bundle_name, + package_id, + onelauncher_core::entity::cluster_bundle_overrides::OverrideType::Disabled, + ) + .await + { + tracing::warn!( + package_id = %package_id, + bundle_name = %bundle_name, + "Failed to persist disabled override for migrated package: {e}" + ); + } + continue; + } + + if should_be_disabled + && let Err(e) = + api::packages::toggle_package(target_cluster_id, bp.package_hash.clone()).await + { + tracing::warn!( + package_id = %package_id, + bundle_name = %bundle_name, + "Failed to disable migrated bundle package: {e}" + ); + } + } + } + + Ok(()) +} + #[cfg(test)] mod inference_tests { use std::collections::{HashMap, HashSet}; diff --git a/apps/oneclient/desktop/src/oneclient/clusters.rs b/apps/oneclient/desktop/src/oneclient/clusters.rs index 4fdd41e3..6b549e93 100644 --- a/apps/oneclient/desktop/src/oneclient/clusters.rs +++ b/apps/oneclient/desktop/src/oneclient/clusters.rs @@ -57,6 +57,10 @@ pub struct OnlineCluster { name: String, art: String, entries: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + long_description: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tags: Vec, } #[derive(specta::Type, Deserialize, Serialize)] @@ -70,23 +74,46 @@ pub struct OnlineClusterEntry { tags: Vec, } -pub async fn get_data_storage_versions() -> LauncherResult { - let manifest = match fetch_json::( +/// Load the versions manifest, preferring a local override file if one exists. +/// +/// Place a `versions_override.json` in the launcher's data directory +/// (the path logged as "using base directory '...'" on startup) to use it +/// instead of fetching from the remote `DataStorage` URL. Useful for testing +/// new versions locally before pushing to `DataStorage`. +async fn load_versions_manifest() -> LauncherResult { + if let Ok(base_dir) = onelauncher_core::store::Dirs::get() + .await + .map(|d| d.base_dir().clone()) + { + let override_path = base_dir.join("versions_override.json"); + if override_path.exists() { + tracing::info!("using local versions override: {}", override_path.display()); + let bytes = tokio::fs::read(&override_path) + .await + .map_err(anyhow::Error::from)?; + let manifest: OnlineClusterManifest = + serde_json::from_slice(&bytes).map_err(anyhow::Error::from)?; + return Ok(manifest); + } + } + + fetch_json::( Method::GET, &format!("{}/versions/versions.json", crate::constants::META_URL_BASE), None, None, ) .await - { - Ok(m) => m, +} + +pub async fn get_data_storage_versions() -> LauncherResult { + match load_versions_manifest().await { + Ok(m) => Ok(m), Err(e) => { send_error!("failed to fetch clusters manifest: {}", e); - return Err(e); + Err(e) } - }; - - Ok(manifest) + } } /// Download an art image (e.g. `/versions/art/Foo.png`) from the data storage CDN and @@ -137,14 +164,7 @@ pub async fn refresh_art_cache(path: &str) { } pub async fn init_clusters() -> LauncherResult<()> { - let manifest = match fetch_json::( - Method::GET, - &format!("{}/versions/versions.json", crate::constants::META_URL_BASE), - None, - None, - ) - .await - { + let manifest = match load_versions_manifest().await { Ok(m) => m, Err(e) => { send_error!("failed to fetch clusters manifest: {}", e); @@ -154,7 +174,11 @@ pub async fn init_clusters() -> LauncherResult<()> { for cluster in manifest.clusters { for entry in cluster.entries { - let mc_version = format!("1.{}.{}", cluster.major_version, entry.minor_version); + let mc_version = if cluster.major_version >= 26 { + format!("{}.{}", cluster.major_version, entry.minor_version) + } else { + format!("1.{}.{}", cluster.major_version, entry.minor_version) + }; if let Err(e) = create_cluster_if_not_exist(&mc_version, entry.loader, None).await { send_error!("failed to create cluster for {}: {}", mc_version, e); diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index a0f4161b..d5aa1de3 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -21,6 +21,12 @@ export type ClusterStage = "notready" | "downloading" | "repairing" | "ready" export type ClusterUpdate = { name: string | null; icon_url: Icon | null; setting_profile_name: string | null } +export type CopyClusterResult = { +/** + * Files that had no compatible provider version and were copied verbatim from the source. + */ +fallback_files: string[] } + export type CreateCluster = { name: string; mc_version: string; mc_loader: GameLoader; mc_loader_version: string | null; icon: Icon | null } export type CryptoError = { type: "InvalidHash"; data: string } | { type: "InvalidAlgorithm"; data: string } @@ -129,7 +135,7 @@ export type MojangSkin = { id: string; state: string; url: string; variant: Skin export type MowojangProfile = { id: string; username: string } -export type OnlineCluster = { major_version: number; name: string; art: string; entries: OnlineClusterEntry[] } +export type OnlineCluster = { major_version: number; name: string; art: string; entries: OnlineClusterEntry[]; long_description?: string | null; tags: string[] } export type OnlineClusterEntry = { minor_version: number; name?: string | null; art?: string | null; loader: GameLoader; tags: string[] } @@ -248,9 +254,9 @@ gallery: string[] } export type SettingProfileModel = { name: string; java_id: number | null; res: Resolution | null; force_fullscreen: boolean | null; mem_max: number | null; launch_args: string | null; launch_env: string | null; hook_pre: string | null; hook_wrapper: string | null; hook_post: string | null; os_extra: SettingsOsExtra | null } -export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; seen_onboarding: boolean; mod_list_use_grid: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean; log_debug_info: boolean; show_tanstack_dev_tools: boolean; toast_position: ToastPosition; toast_duration: number; toast_auto_close: boolean } +export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; seen_onboarding: boolean; mod_list_use_grid: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean; log_debug_info: boolean; show_tanstack_dev_tools: boolean; toast_position: ToastPosition; toast_duration: number; toast_auto_close: boolean; seen_versions: string[] } -export type SettingsOsExtra = { enable_gamemode: boolean | null } +export type SettingsOsExtra = Record export type SkinVariant = "classic" | "slim" @@ -319,21 +325,8 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'events':'{"ingress":["event"],"process":["event"],"message":["event"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'debug':'{"getLocale":[],"getPlatform":[],"getOsVersion":[],"getOsDistro":[],"isInDev":[],"getArch":[],"getFullDebugInfo":[],"getFullDebugInfoParsedString":[],"getPackageVersion":[],"getBuildTimestamp":[],"getType":[],"getGitCommitHash":[],"openDevTools":[],"getFamily":[],"getFullDebugInfoParsed":[],"hasDevSettings":[]}', 'oneclient':'{"getBundlesFor":["cluster_id"],"isBundleSyncing":[],"extractBundleOverrides":["bundle_path","cluster_id"],"installUpdate":[],"getClustersGroupedByMajor":[],"updateBundlePackages":["cluster_id"],"cacheArt":["path"],"checkForUpdate":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"refreshArt":["path"],"getVersions":[]}', 'core':'{"removePackage":["cluster_id","package_hash"],"getLinkedPackages":["cluster_id"],"refreshAccount":["uuid"],"createSettingsProfile":["name"],"changeCape":["access_token","cape_uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"isClusterRunning":["cluster_id"],"convertUsernameUUID":["username_uuid"],"setDiscordRPCMessage":["message"],"removeUser":["uuid"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"syncCluster":["cluster_id"],"getRunningProcessesByClusterId":["cluster_id"],"removeCluster":["id"],"open":["input"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getWorlds":["id"],"getScreenshots":["id"],"killProcess":["pid"],"getUsers":[],"removeCape":["access_token"],"getClusters":[],"getUsersFromAuthor":["provider","author"],"installModpack":["modpack","cluster_id"],"fetchMinecraftProfile":["uuid"],"getUser":["uuid"],"getPackageBody":["provider","body"],"getLoadersForVersion":["mc_version"],"fetchLoggedInProfile":["access_token"],"getLogByName":["id","name"],"changeSkin":["access_token","skin_url","skin_variant"],"writeSettings":["setting"],"getDefaultUser":["fallback"],"setDefaultUser":["uuid"],"getLogs":["id"],"updateClusterProfile":["name","profile"],"getClusterById":["id"],"launchCluster":["id","uuid","search_for_java"],"refreshAccounts":[],"getProcessLogTail":["id","max_lines"],"getGlobalProfile":[],"getRunningProcesses":[],"openMsaLogin":[],"createCluster":["options"],"getProfileOrDefault":["name"],"getGameVersions":[],"searchPackages":["provider","query"],"updateClusterById":["id","request"],"getPackage":["provider","slug"],"togglePackage":["cluster_id","package_hash"],"getMultiplePackages":["provider","slugs"],"setClusterStage":["id","stage"],"readSettings":[],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"]}' } -export type Router = { 'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise }, -'oneclient': { getClustersGroupedByMajor: () => Promise>, -getBundlesFor: (clusterId: number) => Promise, -getVersions: () => Promise, -extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise, -checkForUpdate: () => Promise, -installUpdate: () => Promise, -downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise, -updateBundlePackages: (clusterId: number) => Promise, -isBundleSyncing: () => Promise, -cacheArt: (path: string) => Promise, -refreshArt: (path: string) => Promise }, -'debug': { openDevTools: () => Promise, +const ARGS_MAP = { 'oneclient':'{"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"getVersions":[],"isBundleSyncing":[],"getClustersGroupedByMajor":[],"extractBundleOverrides":["bundle_path","cluster_id"],"updateBundlePackages":["cluster_id"],"getBundlesFor":["cluster_id"],"cacheArt":["path"],"copyClusterContent":["source_id","target_id"],"refreshArt":["path"],"installUpdate":[],"checkForUpdate":[]}', 'core':'{"getMultiplePackages":["provider","slugs"],"getRunningProcessesByClusterId":["cluster_id"],"getGameVersions":[],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"syncCluster":["cluster_id"],"createSettingsProfile":["name"],"getUsers":[],"updateClusterById":["id","request"],"getProcessLogTail":["id","max_lines"],"killProcess":["pid"],"getDefaultUser":["fallback"],"installModpack":["modpack","cluster_id"],"readSettings":[],"changeCape":["access_token","cape_uuid"],"refreshAccounts":[],"fetchMinecraftProfile":["uuid"],"createCluster":["options"],"getGlobalProfile":[],"getPackageBody":["provider","body"],"getRunningProcesses":[],"getProfileOrDefault":["name"],"removeCape":["access_token"],"togglePackage":["cluster_id","package_hash"],"getLogByName":["id","name"],"getLogs":["id"],"removePackage":["cluster_id","package_hash"],"setDefaultUser":["uuid"],"getUsersFromAuthor":["provider","author"],"getLinkedPackages":["cluster_id"],"setClusterStage":["id","stage"],"fetchLoggedInProfile":["access_token"],"setDiscordRPCMessage":["message"],"getUser":["uuid"],"removeUser":["uuid"],"getScreenshots":["id"],"getClusters":[],"isClusterRunning":["cluster_id"],"getClusterById":["id"],"getLoadersForVersion":["mc_version"],"openMsaLogin":[],"launchCluster":["id","uuid","search_for_java"],"getWorlds":["id"],"writeSettings":["setting"],"searchPackages":["provider","query"],"getPackage":["provider","slug"],"refreshAccount":["uuid"],"convertUsernameUUID":["username_uuid"],"open":["input"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"updateClusterProfile":["name","profile"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"changeSkin":["access_token","skin_url","skin_variant"],"removeCluster":["id"]}', 'debug':'{"getPackageVersion":[],"getArch":[],"getType":[],"hasDevSettings":[],"getOsVersion":[],"getOsDistro":[],"getFullDebugInfoParsed":[],"getFamily":[],"getLocale":[],"getPlatform":[],"getGitCommitHash":[],"getFullDebugInfo":[],"isInDev":[],"openDevTools":[],"getFullDebugInfoParsedString":[],"getBuildTimestamp":[]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}' } +export type Router = { 'debug': { openDevTools: () => Promise, isInDev: () => Promise, getArch: () => Promise, getFamily: () => Promise, @@ -403,9 +396,23 @@ removeCape: (accessToken: string) => Promise, convertUsernameUUID: (usernameUuid: string) => Promise, setDiscordRPCMessage: (message: string) => Promise, open: (input: string) => Promise }, +'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise }, 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise } }; +process: (event: ProcessPayload) => Promise }, +'oneclient': { getClustersGroupedByMajor: () => Promise>, +getBundlesFor: (clusterId: number) => Promise, +getVersions: () => Promise, +extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise, +checkForUpdate: () => Promise, +installUpdate: () => Promise, +downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise, +updateBundlePackages: (clusterId: number) => Promise, +isBundleSyncing: () => Promise, +cacheArt: (path: string) => Promise, +refreshArt: (path: string) => Promise, +copyClusterContent: (sourceId: number, targetId: number) => Promise } }; export type { InferCommandOutput } diff --git a/apps/oneclient/frontend/src/components/overlay/MigrationModal.tsx b/apps/oneclient/frontend/src/components/overlay/MigrationModal.tsx new file mode 100644 index 00000000..538b3f83 --- /dev/null +++ b/apps/oneclient/frontend/src/components/overlay/MigrationModal.tsx @@ -0,0 +1,235 @@ +import type { ClusterModel } from '@/bindings.gen'; +import { Overlay } from '@/components'; +import { useCachedImage } from '@/hooks/useCachedImage'; +import { bindings } from '@/main'; +import { prettifyLoader } from '@/utils/loaders'; +import { useToast } from '@/utils/toast'; +import { getOnlineClusterForVersion } from '@/utils/versionMap'; +import { useCommand } from '@onelauncher/common'; +import { Dropdown } from '@onelauncher/common/components'; +import { Announcement01Icon } from '@untitled-theme/icons-react'; +import { useEffect, useRef, useState } from 'react'; + +interface MigrationModalProps { + isOpen: boolean; + isDebugPreview?: boolean; + onOpenChange: (open: boolean) => void; + newVersions: Array; + sourceClusters: Array; + allClusters: Array; +} + +export function MigrationModal({ + isOpen, + isDebugPreview = false, + onOpenChange, + newVersions, + sourceClusters, + allClusters, +}: MigrationModalProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const prevOpen = useRef(isOpen); + + useEffect(() => { + if (isOpen && !prevOpen.current) + setCurrentIndex(0); + prevOpen.current = isOpen; + }, [isOpen]); + + const currentVersion = newVersions[currentIndex]; + + const currentClusters = allClusters.filter(c => + c.mc_version === currentVersion && c.last_played === null); + + const defaultSourceId = sourceClusters[sourceClusters.length - 1]?.id ?? null; + + const [targetId, setTargetId] = useState(null); + const [sourceId, setSourceId] = useState(defaultSourceId); + + const effectiveTargetId = targetId ?? currentClusters.at(0)?.id ?? null; + const [isMigrating, setIsMigrating] = useState(false); + const [fallbackFiles, setFallbackFiles] = useState>([]); + + const toast = useToast(); + + const { data: manifest } = useCommand( + ['getVersions'], + () => bindings.oneclient.getVersions(), + ); + + const onlineCluster = manifest + ? getOnlineClusterForVersion(currentVersion, manifest) + : undefined; + const artSrc = useCachedImage(onlineCluster?.art); + const displayName = onlineCluster?.name ?? currentVersion; + + function advanceOrClose() { + if (currentIndex < newVersions.length - 1) { + setCurrentIndex(i => i + 1); + setTargetId(null); + setSourceId(defaultSourceId); + } + else { + onOpenChange(false); + } + } + + async function migrate() { + if (sourceId === null || effectiveTargetId === null) + return; + + if (isDebugPreview) { + toast({ type: 'info', title: 'Debug preview', message: 'Migration is disabled in debug preview mode.' }); + advanceOrClose(); + return; + } + + setIsMigrating(true); + try { + const result = await bindings.oneclient.copyClusterContent(sourceId, effectiveTargetId); + if (result.fallback_files.length > 0) + setFallbackFiles(result.fallback_files); + else + toast({ type: 'success', title: 'Migration complete', message: 'Your content has been copied to the new version.' }); + advanceOrClose(); + } + catch { + toast({ type: 'error', title: 'Migration failed', message: 'Could not copy content. Please try again.' }); + } + finally { + setIsMigrating(false); + } + } + + return ( + <> + {}}> + + {/* Art banner */} +
+ {artSrc + ? ( + {`Minecraft + ) + : ( +
+ )} +
+ +
+
+ + New version +
+

+ {currentVersion} + {' '} + is now on OneClient! +

+
+
+ + {/* Migration controls */} +
+

+ Copy your content from a previous version to get started. +

+ + {newVersions.length > 1 && ( +

+ Version + {' '} + {currentIndex + 1} + {' '} + of + {' '} + {newVersions.length} +

+ )} + + {currentClusters.length > 1 && ( +
+

Copy to

+ setTargetId(Number(key))} + selectedKey={effectiveTargetId} + > + {currentClusters.map(c => ( + + {c.mc_version} + {' '} + {prettifyLoader(c.mc_loader)} + + ))} + +
+ )} + +
+

Copy from

+ setSourceId(Number(key))} + selectedKey={sourceId} + > + {sourceClusters.map(c => ( + + {c.mc_version} + {' '} + {prettifyLoader(c.mc_loader)} + + ))} + +
+ + +
+ + + + 0} onOpenChange={() => setFallbackFiles([])}> + + Some files could not be updated + +

+ The following custom files had no compatible version available for the new Minecraft version. They have been copied over as disabled and will not load until you manually enable them. +

+ +
    + {fallbackFiles.map(file => ( +
  • + {file} +
  • + ))} +
+ + +
+
+ + ); +} diff --git a/apps/oneclient/frontend/src/hooks/useVersionMigration.ts b/apps/oneclient/frontend/src/hooks/useVersionMigration.ts new file mode 100644 index 00000000..202064a8 --- /dev/null +++ b/apps/oneclient/frontend/src/hooks/useVersionMigration.ts @@ -0,0 +1,93 @@ +import { bindings } from '@/main'; +import useMigrationStore from '@/stores/migrationStore'; +import { useCommandSuspense } from '@onelauncher/common'; +import { useEffect } from 'react'; + +const DEFAULT_SEEN_VERSIONS = ['1.21.1', '1.21.10', '1.21.11'] as const; + +function areStringArraysEqual(left: Array, right: Array): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function areClustersEqual( + left: Array<{ id: number; mc_version: string; mc_loader: string; last_played: string | null }>, + right: Array<{ id: number; mc_version: string; mc_loader: string; last_played: string | null }>, +): boolean { + return left.length === right.length + && left.every((cluster, index) => { + const other = right[index]; + return cluster.id === other.id + && cluster.mc_version === other.mc_version + && cluster.mc_loader === other.mc_loader + && cluster.last_played === other.last_played; + }); +} + +export function useVersionMigration() { + const { data: grouped } = useCommandSuspense( + ['getClustersGroupedByMajor'], + bindings.oneclient.getClustersGroupedByMajor, + ); + const { data: settingsData } = useCommandSuspense(['readSettings'], bindings.core.readSettings); + const isOpen = useMigrationStore(state => state.isOpen); + const isDebugPreview = useMigrationStore(state => state.isDebugPreview); + const newVersions = useMigrationStore(state => state.newVersions); + const sourceClusters = useMigrationStore(state => state.sourceClusters); + const allClusters = useMigrationStore(state => state.allClusters); + const setAllClusters = useMigrationStore(state => state.setAllClusters); + const setMigrationCandidates = useMigrationStore(state => state.setMigrationCandidates); + const setIsOpen = useMigrationStore(state => state.setIsOpen); + const openForDebug = useMigrationStore(state => state.openForDebug); + + useEffect(() => { + type SettingsWithSeenVersions = typeof settingsData & { + seen_versions?: Array; + }; + + const settings = settingsData as SettingsWithSeenVersions; + const storedSeenVersions = Array.isArray(settings.seen_versions) ? settings.seen_versions : []; + const seenVersions = [ + ...new Set([ + ...DEFAULT_SEEN_VERSIONS, + ...storedSeenVersions, + ]), + ]; + const nextAllClusters = Object.values(grouped) + .flat() + .filter((cluster): cluster is NonNullable => Boolean(cluster)); + const currentVersions = [...new Set(nextAllClusters.map(c => c.mc_version))]; + + const unseen = currentVersions.filter(v => !seenVersions.includes(v)); + const unseenUnplayed = unseen.filter(v => + nextAllClusters.some(c => c.mc_version === v && c.last_played === null)); + + // Clusters not belonging to any new version — candidates to copy from + const sources = nextAllClusters.filter(c => !unseenUnplayed.includes(c.mc_version)); + const migrationState = useMigrationStore.getState(); + + if (!areClustersEqual(migrationState.allClusters, nextAllClusters)) + setAllClusters(nextAllClusters); + + if ( + !areStringArraysEqual(migrationState.newVersions, unseenUnplayed) + || !areClustersEqual(migrationState.sourceClusters, sources) + ) + setMigrationCandidates(unseenUnplayed, sources); + + if (unseenUnplayed.length > 0 && sources.length > 0 && !migrationState.isOpen) + setIsOpen(true); + + const nextSeenVersions = [...new Set([...currentVersions, ...DEFAULT_SEEN_VERSIONS])].sort(); + const normalizedStoredSeenVersions = [...storedSeenVersions].sort(); + const hasSeenVersionsChanged + = !areStringArraysEqual(nextSeenVersions, normalizedStoredSeenVersions); + + if (hasSeenVersionsChanged) + void bindings.core.writeSettings({ + ...settings, + seen_versions: nextSeenVersions, + } as typeof settingsData); + }, [grouped, setAllClusters, setIsOpen, setMigrationCandidates, settingsData]); + + return { isOpen, isDebugPreview, setIsOpen, newVersions, sourceClusters, allClusters, openForDebug }; +} diff --git a/apps/oneclient/frontend/src/routes/__root.tsx b/apps/oneclient/frontend/src/routes/__root.tsx index 68571542..e8b8cefa 100644 --- a/apps/oneclient/frontend/src/routes/__root.tsx +++ b/apps/oneclient/frontend/src/routes/__root.tsx @@ -1,10 +1,12 @@ import type { QueryClient } from '@tanstack/react-query'; import type { NavigateOptions, ToOptions } from '@tanstack/react-router'; import { Toasts } from '@/components'; +import { MigrationModal } from '@/components/overlay/MigrationModal'; import { useDebugKeybind } from '@/hooks/useDebugInfo'; import { useDiscordRPC } from '@/hooks/useDiscordRPC'; import { useSettings } from '@/hooks/useSettings'; import { useAutoUpdater } from '@/hooks/useUpdater'; +import { useVersionMigration } from '@/hooks/useVersionMigration'; import { TanStackDevtools } from '@tanstack/react-devtools'; import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'; import { createRootRouteWithContext, Outlet, useRouter } from '@tanstack/react-router'; @@ -32,6 +34,9 @@ function RootRoute() { useDiscordRPC(); useAutoUpdater(); useDebugKeybind(); + + const migration = useVersionMigration(); + return ( router.navigate({ to, ...options })} @@ -44,6 +49,17 @@ function RootRoute() {
+ + {setting('seen_onboarding') && ( + + )} ); } diff --git a/apps/oneclient/frontend/src/routes/app/cluster/route.tsx b/apps/oneclient/frontend/src/routes/app/cluster/route.tsx index 198ac83c..02148b23 100644 --- a/apps/oneclient/frontend/src/routes/app/cluster/route.tsx +++ b/apps/oneclient/frontend/src/routes/app/cluster/route.tsx @@ -3,6 +3,7 @@ import { useIsRunning } from '@/hooks/useClusters'; import { bindings } from '@/main'; import { prettifyLoader } from '@/utils/loaders'; import { getVersionInfoOrDefault } from '@/utils/versionMap'; +import { useCommandSuspense } from '@onelauncher/common'; import { Button } from '@onelauncher/common/components'; import { useQueryClient } from '@tanstack/react-query'; import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; @@ -100,7 +101,8 @@ function RouteComponent() { function HeaderLarge() { const { cluster } = Route.useRouteContext(); - const versionInfo = getVersionInfoOrDefault(cluster.mc_version); + const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions()); + const versionInfo = getVersionInfoOrDefault(cluster.mc_version, versions); const openFolder = () => bindings.folders.openCluster(cluster.folder_name); diff --git a/apps/oneclient/frontend/src/routes/app/clusters.tsx b/apps/oneclient/frontend/src/routes/app/clusters.tsx index ca1e4ccf..c9b39045 100644 --- a/apps/oneclient/frontend/src/routes/app/clusters.tsx +++ b/apps/oneclient/frontend/src/routes/app/clusters.tsx @@ -1,10 +1,10 @@ -import type { GameLoader } from '@/bindings.gen'; +import type { GameLoader, OnlineClusterManifest } from '@/bindings.gen'; import { GameBackground, LaunchButton, SheetPage } from '@/components'; import { useCachedImage } from '@/hooks/useCachedImage'; import { bindings } from '@/main'; import useClusterStore from '@/stores/clusterStore'; import { prettifyLoader } from '@/utils/loaders'; -import { getVersionInfo, getVersionInfoOrDefault, parseMcVersion } from '@/utils/versionMap'; +import { formatMcVersion, getVersionInfoOrDefault, parseMcVersion } from '@/utils/versionMap'; import { useCommandSuspense } from '@onelauncher/common'; import { Button, Dropdown } from '@onelauncher/common/components'; import { createFileRoute } from '@tanstack/react-router'; @@ -120,7 +120,7 @@ function RouteComponent() { const selectedMinorArtSrc = useCachedImage(selectedMinorArtPath); - const versionInfo = useMemo(() => getVersionInfoOrDefault(cluster?.mc_version), [cluster]); + const versionInfo = useMemo(() => getVersionInfoOrDefault(cluster?.mc_version, versions), [cluster, versions]); const view = useCallback(() => { if (!cluster) @@ -171,6 +171,7 @@ function RouteComponent() { major_version={major} onClick={() => setMajorVersion(major)} tags={[...new Set(clusters[major]?.flatMap(c => prettifyLoader(c.mc_loader)))]} + versions={versions} /> ); })} @@ -207,7 +208,7 @@ function RouteComponent() { selectedKey={effectiveMinorVersion} > {minorVersions.map((minorVersion) => { - const fullVer = `${versionInfo.prettyName}.${minorVersion}`; + const fullVer = formatMcVersion(majorVersion, minorVersion); return ( unknown; tags: Array; + versions: OnlineClusterManifest; }) { const artSrc = useCachedImage(artPath); - const info = getVersionInfo(major_version); - - if (!info) - return undefined; + const info = getVersionInfoOrDefault(major_version, versions); return (
diff --git a/apps/oneclient/frontend/src/routes/app/debug.tsx b/apps/oneclient/frontend/src/routes/app/debug.tsx index 904a32b9..ccea9746 100644 --- a/apps/oneclient/frontend/src/routes/app/debug.tsx +++ b/apps/oneclient/frontend/src/routes/app/debug.tsx @@ -3,6 +3,7 @@ import { MinecraftAuthErrorModal, minecraftAuthErrors, Overlay, RawDebugInfo, Se import { useDebugInfo } from '@/hooks/useDebugInfo'; import { useSettings } from '@/hooks/useSettings'; import { bindings } from '@/main'; +import useMigrationStore from '@/stores/migrationStore'; import { TitleCase } from '@/utils/string'; import { ToastPositions, ToastTypes, useToast } from '@/utils/toast'; import { Button, Dropdown, TextField } from '@onelauncher/common/components'; @@ -168,6 +169,7 @@ function Settings() { function Other() { const [launcherDir, setLauncherDir] = useState(''); + const openMigrationGui = useMigrationStore(state => state.openForDebug); useEffect(() => { (async () => { setLauncherDir(await join(await dataDir(), 'OneClient')); @@ -189,6 +191,7 @@ function Other() {

Other

+
diff --git a/apps/oneclient/frontend/src/routes/app/index.tsx b/apps/oneclient/frontend/src/routes/app/index.tsx index 9d19cb93..a18805a7 100644 --- a/apps/oneclient/frontend/src/routes/app/index.tsx +++ b/apps/oneclient/frontend/src/routes/app/index.tsx @@ -34,12 +34,24 @@ function RouteComponent() { from: Route.id, }); + const BOOST_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + const now = Date.now(); + const displayClusters = [...lastPlayedClusters].sort((a, b) => { + const aIsNew = a.last_played === null && (now - new Date(a.created_at).getTime()) < BOOST_WINDOW_MS; + const bIsNew = b.last_played === null && (now - new Date(b.created_at).getTime()) < BOOST_WINDOW_MS; + if (aIsNew && !bIsNew) + return -1; + if (bIsNew && !aIsNew) + return 1; + return 0; + }).slice(0, 3); + return (
- {lastPlayedClusters.slice(0, 3).map(cluster => ( + {displayClusters.map(cluster => ( bindings.oneclient.getVersions()); + const versionInfo = getVersionInfoOrDefault(cluster.mc_version, versions); const entry = getOnlineEntryForVersion(cluster.mc_version, versions); const onlineCluster = getOnlineClusterForVersion(cluster.mc_version, versions); diff --git a/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx b/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx index aeab6859..8c9dd828 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx @@ -1,9 +1,8 @@ import type { GameLoader, OnlineCluster, OnlineClusterEntry } from '@/bindings.gen'; -import type { VersionInfo } from '@/utils/versionMap'; import { useCachedImage } from '@/hooks/useCachedImage'; import { bindings } from '@/main'; import { OnboardingNavigation } from '@/routes/onboarding/route'; -import { getVersionInfoOrDefault } from '@/utils/versionMap'; +import { formatMcVersion } from '@/utils/versionMap'; import { useCommandSuspense } from '@onelauncher/common'; import { createFileRoute } from '@tanstack/react-router'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; @@ -40,15 +39,13 @@ function RouteComponent() {
{versions.clusters.map((cluster) => { - const versionData = getVersionInfoOrDefault(cluster.major_version); return cluster.entries.map(entry => ( )); })} @@ -64,7 +61,7 @@ function RouteComponent() { ); } -function VersionCard({ cluster, versionData: _versionData, version, fullVersionName, setSelectedClusters }: { cluster: OnlineCluster; versionData: VersionInfo; version: OnlineClusterEntry; fullVersionName: string; setSelectedClusters: React.Dispatch>> }) { +function VersionCard({ cluster, version, fullVersionName, setSelectedClusters }: { cluster: OnlineCluster; version: OnlineClusterEntry; fullVersionName: string; setSelectedClusters: React.Dispatch>> }) { const [isSelected, setSelected] = useState(false); const toggle = () => { setSelected(prev => !prev); diff --git a/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx b/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx index 4c7b4a73..46895f28 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx @@ -5,6 +5,7 @@ import { BundleModListModal, DownloadMods, ModCardContext, Overlay } from '@/com import { useCachedImage } from '@/hooks/useCachedImage'; import { bindings } from '@/main'; import { OnboardingNavigation } from '@/routes/onboarding/route'; +import { getOnlineClusterForVersion, parseMcVersion } from '@/utils/versionMap'; import { useCommandSuspense } from '@onelauncher/common'; import { Button } from '@onelauncher/common/components'; import { createFileRoute } from '@tanstack/react-router'; @@ -41,10 +42,10 @@ function RouteComponent() { const selected = selectedClusters.some(selectedCluster => selectedCluster.mc_version === cluster.mc_version && selectedCluster.mc_loader === cluster.mc_loader); if (!selected) return; - const onlineCluster = versions.clusters.find(c => cluster.mc_version.startsWith(`1.${c.major_version}`)); - const minorVersion = Number.parseInt(cluster.mc_version.split('.')[2] ?? '', 10); - const onlineEntry = !Number.isNaN(minorVersion) - ? onlineCluster?.entries.find(e => e.minor_version === minorVersion) + const onlineCluster = getOnlineClusterForVersion(cluster.mc_version, versions); + const parsedVersion = parseMcVersion(cluster.mc_version); + const onlineEntry = parsedVersion?.minor !== undefined + ? onlineCluster?.entries.find(e => e.minor_version === parsedVersion.minor) : undefined; bundlesData[cluster.name] = { bundles: bundleQueries[i], diff --git a/apps/oneclient/frontend/src/stores/migrationStore.ts b/apps/oneclient/frontend/src/stores/migrationStore.ts new file mode 100644 index 00000000..6abfe966 --- /dev/null +++ b/apps/oneclient/frontend/src/stores/migrationStore.ts @@ -0,0 +1,55 @@ +import type { ClusterModel } from '@/bindings.gen'; +import { create } from 'zustand'; + +export interface MigrationStore { + isOpen: boolean; + isDebugPreview: boolean; + newVersions: Array; + sourceClusters: Array; + allClusters: Array; +} + +export interface MigrationStoreActions { + setAllClusters: (clusters: Array) => void; + setMigrationCandidates: (newVersions: Array, sourceClusters: Array) => void; + setIsOpen: (open: boolean) => void; + openForDebug: () => void; +} + +function getUniqueVersions(clusters: Array): Array { + return [...new Set(clusters.map(cluster => cluster.mc_version))]; +} + +export const useMigrationStore = create()(set => ({ + isOpen: false, + isDebugPreview: false, + newVersions: [], + sourceClusters: [], + allClusters: [], + + setAllClusters: clusters => set({ allClusters: clusters }), + setMigrationCandidates: (newVersions, sourceClusters) => set({ newVersions, sourceClusters, isDebugPreview: false }), + setIsOpen: open => set(state => ({ + isOpen: open, + isDebugPreview: open ? state.isDebugPreview : false, + })), + openForDebug: () => set((state) => { + if (state.allClusters.length === 0) + return state; + + const unplayedClusters = state.allClusters.filter(cluster => cluster.last_played === null); + const unplayedVersions = getUniqueVersions(unplayedClusters); + const allVersions = getUniqueVersions(state.allClusters); + const targetVersions = unplayedVersions.length > 0 ? unplayedVersions : allVersions; + const sourceClusters = state.allClusters.filter(cluster => !targetVersions.includes(cluster.mc_version)); + + return { + isOpen: true, + isDebugPreview: true, + newVersions: targetVersions, + sourceClusters: sourceClusters.length > 0 ? sourceClusters : state.allClusters, + }; + }), +})); + +export default useMigrationStore; diff --git a/apps/oneclient/frontend/src/utils/versionMap.ts b/apps/oneclient/frontend/src/utils/versionMap.ts index be3dbd6d..1cff5e5a 100644 --- a/apps/oneclient/frontend/src/utils/versionMap.ts +++ b/apps/oneclient/frontend/src/utils/versionMap.ts @@ -9,60 +9,54 @@ export interface VersionInfo { tags: Array; } -const VERSION_MAP: Record = { - [-1]: { - backgroundName: 'HypixelSkyblockHub', - shortDescription: 'Unknown Version', - longDescription: 'This version is not recognized or does not have specific information available.', - prettyName: '0.0', - tags: [], - }, - 8: { - backgroundName: 'HypixelSkyblockHub', - prettyName: '1.8', - shortDescription: 'The Bountiful Update', - longDescription: 'The Bountiful Update is a major update that introduces new features, items, and gameplay mechanics to enhance the Minecraft experience. It focuses on expanding the game world and providing players with new challenges and adventures.', - tags: ['Legacy', 'Bountiful Update'], - }, - 21: { - backgroundName: 'CavesAndCliffs', - prettyName: '1.21', - shortDescription: 'The Tricky Trials Update', - longDescription: `Minecraft's 1.21 update, known as "Tricky Trials," primarily focuses on combat adventures and tinkering, introducing trial chambers, new copper block variants, a new crafting tool, and a new weapon. It also features new hostile mobs, paintings, and gameplay enhancements.`, - tags: ['Tricky Trials'], - }, -} as const; +const DEFAULT_VERSION_INFO: VersionInfo = { + backgroundName: 'MinecraftBuilding', + prettyName: '?', + shortDescription: 'Unknown Version', + longDescription: 'This version is not recognized or does not have specific information available.', + tags: [], +}; -export function getVersionInfo(version: number | string | null | undefined): VersionInfo | undefined { - if (version === undefined || version === null) - return undefined; - - let majorVersion: number | undefined; - - if (typeof version === 'string') - majorVersion = parseMcVersion(version)?.major; - else if (typeof version === 'number') - majorVersion = version; - - if (majorVersion) - return VERSION_MAP[majorVersion]; - else - return undefined; +export function getVersionInfoFromCluster(cluster: OnlineCluster): VersionInfo { + const { major_version } = cluster; + return { + backgroundName: 'MinecraftBuilding', + prettyName: major_version >= 26 ? `${major_version}` : `1.${major_version}`, + shortDescription: cluster.name, + longDescription: cluster.long_description ?? '', + tags: cluster.tags, + }; } -export function getVersionInfoOrDefault(version: number | string | null | undefined): VersionInfo { - const info = getVersionInfo(version); - return info ?? VERSION_MAP[-1]; +export function getVersionInfo( + version: number | string | null | undefined, + manifest: OnlineClusterManifest, +): VersionInfo | undefined { + const cluster = getOnlineClusterForVersion(version, manifest); + if (!cluster) + return undefined; + return getVersionInfoFromCluster(cluster); +} + +export function getVersionInfoOrDefault( + version: number | string | null | undefined, + manifest: OnlineClusterManifest, +): VersionInfo { + return getVersionInfo(version, manifest) ?? DEFAULT_VERSION_INFO; } export function getOnlineClusterForVersion( - version: string | null | undefined, + version: number | string | null | undefined, versions: OnlineClusterManifest, ): OnlineCluster | undefined { - const parsed = parseMcVersion(version); - if (!parsed) + let major: number | undefined; + if (typeof version === 'string') + major = parseMcVersion(version)?.major; + else if (typeof version === 'number') + major = version; + if (major === undefined) return undefined; - return versions.clusters.find(c => c.major_version === parsed.major); + return versions.clusters.find(c => c.major_version === major); } export function getOnlineEntryForVersion( @@ -88,14 +82,38 @@ export function parseMcVersion(version: string | null | undefined): ParsedMcVers return undefined; const parts = version.split('.'); - if (parts.length <= 1) // we need a.b.c where c is optional + + // New format: YY.N[.P] (e.g. "26.1" or "26.1.1") — no "1." prefix + if (parts[0] !== '1') { + if (parts.length < 2) + return undefined; + const major = Number.parseInt(parts[0], 10); + const minor = Number.parseInt(parts[1], 10); + return { + major, + minor: Number.isNaN(minor) ? undefined : minor, + }; + } + + // Old format: 1.X[.Y] (e.g. "1.21.5") + if (parts.length <= 1) return undefined; const major = Number.parseInt(parts[1], 10); - const minor = parts.length > 1 ? Number.parseInt(parts[2], 10) : undefined; + const minor = parts.length > 2 ? Number.parseInt(parts[2], 10) : undefined; return { major, minor: Number.isNaN(minor) ? undefined : minor, }; } + +/** + * Constructs a Minecraft version string from a major+minor pair. + * Versions from 2026 onward use the new YY.N format; older versions use 1.X.Y. + */ +export function formatMcVersion(major: number, minor: number): string { + if (major >= 26) + return `${major}.${minor}`; + return `1.${major}.${minor}`; +} diff --git a/packages/core/src/api/cluster/dao.rs b/packages/core/src/api/cluster/dao.rs index b248bca2..35f3f37a 100644 --- a/packages/core/src/api/cluster/dao.rs +++ b/packages/core/src/api/cluster/dao.rs @@ -1,5 +1,6 @@ use std::path::Path; +use chrono::Utc; use onelauncher_entity::icon::Icon; use onelauncher_entity::loader::GameLoader; use onelauncher_entity::{cluster_groups, clusters}; @@ -32,6 +33,7 @@ pub async fn insert_cluster( mc_loader: Set(mc_loader), mc_loader_version: Set(mc_loader_version.map(String::from)), icon_url: Set(icon_url), + created_at: Set(Utc::now()), ..Default::default() }; @@ -88,6 +90,16 @@ pub async fn delete_cluster_by_id(id: ClusterId) -> LauncherResult<()> { Ok(()) } +/// Stamps `created_at` to the current UTC time for the given cluster. +pub async fn touch_cluster_created_at(id: ClusterId) -> LauncherResult<()> { + update_cluster_by_id(id, |mut active: clusters::ActiveModel| async move { + active.created_at = Set(Utc::now()); + Ok(active) + }) + .await?; + Ok(()) +} + /// Gets a cluster by its ID from the database. pub async fn get_cluster_by_id(id: ClusterId) -> LauncherResult> { let state = State::get().await?; diff --git a/packages/core/src/store/settings.rs b/packages/core/src/store/settings.rs index f3625423..02965a17 100644 --- a/packages/core/src/store/settings.rs +++ b/packages/core/src/store/settings.rs @@ -41,6 +41,8 @@ pub struct Settings { pub toast_position: ToastPosition, pub toast_duration: u32, pub toast_auto_close: bool, + + pub seen_versions: Vec, } impl Default for Settings { @@ -64,6 +66,8 @@ impl Default for Settings { toast_position: ToastPosition::BottomRight, toast_duration: 5000, toast_auto_close: true, + + seen_versions: Vec::new(), } } }