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.
This commit is contained in:
Wyvest 2026-03-30 00:28:14 -04:00
parent 247d23083d
commit 5cac13a54c
No known key found for this signature in database
18 changed files with 1065 additions and 117 deletions

View file

@ -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<CopyClusterResult>;
}
#[taurpc::ipc_type]
pub struct CopyClusterResult {
/// Files that had no compatible provider version and were copied verbatim from the source.
pub fallback_files: Vec<String>,
}
#[taurpc::ipc_type]
@ -74,16 +112,21 @@ impl OneClientApi for OneClientApiImpl {
let mut mapped: HashMap<u32, Vec<clusters::Model>> = 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<CopyClusterResult> {
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<String> =
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(),
})
}
}

View file

@ -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<String, (String, ModpackFile)>,
hidden_dependency_keys: &HashSet<String>,
@ -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<Vec<String>> {
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<String> = 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<String, String> = 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<String> = 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};

View file

@ -57,6 +57,10 @@ pub struct OnlineCluster {
name: String,
art: String,
entries: Vec<OnlineClusterEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
long_description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tags: Vec<String>,
}
#[derive(specta::Type, Deserialize, Serialize)]
@ -70,23 +74,46 @@ pub struct OnlineClusterEntry {
tags: Vec<String>,
}
pub async fn get_data_storage_versions() -> LauncherResult<OnlineClusterManifest> {
let manifest = match fetch_json::<OnlineClusterManifest>(
/// 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<OnlineClusterManifest> {
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::<OnlineClusterManifest>(
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<OnlineClusterManifest> {
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::<OnlineClusterManifest>(
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);

View file

@ -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<string, never>
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<string>,
openCluster: (folderName: string) => Promise<null> },
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
getVersions: () => Promise<OnlineClusterManifest>,
extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise<null>,
checkForUpdate: () => Promise<Update | null>,
installUpdate: () => Promise<null>,
downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise<PackageModel>,
updateBundlePackages: (clusterId: number) => Promise<ApplyBundleUpdatesResult>,
isBundleSyncing: () => Promise<boolean>,
cacheArt: (path: string) => Promise<string>,
refreshArt: (path: string) => Promise<null> },
'debug': { openDevTools: () => Promise<void>,
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<void>,
isInDev: () => Promise<boolean>,
getArch: () => Promise<string>,
getFamily: () => Promise<string>,
@ -403,9 +396,23 @@ removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
setDiscordRPCMessage: (message: string) => Promise<null>,
open: (input: string) => Promise<null> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> } };
process: (event: ProcessPayload) => Promise<void> },
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
getVersions: () => Promise<OnlineClusterManifest>,
extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise<null>,
checkForUpdate: () => Promise<Update | null>,
installUpdate: () => Promise<null>,
downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise<PackageModel>,
updateBundlePackages: (clusterId: number) => Promise<ApplyBundleUpdatesResult>,
isBundleSyncing: () => Promise<boolean>,
cacheArt: (path: string) => Promise<string>,
refreshArt: (path: string) => Promise<null>,
copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterResult> } };
export type { InferCommandOutput }

View file

