add fabric loader updating
This commit is contained in:
parent
0ab1043082
commit
93fef599ae
10 changed files with 575 additions and 73 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue