add fabric loader updating

This commit is contained in:
Wyvest 2026-03-31 00:41:24 -04:00
parent 0ab1043082
commit 93fef599ae
No known key found for this signature in database
10 changed files with 575 additions and 73 deletions

View file

@ -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<String> {
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<String> {
let mut visible_managed_keys: HashSet<String> = HashSet::new();
let mut hidden_dependency_keys: HashSet<String> = HashSet::new();
let mut hidden_explicit_keys: HashSet<String> = 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<String>,
) -> HashSet<String> {
let mut subscribed_bundles: HashSet<String> = bundle_packages
.iter()
.filter_map(|bp| bp.bundle_name.clone())
.collect();
let mut candidate_keys_by_bundle: HashMap<String, HashSet<String>> = 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<String> {
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::<u64>(), right.parse::<u64>()) {
(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::<u64>().is_ok_and(|value| value == 0) {
continue;
}
return Ordering::Greater;
}
(None, Some(right_segment)) => {
if right_segment.parse::<u64>().is_ok_and(|value| value == 0) {
continue;
}
return Ordering::Less;
}
(None, None) => break,
}
}
Ordering::Equal
}
fn select_highest_loader_version<'a>(
versions: impl IntoIterator<Item = &'a str>,
) -> Option<String> {
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<String>,
) -> Option<String> {
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<String> {
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)]

View file

@ -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(

View file

@ -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<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
@ -380,11 +380,21 @@ removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
setDiscordRPCMessage: (message: string) => Promise<null>,
open: (input: 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>,
copyClusterContent: (sourceId: number, targetId: number) => Promise<CopyClusterResult> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'debug': { openDevTools: () => Promise<void>,
isInDev: () => Promise<boolean>,
getArch: () => Promise<string>,
@ -401,18 +411,8 @@ getFullDebugInfo: () => Promise<DebugInfoData>,
getFullDebugInfoParsed: () => Promise<DebugInfoParsedLine[]>,
getFullDebugInfoParsedString: () => Promise<string>,
hasDevSettings: () => Promise<boolean> },
'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> } };
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> } };
export type { InferCommandOutput }

View file

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

View file

@ -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<std::collections::HashMap<i64, Arc<tokio::sync::Mutex<()>>>>,
> = OnceLock::new();
fn get_cluster_launch_lock(cluster_id: i64) -> Arc<tokio::sync::Mutex<()>> {
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<bool>,
search_for_java: Option<bool>,
) -> LauncherResult<Arc<RwLock<Process>>> {
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)

View file

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

View file

@ -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()),

View file

@ -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(())
}

View file

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

View file

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