@ -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<string>;
sourceClusters: Array<ClusterModel>;
allClusters: Array<ClusterModel>;
}
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<number | null>(null);
const [sourceId, setSourceId] = useState<number | null>(defaultSourceId);
const effectiveTargetId = targetId ?? currentClusters.at(0)?.id ?? null;
const [isMigrating, setIsMigrating] = useState(false);
const [fallbackFiles, setFallbackFiles] = useState<Array<string>>([]);
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 (
<>
<Overlay isDismissable={false} isOpen={isOpen} onOpenChange={() => {}}>
<Overlay.Dialog className="p-0 overflow-hidden w-[56rem] max-w-[95vw] min-h-[28rem] flex-row items-stretch gap-0">
{/* Art banner */}
<div className="relative w-[46%] min-h-[28rem] shrink-0">
{artSrc
? (
<img
alt={`Minecraft ${displayName}`}
className="w-full h-full object-cover"
src={artSrc}
/>
)
: (
<div className="w-full h-full bg-gradient-to-br from-brand/20 to-page" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-page-elevated via-page-elevated/30 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-4">
<div className="inline-flex items-center gap-1.5 bg-brand text-white text-xs font-semibold px-2.5 py-1 rounded-full mb-2">
<Announcement01Icon className="size-3" />
New version
</div>
<h2 className="text-2xl font-bold text-white leading-tight">
{currentVersion}
{' '}
is now on OneClient!
</h2>
</div>
</div>
{/* Migration controls */}
<div className="flex flex-col gap-4 p-6 w-[54%] justify-center">
<p className="text-fg-secondary text-sm text-center">
Copy your content from a previous version to get started.
</p>
{newVersions.length > 1 && (
<p className="text-xs text-fg-secondary text-center">
Version
{' '}
{currentIndex + 1}
{' '}
of
{' '}
{newVersions.length}
</p>
)}
{currentClusters.length > 1 && (
<div className="flex flex-col w-full gap-1">
<p className="text-sm font-medium">Copy to</p>
<Dropdown
aria-label="Target version"
onSelectionChange={key => setTargetId(Number(key))}
selectedKey={effectiveTargetId}
>
{currentClusters.map(c => (
<Dropdown.Item id={c.id} key={c.id} textValue={`${c.mc_version} ${prettifyLoader(c.mc_loader)}`}>
{c.mc_version}
{' '}
{prettifyLoader(c.mc_loader)}
</Dropdown.Item>
))}
</Dropdown>
</div>
)}
<div className="flex flex-col w-full gap-1">
<p className="text-sm font-medium">Copy from</p>
<Dropdown
aria-label="Source version"
onSelectionChange={key => setSourceId(Number(key))}
selectedKey={sourceId}
>
{sourceClusters.map(c => (
<Dropdown.Item id={c.id} key={c.id} textValue={`${c.mc_version} ${prettifyLoader(c.mc_loader)}`}>
{c.mc_version}
{' '}
{prettifyLoader(c.mc_loader)}
</Dropdown.Item>
))}
</Dropdown>
</div>
<Overlay.Buttons
buttons={[
{
key: 'migrate',
children: isMigrating ? 'Migrating...' : 'Migrate',
color: 'primary',
isDisabled: sourceId === null || currentClusters.length === 0 || isMigrating,
onPress: migrate,
},
]}
/>
</div>
</Overlay.Dialog>
</Overlay>
<Overlay isOpen={fallbackFiles.length > 0} onOpenChange={() => setFallbackFiles([])}>
<Overlay.Dialog isDismissable>
<Overlay.Title>Some files could not be updated</Overlay.Title>
<p className="text-fg-secondary text-sm text-center">
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.
</p>
<ul className="w-full flex flex-col gap-1 max-h-48 overflow-y-auto">
{fallbackFiles.map(file => (
<li className="text-sm text-fg-primary bg-component-bg rounded px-2 py-1 font-mono break-all" key={file}>
{file}
</li>
))}
</ul>
<Overlay.Buttons
buttons={[
{
key: 'ok',
children: 'OK',
color: 'primary',
slot: 'close',
},
]}
/>
</Overlay.Dialog>
</Overlay>
</>
);
}

View file

@ -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<string>, right: Array<string>): 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<string>;
};
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<typeof cluster> => 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 };
}

View file

@ -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 (
<RouterProvider
navigate={(to, options) => router.navigate({ to, ...options })}
@ -44,6 +49,17 @@ function RootRoute() {
</div>
<Toasts />
{setting('seen_onboarding') && (
<MigrationModal
allClusters={migration.allClusters}
isDebugPreview={migration.isDebugPreview}
isOpen={migration.isOpen}
newVersions={migration.newVersions}
onOpenChange={migration.setIsOpen}
sourceClusters={migration.sourceClusters}
/>
)}
</RouterProvider>
);
}

View file

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

View file

