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:
parent
247d23083d
commit
5cac13a54c
18 changed files with 1065 additions and 117 deletions
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
93
apps/oneclient/frontend/src/hooks/useVersionMigration.ts
Normal file
93
apps/oneclient/frontend/src/hooks/useVersionMigration.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
55
apps/oneclient/frontend/src/stores/migrationStore.ts
Normal file
55
apps/oneclient/frontend/src/stores/migrationStore.ts
Normal 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;
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue