From 93fef599ae44960be508b080695dc720380d6e27 Mon Sep 17 00:00:00 2001 From: Wyvest Date: Tue, 31 Mar 2026 00:41:24 -0400 Subject: [PATCH] add fabric loader updating --- .../desktop/src/oneclient/bundle_updates.rs | 251 +++++++++++++++++- .../src/oneclient/bundle_updates_test.rs | 18 ++ apps/oneclient/frontend/src/bindings.gen.ts | 32 +-- packages/core/src/api/cluster/dao.rs | 17 ++ packages/core/src/api/game/launch.rs | 114 +++++++- packages/core/src/api/game/metadata.rs | 88 ++++-- packages/core/src/api/packages/mod.rs | 95 +++++-- packages/core/src/api/tauri/commands.rs | 18 +- packages/core/src/store/ingress.rs | 13 + packages/core/src/store/metadata.rs | 2 + 10 files changed, 575 insertions(+), 73 deletions(-) diff --git a/apps/oneclient/desktop/src/oneclient/bundle_updates.rs b/apps/oneclient/desktop/src/oneclient/bundle_updates.rs index 35ac61d8..a5f1f104 100644 --- a/apps/oneclient/desktop/src/oneclient/bundle_updates.rs +++ b/apps/oneclient/desktop/src/oneclient/bundle_updates.rs @@ -1,3 +1,4 @@ +use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex, OnceLock}; @@ -85,6 +86,219 @@ fn infer_bundle_names_from_unique_installed_keys( .collect() } +fn collect_all_installed_managed_keys( + all_linked_packages: &[onelauncher_core::entity::packages::Model], +) -> HashSet { + all_linked_packages + .iter() + .filter_map(|pkg| { + if pkg.provider == Provider::Local && pkg.package_id == pkg.hash { + None + } else { + Some(managed_bundle_key(pkg.provider, &pkg.package_id)) + } + }) + .collect() +} + +fn collect_inferable_managed_keys_for_bundle(bundle: &ModpackArchive) -> HashSet { + let mut visible_managed_keys: HashSet = HashSet::new(); + let mut hidden_dependency_keys: HashSet = HashSet::new(); + let mut hidden_explicit_keys: HashSet = HashSet::new(); + + for file in &bundle.manifest.files { + if !file.enabled { + continue; + } + + if let ModpackFileKind::Managed(box_) = &file.kind { + let (pkg, version) = &**box_; + let key = managed_bundle_key(pkg.provider, &pkg.id); + visible_managed_keys.insert(key.clone()); + + if file.hidden { + hidden_explicit_keys.insert(key); + } + + for dep in &version.dependencies { + if let Some(dep_project_id) = &dep.project_id { + hidden_dependency_keys.insert(managed_bundle_key(pkg.provider, dep_project_id)); + } + } + } + } + + for visible_key in &visible_managed_keys { + hidden_dependency_keys.remove(visible_key); + } + + visible_managed_keys + .into_iter() + .filter(|key| !hidden_dependency_keys.contains(key) && !hidden_explicit_keys.contains(key)) + .collect() +} + +fn infer_subscribed_bundles_for_loader_sync( + bundle_packages: &[cluster_packages::Model], + bundles: &[ModpackArchive], + all_installed_managed_keys: &HashSet, +) -> HashSet { + let mut subscribed_bundles: HashSet = bundle_packages + .iter() + .filter_map(|bp| bp.bundle_name.clone()) + .collect(); + + let mut candidate_keys_by_bundle: HashMap> = HashMap::new(); + for bundle in bundles { + candidate_keys_by_bundle.insert( + bundle.manifest.name.clone(), + collect_inferable_managed_keys_for_bundle(bundle), + ); + } + + for inferred_bundle in infer_bundle_names_from_unique_installed_keys( + &candidate_keys_by_bundle, + all_installed_managed_keys, + ) { + subscribed_bundles.insert(inferred_bundle); + } + + subscribed_bundles +} + +fn split_version_segments(version: &str) -> Vec { + version + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|segment| !segment.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + +fn compare_version_segment(left: &str, right: &str) -> Ordering { + match (left.parse::(), right.parse::()) { + (Ok(left_num), Ok(right_num)) => left_num.cmp(&right_num), + (Ok(_), Err(_)) => Ordering::Greater, + (Err(_), Ok(_)) => Ordering::Less, + (Err(_), Err(_)) => left.cmp(right), + } +} + +fn compare_version_like(left: &str, right: &str) -> Ordering { + let left_segments = split_version_segments(left); + let right_segments = split_version_segments(right); + + for index in 0..left_segments.len().max(right_segments.len()) { + match (left_segments.get(index), right_segments.get(index)) { + (Some(left_segment), Some(right_segment)) => { + let cmp = compare_version_segment(left_segment, right_segment); + if cmp != Ordering::Equal { + return cmp; + } + } + (Some(left_segment), None) => { + if left_segment.parse::().is_ok_and(|value| value == 0) { + continue; + } + return Ordering::Greater; + } + (None, Some(right_segment)) => { + if right_segment.parse::().is_ok_and(|value| value == 0) { + continue; + } + return Ordering::Less; + } + (None, None) => break, + } + } + + Ordering::Equal +} + +fn select_highest_loader_version<'a>( + versions: impl IntoIterator, +) -> Option { + versions + .into_iter() + .map(str::trim) + .filter(|value| !value.is_empty()) + .max_by(|left, right| compare_version_like(left, right)) + .map(std::string::ToString::to_string) +} + +fn resolve_target_loader_version_for_subscribed_bundles( + bundles: &[ModpackArchive], + subscribed_bundles: &HashSet, +) -> Option { + select_highest_loader_version( + bundles + .iter() + .filter(|bundle| subscribed_bundles.contains(&bundle.manifest.name)) + .map(|bundle| bundle.manifest.loader_version.as_str()), + ) +} + +async fn sync_cluster_loader_version_from_bundles(cluster_id: ClusterId) -> LauncherResult<()> { + let Some(cluster) = onelauncher_core::api::cluster::dao::get_cluster_by_id(cluster_id).await? + else { + tracing::warn!( + cluster_id = %cluster_id, + "Skipping loader version sync because cluster no longer exists" + ); + return Ok(()); + }; + + let bundle_packages = bundle_dao::get_bundle_packages_for_cluster(cluster_id).await?; + let all_linked_packages = api::packages::dao::get_linked_packages(&cluster).await?; + let bundles = BundlesManager::get() + .await + .get_bundles_for(&cluster.mc_version, cluster.mc_loader) + .await?; + + let all_installed_managed_keys = collect_all_installed_managed_keys(&all_linked_packages); + let subscribed_bundles = infer_subscribed_bundles_for_loader_sync( + &bundle_packages, + &bundles, + &all_installed_managed_keys, + ); + + let Some(target_loader_version) = + resolve_target_loader_version_for_subscribed_bundles(&bundles, &subscribed_bundles) + else { + tracing::debug!( + cluster_id = %cluster_id, + subscribed_bundle_count = %subscribed_bundles.len(), + "No subscribed bundle loader version available for sync" + ); + return Ok(()); + }; + + let current_loader_version = cluster.mc_loader_version.clone(); + if current_loader_version.as_deref() == Some(target_loader_version.as_str()) { + tracing::debug!( + cluster_id = %cluster_id, + loader_version = %target_loader_version, + "Cluster loader version already matches resolved bundle loader version" + ); + return Ok(()); + } + + tracing::info!( + cluster_id = %cluster_id, + old_loader_version = ?current_loader_version, + new_loader_version = %target_loader_version, + subscribed_bundle_count = %subscribed_bundles.len(), + "Updating cluster loader version from subscribed bundle manifests" + ); + + onelauncher_core::api::cluster::dao::set_cluster_loader_version( + cluster_id, + Some(target_loader_version), + ) + .await?; + + Ok(()) +} + #[taurpc::ipc_type] pub struct BundlePackageUpdate { pub cluster_id: ClusterId, @@ -1234,6 +1448,18 @@ pub async fn apply_bundle_updates( } } + let package_operation_failed = + !updates_failed.is_empty() || !removals_failed.is_empty() || !additions_failed.is_empty(); + + if package_operation_failed { + tracing::warn!( + cluster_id = %cluster_id, + "Skipping loader version sync because one or more bundle package operations failed" + ); + } else if let Err(e) = sync_cluster_loader_version_from_bundles(cluster_id).await { + send_error!("Failed to sync cluster loader version from bundles: {e}"); + } + Ok(ApplyBundleUpdatesResult { updates_applied, removals_applied, @@ -1567,7 +1793,10 @@ pub async fn migrate_bundles_to_cluster( mod inference_tests { use std::collections::{HashMap, HashSet}; - use super::infer_bundle_names_from_unique_installed_keys; + use super::{ + compare_version_like, infer_bundle_names_from_unique_installed_keys, + select_highest_loader_version, + }; fn set(values: &[&str]) -> HashSet { values.iter().map(|value| (*value).to_string()).collect() @@ -1641,6 +1870,26 @@ mod inference_tests { ); assert_eq!(inferred, set(&["QoL"])); } + + #[test] + fn loader_version_selects_highest_numeric_value() { + let selected = select_highest_loader_version(["0.16.8", "0.16.12", "0.15.11"]); + assert_eq!(selected.as_deref(), Some("0.16.12")); + } + + #[test] + fn loader_version_ignores_empty_values() { + let selected = select_highest_loader_version(["", " ", "0.16.10"]); + assert_eq!(selected.as_deref(), Some("0.16.10")); + } + + #[test] + fn loader_version_comparator_treats_missing_zero_segments_as_equal() { + assert_eq!( + compare_version_like("0.16", "0.16.0"), + std::cmp::Ordering::Equal + ); + } } #[cfg(test)] diff --git a/apps/oneclient/desktop/src/oneclient/bundle_updates_test.rs b/apps/oneclient/desktop/src/oneclient/bundle_updates_test.rs index 2d1de71c..93fe2f16 100644 --- a/apps/oneclient/desktop/src/oneclient/bundle_updates_test.rs +++ b/apps/oneclient/desktop/src/oneclient/bundle_updates_test.rs @@ -105,6 +105,15 @@ mod tests { check1.additions_available.len() ); + let cluster_after_apply = dao::get_cluster_by_id(cluster.id) + .await + .unwrap() + .expect("cluster should still exist after bundle apply"); + assert!( + cluster_after_apply.mc_loader_version.is_some(), + "Expected bundle apply to sync cluster mc_loader_version" + ); + // 4. Verify overrides were extracted // The 1.21.1 Fabric bundles usually include some config overrides (e.g. options.txt or config/...) // We'll write a custom file in the cluster to simulate a user creating a config @@ -261,6 +270,15 @@ mod tests { "Expected initial additions from bundle" ); + let cluster_after_apply = dao::get_cluster_by_id(cluster.id) + .await + .unwrap() + .expect("cluster should still exist after bundle apply"); + assert!( + cluster_after_apply.mc_loader_version.is_some(), + "Expected bundle apply to sync cluster mc_loader_version" + ); + // Get all installed packages mapping to the bundle let installed_pkgs = onelauncher_core::api::packages::bundle_dao::get_bundle_packages_for_cluster( diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index e047434d..091b650a 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -86,7 +86,7 @@ export type MessageLevel = "Info" | "Warn" | "Error" export type MessagePayload = { level: MessageLevel; message: string } -export type MetadataError = { type: "FetchError"; data: string } | { type: "NotModdedManifest"; data: string } | { type: "NotVanillaManifest"; data: string } | { type: "ParseError"; data: string } | { type: "NoMatchingLoader"; data: string } | { type: "NoMatchingVersion"; data: string } +export type MetadataError = { type: "FetchError"; data: string } | { type: "NotModdedManifest"; data: string } | { type: "NotVanillaManifest"; data: string } | { type: "ParseError"; data: string } | { type: "NoMatchingLoader"; data: string } | { type: "RequestedLoaderVersionNotFound"; data: string } | { type: "NoMatchingVersion"; data: string } export type MinecraftAuthError = { type: "PublicKeyReading"; data: string } | { type: "PKCS8Error"; data: string } | { type: "SerializeError"; data: string } | { type: "DeserializeError"; data: string } | { type: "RequestError"; data: string } | { type: "SigningError"; data: string } | { type: "HashError"; data: string } | { type: "SessionIdError"; data: string } | { type: "XboxError"; data: string } @@ -325,7 +325,7 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'oneclient':'{"cacheArt":["path"],"refreshArt":["path"],"copyClusterContent":["source_id","target_id"],"checkForUpdate":[],"getBundlesFor":["cluster_id"],"getClustersGroupedByMajor":[],"installUpdate":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"updateBundlePackages":["cluster_id"],"extractBundleOverrides":["bundle_path","cluster_id"],"getVersions":[],"isBundleSyncing":[]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'debug':'{"isInDev":[],"getFamily":[],"getLocale":[],"getArch":[],"getBuildTimestamp":[],"hasDevSettings":[],"getFullDebugInfoParsed":[],"getFullDebugInfo":[],"getFullDebugInfoParsedString":[],"getType":[],"getGitCommitHash":[],"openDevTools":[],"getPackageVersion":[],"getPlatform":[],"getOsVersion":[],"getOsDistro":[]}', 'core':'{"changeCape":["access_token","cape_uuid"],"getPackage":["provider","slug"],"installModpack":["modpack","cluster_id"],"convertUsernameUUID":["username_uuid"],"getLoadersForVersion":["mc_version"],"removeCape":["access_token"],"openMsaLogin":[],"getClusterById":["id"],"removeUser":["uuid"],"getUser":["uuid"],"refreshAccount":["uuid"],"readSettings":[],"getPackageBody":["provider","body"],"changeSkin":["access_token","skin_url","skin_variant"],"getScreenshots":["id"],"refreshAccounts":[],"launchCluster":["id","uuid","search_for_java"],"getGameVersions":[],"removeCluster":["id"],"getUsers":[],"getRunningProcessesByClusterId":["cluster_id"],"setClusterStage":["id","stage"],"getLinkedPackages":["cluster_id"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"open":["input"],"createCluster":["options"],"searchPackages":["provider","query"],"setDiscordRPCMessage":["message"],"createSettingsProfile":["name"],"setDefaultUser":["uuid"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"getRunningProcesses":[],"getProcessLogTail":["id","max_lines"],"getClusters":[],"getLogByName":["id","name"],"getProfileOrDefault":["name"],"killProcess":["pid"],"updateClusterById":["id","request"],"getGlobalProfile":[],"isClusterRunning":["cluster_id"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"getUsersFromAuthor":["provider","author"],"updateClusterProfile":["name","profile"],"getLogs":["id"],"writeSettings":["setting"],"getMultiplePackages":["provider","slugs"],"syncCluster":["cluster_id"],"togglePackage":["cluster_id","package_hash"],"fetchMinecraftProfile":["uuid"],"fetchLoggedInProfile":["access_token"],"getWorlds":["id"],"getDefaultUser":["fallback"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"removePackage":["cluster_id","package_hash"]}' } +const ARGS_MAP = { 'oneclient':'{"refreshArt":["path"],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"getBundlesFor":["cluster_id"],"extractBundleOverrides":["bundle_path","cluster_id"],"installUpdate":[],"updateBundlePackages":["cluster_id"],"copyClusterContent":["source_id","target_id"],"cacheArt":["path"],"getVersions":[],"checkForUpdate":[],"getClustersGroupedByMajor":[],"isBundleSyncing":[]}', 'folders':'{"fromCluster":["folder_name"],"openCluster":["folder_name"]}', 'core':'{"getProcessLogTail":["id","max_lines"],"createSettingsProfile":["name"],"getWorlds":["id"],"getUsersFromAuthor":["provider","author"],"syncCluster":["cluster_id"],"updateClusterProfile":["name","profile"],"getLogByName":["id","name"],"getRunningProcesses":[],"readSettings":[],"searchPackages":["provider","query"],"removePackage":["cluster_id","package_hash"],"convertUsernameUUID":["username_uuid"],"getClusters":[],"removeUser":["uuid"],"open":["input"],"isClusterRunning":["cluster_id"],"changeCape":["access_token","cape_uuid"],"getUser":["uuid"],"getLoadersForVersion":["mc_version"],"fetchLoggedInProfile":["access_token"],"setClusterStage":["id","stage"],"getClusterById":["id"],"launchCluster":["id","uuid","search_for_java"],"killProcess":["pid"],"getRunningProcessesByClusterId":["cluster_id"],"getProfileOrDefault":["name"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"updateClusterById":["id","request"],"getGlobalProfile":[],"getLogs":["id"],"getUsers":[],"getDefaultUser":["fallback"],"getPackageBody":["provider","body"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"setDiscordRPCMessage":["message"],"openMsaLogin":[],"getGameVersions":[],"getLinkedPackages":["cluster_id"],"createCluster":["options"],"removeCluster":["id"],"writeSettings":["setting"],"getPackage":["provider","slug"],"setDefaultUser":["uuid"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"refreshAccounts":[],"togglePackage":["cluster_id","package_hash"],"installModpack":["modpack","cluster_id"],"fetchMinecraftProfile":["uuid"],"changeSkin":["access_token","skin_url","skin_variant"],"removeCape":["access_token"],"getMultiplePackages":["provider","slugs"],"refreshAccount":["uuid"],"getScreenshots":["id"]}', 'debug':'{"isInDev":[],"openDevTools":[],"getLocale":[],"getPackageVersion":[],"getPlatform":[],"getOsDistro":[],"getFullDebugInfo":[],"getGitCommitHash":[],"getArch":[],"getBuildTimestamp":[],"getType":[],"getFullDebugInfoParsedString":[],"getFullDebugInfoParsed":[],"getOsVersion":[],"hasDevSettings":[],"getFamily":[]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}' } export type Router = { 'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, @@ -380,11 +380,21 @@ removeCape: (accessToken: string) => Promise, convertUsernameUUID: (usernameUuid: string) => Promise, setDiscordRPCMessage: (message: string) => Promise, open: (input: string) => Promise }, +'oneclient': { getClustersGroupedByMajor: () => Promise>, +getBundlesFor: (clusterId: number) => Promise, +getVersions: () => Promise, +extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise, +checkForUpdate: () => Promise, +installUpdate: () => Promise, +downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise, +updateBundlePackages: (clusterId: number) => Promise, +isBundleSyncing: () => Promise, +cacheArt: (path: string) => Promise, +refreshArt: (path: string) => Promise, +copyClusterContent: (sourceId: number, targetId: number) => Promise }, 'events': { ingress: (event: IngressPayload) => Promise, message: (event: MessagePayload) => Promise, process: (event: ProcessPayload) => Promise }, -'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise }, 'debug': { openDevTools: () => Promise, isInDev: () => Promise, getArch: () => Promise, @@ -401,18 +411,8 @@ getFullDebugInfo: () => Promise, getFullDebugInfoParsed: () => Promise, getFullDebugInfoParsedString: () => Promise, hasDevSettings: () => Promise }, -'oneclient': { getClustersGroupedByMajor: () => Promise>, -getBundlesFor: (clusterId: number) => Promise, -getVersions: () => Promise, -extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise, -checkForUpdate: () => Promise, -installUpdate: () => Promise, -downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise, -updateBundlePackages: (clusterId: number) => Promise, -isBundleSyncing: () => Promise, -cacheArt: (path: string) => Promise, -refreshArt: (path: string) => Promise, -copyClusterContent: (sourceId: number, targetId: number) => Promise } }; +'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise } }; export type { InferCommandOutput } diff --git a/packages/core/src/api/cluster/dao.rs b/packages/core/src/api/cluster/dao.rs index 35f3f37a..83da2793 100644 --- a/packages/core/src/api/cluster/dao.rs +++ b/packages/core/src/api/cluster/dao.rs @@ -100,6 +100,23 @@ pub async fn touch_cluster_created_at(id: ClusterId) -> LauncherResult<()> { Ok(()) } +/// Sets the loader version for a cluster. +/// +/// Passing `None` means no explicit version is pinned. +pub async fn set_cluster_loader_version( + id: ClusterId, + loader_version: Option, +) -> LauncherResult { + update_cluster_by_id(id, move |mut active: clusters::ActiveModel| { + let loader_version = loader_version.clone(); + async move { + active.mc_loader_version = Set(loader_version); + Ok(active) + } + }) + .await +} + /// Gets a cluster by its ID from the database. pub async fn get_cluster_by_id(id: ClusterId) -> LauncherResult> { let state = State::get().await?; diff --git a/packages/core/src/api/game/launch.rs b/packages/core/src/api/game/launch.rs index 715960d7..f03ab27e 100644 --- a/packages/core/src/api/game/launch.rs +++ b/packages/core/src/api/game/launch.rs @@ -1,5 +1,5 @@ use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; use chrono::Utc; use discord_rich_presence::activity::Timestamps; @@ -18,6 +18,21 @@ use crate::store::credentials::MinecraftCredentials; use crate::store::processes::Process; use crate::store::{Dirs, State}; use crate::utils::io; +use onelauncher_entity::cluster_stage::ClusterStage; +use sea_orm::ActiveValue::Set; + +/// Per-cluster lock map to avoid concurrent launch/prepare races. +static CLUSTER_LAUNCH_LOCKS: OnceLock< + Mutex>>>, +> = OnceLock::new(); + +fn get_cluster_launch_lock(cluster_id: i64) -> Arc> { + let locks = CLUSTER_LAUNCH_LOCKS.get_or_init(|| Mutex::new(std::collections::HashMap::new())); + let mut map = locks.lock().unwrap(); + map.entry(cluster_id) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() +} #[tracing::instrument(skip_all)] pub async fn launch_minecraft( @@ -26,11 +41,73 @@ pub async fn launch_minecraft( force: Option, search_for_java: Option, ) -> LauncherResult>> { + let cluster_launch_lock = get_cluster_launch_lock(cluster.id); + let _launch_guard = cluster_launch_lock.lock().await; + + tracing::info!( + cluster_id = %cluster.id, + cluster_name = %cluster.name, + mc_version = %cluster.mc_version, + mc_loader = ?cluster.mc_loader, + mc_loader_version = ?cluster.mc_loader_version, + "launch requested" + ); + if cluster.stage.is_downloading() { - return Err(ClusterError::ClusterDownloading.into()); + let state = State::get().await?; + let mut has_active_prepare = state + .ingress_processor + .has_active_prepare_cluster(&cluster.name) + .await; + + if has_active_prepare { + tracing::info!( + cluster_id = %cluster.id, + cluster_name = %cluster.name, + "cluster prepare is active; waiting for completion before launching" + ); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120); + while tokio::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + if let Some(fresh_cluster) = + crate::api::cluster::dao::get_cluster_by_id(cluster.id).await? + { + *cluster = fresh_cluster; + } + + has_active_prepare = state + .ingress_processor + .has_active_prepare_cluster(&cluster.name) + .await; + + if !cluster.stage.is_downloading() || !has_active_prepare { + break; + } + } + } + + if has_active_prepare { + return Err(ClusterError::ClusterDownloading.into()); + } + + tracing::warn!( + cluster_id = %cluster.id, + cluster_name = %cluster.name, + "cluster was marked as Downloading without active prepare ingress; resetting stage" + ); + + crate::api::cluster::dao::update_cluster(cluster, async |mut active| { + active.stage = Set(ClusterStage::NotReady); + Ok(active) + }) + .await?; } + tracing::info!(cluster_id = %cluster.id, "preparing cluster before launch"); prepare_cluster(cluster, force, search_for_java).await?; + tracing::info!(cluster_id = %cluster.id, "cluster preparation completed"); let global = get_global_profile().await; let settings = if let Some(name) = &cluster.setting_profile_name && let Some(profile) = get_profile_by_name(name).await? @@ -56,16 +133,20 @@ pub async fn launch_minecraft( let cwd = &io::canonicalize(dirs.clusters_dir().join(cluster.folder_name.clone()))?; - let metadata = state.metadata.read().await; - let versions = &metadata.get_vanilla()?.versions; + let (version, updated) = { + let metadata_store = state.metadata.read().await; + let versions = &metadata_store.get_vanilla()?.versions; - let version_index = versions - .iter() - .position(|it| it.id == cluster.mc_version) - .ok_or_else(|| anyhow::anyhow!("invalid game version {}", cluster.mc_version))?; + let version_index = versions + .iter() + .position(|it| it.id == cluster.mc_version) + .ok_or_else(|| anyhow::anyhow!("invalid game version {}", cluster.mc_version))?; - let version = versions[version_index].clone(); - let updated = metadata::is_version_updated(version_index, versions); + ( + versions[version_index].clone(), + metadata::is_version_updated(version_index, versions), + ) + }; let loader_version = metadata::get_loader_version( &cluster.mc_version, @@ -74,6 +155,12 @@ pub async fn launch_minecraft( ) .await?; + tracing::info!( + cluster_id = %cluster.id, + resolved_loader_version = ?loader_version.as_ref().map(|value| value.id.clone()), + "resolved loader version for launch" + ); + let version_name = loader_version.as_ref().map_or_else( || version.id.clone(), |it| format!("{}-{}", version.id, it.id), @@ -81,6 +168,11 @@ pub async fn launch_minecraft( let version_info = metadata::download_version_info(&version, loader_version.as_ref(), None, None).await?; + tracing::info!( + cluster_id = %cluster.id, + version_id = %version_info.id, + "version metadata prepared for launch" + ); let Some(java) = java::get_recommended_java(&version_info, Some(&settings)).await? else { return Err(JavaError::MissingJava.into()); @@ -221,6 +313,8 @@ pub async fn launch_minecraft( } } + tracing::info!(cluster_id = %cluster.id, "spawning minecraft process"); + state .processes .spawn(cluster.id, cwd.clone(), creds, censors, &settings, command) diff --git a/packages/core/src/api/game/metadata.rs b/packages/core/src/api/game/metadata.rs index e6d42c1a..0b5f3b79 100644 --- a/packages/core/src/api/game/metadata.rs +++ b/packages/core/src/api/game/metadata.rs @@ -422,26 +422,82 @@ pub async fn get_loader_version( } let state = State::get().await?; - let metadata = state.metadata.read().await; - let manifest = metadata.get_modded(&loader)?; + let mut metadata = state.metadata.write().await; - let Some(loaders) = manifest.game_versions.iter().find(|it| { - it.id - .replace(interpulse::api::modded::DUMMY_REPLACE_STRING, mc_version) - == mc_version - }) else { - return Err(MetadataError::NoMatchingVersion.into()); + let resolve_from_manifest = |manifest: &interpulse::api::modded::Manifest| { + let mut saw_matching_game_version = false; + + for entry in &manifest.game_versions { + if entry + .id + .replace(interpulse::api::modded::DUMMY_REPLACE_STRING, mc_version) + != mc_version + { + continue; + } + + saw_matching_game_version = true; + + if let Some(requested_loader_version) = loader_version { + if let Some(found) = entry + .loaders + .iter() + .find(|loader_entry| loader_entry.id == requested_loader_version) + { + return (saw_matching_game_version, Some(found.clone())); + } + continue; + } + + if let Some(found) = entry + .loaders + .iter() + .find(|loader_entry| loader_entry.stable) + .or_else(|| entry.loaders.first()) + { + return (saw_matching_game_version, Some(found.clone())); + } + } + + (saw_matching_game_version, None) }; - let loader_version = loaders - .loaders - .iter() - .find(|it| loader_version.map_or(it.stable, |version| it.id == version)) - .or_else(|| loaders.loaders.first()) - .cloned() - .ok_or(MetadataError::NoMatchingLoader)?; + let mut manifest = metadata.get_modded_or_fetch(&loader).await?; + let (mut saw_matching_game_version, mut resolved_loader) = resolve_from_manifest(manifest); + if let Some(resolved) = resolved_loader.take() { + return Ok(Some(resolved)); + } - Ok(Some(loader_version)) + if loader_version.is_some() { + tracing::warn!( + mc_version = %mc_version, + loader = ?loader, + requested_loader_version = ?loader_version, + "Requested loader version not found in cached metadata; refetching manifests" + ); + + metadata.fetch_all().await; + manifest = metadata.get_modded(&loader)?; + (saw_matching_game_version, resolved_loader) = resolve_from_manifest(manifest); + if let Some(resolved) = resolved_loader { + return Ok(Some(resolved)); + } + + if !saw_matching_game_version { + return Err(MetadataError::NoMatchingVersion.into()); + } + + return Err(MetadataError::RequestedLoaderVersionNotFound { + requested: loader_version.expect("checked as some").to_string(), + } + .into()); + } + + if saw_matching_game_version { + Err(MetadataError::NoMatchingLoader.into()) + } else { + Err(MetadataError::NoMatchingVersion.into()) + } } // MARK: Get Versions diff --git a/packages/core/src/api/packages/mod.rs b/packages/core/src/api/packages/mod.rs index 1fc10bd7..032962de 100644 --- a/packages/core/src/api/packages/mod.rs +++ b/packages/core/src/api/packages/mod.rs @@ -74,25 +74,40 @@ pub async fn download_package( .find(|f| f.primary) .ok_or(PackageError::NoPrimaryFile)?; - let update_existing_package = - if let Some(model) = dao::get_package_by_hash(primary_file.sha1.clone()).await? { - if !force.unwrap_or(false) { - let cached_path = model.path().await?; - if cached_path.exists() { + let update_existing_package = if let Some(model) = + dao::get_package_by_hash(primary_file.sha1.clone()).await? + { + if !force.unwrap_or(false) { + let cached_path = model.path().await?; + if cached_path.exists() { + let cached_hash = HashAlgorithm::Sha1.hash_file(&cached_path).await?; + if cached_hash == model.hash { tracing::debug!("package is already downloaded"); return Ok(model); } + tracing::warn!( + "package '{}' was found in database but cached file at '{}' has mismatched hash (existing: {}, expected: {}); re-downloading", + model.hash, + cached_path.display(), + cached_hash, + model.hash, + ); + io::remove_file(&cached_path).await?; + } + + if !cached_path.exists() { tracing::warn!( "package '{}' was found in database but cached file is missing at '{}'; re-downloading", model.hash, cached_path.display() ); } - true - } else { - false - }; + } + true + } else { + false + }; let mut model = packages::Model { hash: primary_file.sha1.clone(), @@ -150,24 +165,38 @@ pub async fn download_external_package( if !force.unwrap_or(false) { let cached_path = model.path().await?; if cached_path.exists() { - tracing::debug!( - "external package is already downloaded as '{}'", - model.display_name + let cached_hash = HashAlgorithm::Sha1.hash_file(&cached_path).await?; + if cached_hash == model.hash { + tracing::debug!( + "external package is already downloaded as '{}'", + model.display_name + ); + + // considering that this function downloads packages to a cluster, + // we'll hard-link the already downloaded package to the cluster + // as to keep functionality consistent + link_package(&model, cluster, skip_compatibility).await?; + + return Ok(Some(model)); + } + + tracing::warn!( + "external package '{}' was found in database but cached file at '{}' has mismatched hash (existing: {}, expected: {}); re-downloading", + model.hash, + cached_path.display(), + cached_hash, + model.hash, ); - - // considering that this function downloads packages to a cluster, - // we'll hard-link the already downloaded package to the cluster - // as to keep functionality consistent - link_package(&model, cluster, skip_compatibility).await?; - - return Ok(Some(model)); + io::remove_file(&cached_path).await?; } - tracing::warn!( - "external package '{}' was found in database but cached file is missing at '{}'; re-downloading", - model.hash, - cached_path.display() - ); + if !cached_path.exists() { + tracing::warn!( + "external package '{}' was found in database but cached file is missing at '{}'; re-downloading", + model.hash, + cached_path.display() + ); + } } true } else { @@ -394,13 +423,23 @@ async fn hard_link(package: &packages::Model, cluster: &clusters::Model) -> Laun dest_pkg.display() ); } else { - return Err(anyhow::anyhow!( - "destination '{}' already exists with mismatched hash (existing: {}, expected: {})", + tracing::warn!( + "destination '{}' already exists with mismatched hash (existing: {}, expected: {}); replacing stale file", dest_pkg.display(), existing_hash, package.hash, - ) - .into()); + ); + + io::remove_file(&dest_pkg).await?; + tokio::fs::hard_link(&src_pkg, &dest_pkg) + .await + .map_err(IOError::from)?; + + tracing::debug!( + "replaced stale package link for '{}' at '{}'", + package.display_name, + dest_pkg.display() + ); } } Err(err) => return Err(IOError::from(err).into()), diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 8d9a0ee9..f10ec51f 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -425,8 +425,22 @@ impl TauriLauncherApi for TauriLauncherApiImpl { // let user = api::credentials::get_fake_user(); - let _ = - api::game::launch::launch_minecraft(&mut cluster, user, None, search_for_java).await?; + let launch_result = + api::game::launch::launch_minecraft(&mut cluster, user, None, search_for_java).await; + + if let Err(err) = &launch_result { + tracing::error!( + cluster_id = %cluster.id, + cluster_name = %cluster.name, + mc_version = %cluster.mc_version, + mc_loader = ?cluster.mc_loader, + mc_loader_version = ?cluster.mc_loader_version, + "failed to launch cluster: {err}" + ); + crate::send_error!("Failed to launch '{}': {}", cluster.name, err); + } + + let _ = launch_result?; Ok(()) } diff --git a/packages/core/src/store/ingress.rs b/packages/core/src/store/ingress.rs index e4c7ce14..767f3383 100644 --- a/packages/core/src/store/ingress.rs +++ b/packages/core/src/store/ingress.rs @@ -101,6 +101,19 @@ impl IngressProcessor { let mut feeds = self.ingress_feeds.write().await; Ok(feeds.remove(&uuid).ok_or(IngressError::NotFound)?) } + + pub async fn has_active_prepare_cluster(&self, cluster_name: &str) -> bool { + let feeds = self.ingress_feeds.read().await; + feeds.values().any(|ingress| { + matches!( + ingress.ingress_type, + IngressType::PrepareCluster { + cluster_name: ref name + } + if name == cluster_name + ) + }) + } } #[derive(Debug, Clone)] diff --git a/packages/core/src/store/metadata.rs b/packages/core/src/store/metadata.rs index 79929730..65bc81e7 100644 --- a/packages/core/src/store/metadata.rs +++ b/packages/core/src/store/metadata.rs @@ -50,6 +50,8 @@ pub enum MetadataError { ), #[error("no matching loader found")] NoMatchingLoader, + #[error("requested loader version '{requested}' was not found")] + RequestedLoaderVersionNotFound { requested: String }, #[error("no matching version found")] NoMatchingVersion, }