@ -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 (
<Dropdown.Item
id={minorVersion}
@ -291,18 +292,17 @@ function ClusterEntry({
major_version,
onClick,
tags,
versions,
}: {
artPath: string | null | undefined;
isSelected: boolean;
major_version: number;
onClick: () => unknown;
tags: Array<string>;
versions: OnlineClusterManifest;
}) {
const artSrc = useCachedImage(artPath);
const info = getVersionInfo(major_version);
if (!info)
return undefined;
const info = getVersionInfoOrDefault(major_version, versions);
return (
<div className={twMerge('flex flex-col justify-between relative aspect-video transition-[filter] px-4', !isSelected && 'brightness-70 grayscale-25 hover:brightness-80 hover:grayscale-0')} key={major_version} onClick={onClick}>

View file

@ -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() {
<h1 className="text-3xl font-semibold">Other</h1>
<div className="flex flex-row gap-4">
<Button onPress={bindings.debug.openDevTools} size="normal">Open Dev Tools</Button>
<Button onPress={openMigrationGui} size="normal">Open Migration GUI</Button>
<Button onClick={openLauncherDir} size="normal">Open Launcher Data</Button>
<Button onClick={logRunningProcesses} size="normal">Log Running Processes to frontend</Button>
</div>

View file

@ -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 (
<div className="flex h-full w-full flex-col justify-center p-12">
<ActiveClusterInfo cluster={activeCluster} versions={versions} />
<motion.div {...animations.slideInUp} className="flex flex-row transition-[height] h-52 gap-6">
{lastPlayedClusters.slice(0, 3).map(cluster => (
{displayClusters.map(cluster => (
<RecentsCard
active={activeCluster.id === cluster.id}
key={cluster.folder_name}
@ -63,7 +75,7 @@ function RouteComponent() {
}
function ActiveClusterInfo({ cluster, versions }: { cluster: ClusterModel; versions: OnlineClusterManifest }) {
const versionInfo = getVersionInfoOrDefault(cluster.mc_version);
const versionInfo = getVersionInfoOrDefault(cluster.mc_version, versions);
const entry = getOnlineEntryForVersion(cluster.mc_version, versions);
const navigate = useNavigate({ from: Route.id });
@ -121,7 +133,7 @@ function RecentsCard({
active,
versions,
}: RecentsCardProps) {
const versionInfo = getVersionInfo(version);
const versionInfo = getVersionInfo(version, versions);
const entry = getOnlineEntryForVersion(version, versions);
const onlineCluster = getOnlineClusterForVersion(version, versions);

View file

@ -58,8 +58,8 @@ function AppShell({
function BackgroundGradient() {
const cluster = useActiveCluster();
const versionInfo = getVersionInfoOrDefault(cluster.mc_version);
const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions());
const versionInfo = getVersionInfoOrDefault(cluster.mc_version, versions);
const entry = getOnlineEntryForVersion(cluster.mc_version, versions);
const onlineCluster = getOnlineClusterForVersion(cluster.mc_version, versions);

View file

@ -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() {
<div className="bg-page-elevated p-4 rounded-xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
{versions.clusters.map((cluster) => {
const versionData = getVersionInfoOrDefault(cluster.major_version);
return cluster.entries.map(entry => (
<VersionCard
cluster={cluster}
fullVersionName={`${versionData.prettyName}.${entry.minor_version}`}
key={`${versionData.prettyName}.${entry.minor_version}-${entry.loader}`}
fullVersionName={formatMcVersion(cluster.major_version, entry.minor_version)}
key={`${formatMcVersion(cluster.major_version, entry.minor_version)}-${entry.loader}`}
setSelectedClusters={setSelectedClusters}
version={entry}
versionData={versionData}
/>
));
})}
@ -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<React.SetStateAction<Array<StrippedCluster>>> }) {
function VersionCard({ cluster, version, fullVersionName, setSelectedClusters }: { cluster: OnlineCluster; version: OnlineClusterEntry; fullVersionName: string; setSelectedClusters: React.Dispatch<React.SetStateAction<Array<StrippedCluster>>> }) {
const [isSelected, setSelected] = useState<boolean>(false);
const toggle = () => {
setSelected(prev => !prev);

View file

@ -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],

View file

@ -0,0 +1,55 @@
import type { ClusterModel } from '@/bindings.gen';
import { create } from 'zustand';
export interface MigrationStore {
isOpen: boolean;
isDebugPreview: boolean;
newVersions: Array<string>;
sourceClusters: Array<ClusterModel>;
allClusters: Array<ClusterModel>;
}
export interface MigrationStoreActions {
setAllClusters: (clusters: Array<ClusterModel>) => void;
setMigrationCandidates: (newVersions: Array<string>, sourceClusters: Array<ClusterModel>) => void;
setIsOpen: (open: boolean) => void;
openForDebug: () => void;
}
function getUniqueVersions(clusters: Array<ClusterModel>): Array<string> {
return [...new Set(clusters.map(cluster => cluster.mc_version))];
}
export const useMigrationStore = create<MigrationStore & MigrationStoreActions>()(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;

View file

@ -9,60 +9,54 @@ export interface VersionInfo {
tags: Array<string>;
}
const VERSION_MAP: Record<number, VersionInfo> = {
[-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}`;
}

View file

@ -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<Option<clusters::Model>> {
let state = State::get().await?;

View file

@ -41,6 +41,8 @@ pub struct Settings {
pub toast_position: ToastPosition,
pub toast_duration: u32,
pub toast_auto_close: bool,
pub seen_versions: Vec<String>,
}
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(),
}
}
}