use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex, OnceLock}; use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::dao::cluster as cluster_dao; use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::models::ClusterPatch; use oneclient_db::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; use tokio::sync::Mutex as AsyncMutex; use tracing::instrument; use crate::bundles::install::{install_package_from_bundle, remove_artifact_from_cluster}; use crate::bundles::manager::BundlesManager; use crate::bundles::overrides; use crate::bundles::types::{ ApplyBundleUpdatesResult, BundleArchive, BundleFileKind, BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus, external_bundle_key, managed_bundle_key, }; use crate::packages::domain::{GameLoader, ProviderId}; use crate::packages::store::PackageStore; use crate::packages::types::LinkedArtifactInfo; use crate::state::LauncherServices; use crate::{LauncherError, LauncherResult}; static CLUSTER_UPDATE_LOCKS: OnceLock>>>> = OnceLock::new(); fn cluster_lock(cluster_id: i64) -> Arc> { let locks = CLUSTER_UPDATE_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); let mut map = locks.lock().unwrap(); map.entry(cluster_id) .or_insert_with(|| Arc::new(AsyncMutex::new(()))) .clone() } pub async fn check_bundle_updates( cluster_id: i64, bundles: &BundlesManager, services: &LauncherServices, ) -> LauncherResult { let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; check_bundle_updates_inner(cluster_id, bundles, services, &overrides).await } #[instrument(skip(bundles, services, overrides))] async fn check_bundle_updates_inner( cluster_id: i64, bundles: &BundlesManager, services: &LauncherServices, overrides: &[ClusterBundleOverrideRow], ) -> LauncherResult { let cluster = PackageStore::get_cluster(cluster_id, services).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { LauncherError::InvalidSettingsProfile { reason: format!("unknown loader {}", cluster.mc_loader), } })?; let bundle_packages = bundle_dao::list_bundle_tracked(&services.db, cluster_id).await?; let all_linked = PackageStore::list_linked_artifacts(cluster_id, services).await?; let all_linked_by_hash: HashMap = all_linked.iter().map(|a| (a.hash.clone(), a)).collect(); let (all_installed_managed_keys, all_installed_external_hashes) = installed_bundle_keys(services, &all_linked).await?; let archives = bundles .archives_for(services, &cluster.mc_version, loader) .await?; let tracked_bundle_names: HashSet = bundle_packages .iter() .filter_map(|bp| bp.bundle_name.clone()) .collect(); let loaded_bundle_names: HashSet = archives.iter().map(|a| a.manifest.name.clone()).collect(); let unavailable_tracked: HashSet = tracked_bundle_names .into_iter() .filter(|name| !loaded_bundle_names.contains(name)) .collect(); let mut bundle_versions: HashMap< String, HashMap, > = HashMap::new(); let mut hidden_dependency_keys_by_bundle: HashMap> = HashMap::new(); let mut hidden_explicit_keys_by_bundle: HashMap> = HashMap::new(); for archive in &archives { let mut files_map = HashMap::new(); let mut hidden_dependency_keys = HashSet::new(); let mut hidden_explicit_keys = HashSet::new(); for file in &archive.manifest.files { if !file.enabled { continue; } match &file.kind { BundleFileKind::Managed { provider, project_id, version_id, .. } => { let key = managed_bundle_key(*provider, project_id); files_map.insert(key.clone(), (version_id.clone(), file.clone())); if file.hidden { hidden_explicit_keys.insert(key); } } BundleFileKind::External(ext) => { let key = external_bundle_key(&ext.sha1); files_map.insert(key.clone(), (ext.sha1.clone(), file.clone())); if file.hidden { hidden_explicit_keys.insert(key); } } } } for visible_key in files_map.keys() { hidden_dependency_keys.remove(visible_key); } hidden_dependency_keys_by_bundle .insert(archive.manifest.name.clone(), hidden_dependency_keys); hidden_explicit_keys_by_bundle.insert(archive.manifest.name.clone(), hidden_explicit_keys); bundle_versions.insert(archive.manifest.name.clone(), files_map); } let mut subscribed_bundles: HashSet = bundle_packages .iter() .filter_map(|bp| bp.bundle_name.clone()) .collect(); let mut candidate_keys_by_bundle = HashMap::new(); for archive in &archives { let Some(files_map) = bundle_versions.get(&archive.manifest.name) else { continue; }; let hidden_keys = hidden_dependency_keys_by_bundle .get(&archive.manifest.name) .cloned() .unwrap_or_default(); let hidden_explicit = hidden_explicit_keys_by_bundle .get(&archive.manifest.name) .cloned() .unwrap_or_default(); candidate_keys_by_bundle.insert( archive.manifest.name.clone(), collect_inferable_managed_keys(files_map, &hidden_keys, &hidden_explicit), ); } for inferred in infer_bundle_names_from_unique_installed_keys( &candidate_keys_by_bundle, &all_installed_managed_keys, ) { subscribed_bundles.insert(inferred); } let subscribed_bundle_names: Vec<&str> = archives .iter() .filter(|a| subscribed_bundles.contains(&a.manifest.name)) .map(|a| a.manifest.name.as_str()) .collect(); let overrides_map: HashMap<(String, String), OverrideType> = overrides .iter() .filter_map(|o| { OverrideType::parse(&o.override_type) .map(|t| ((o.bundle_name.clone(), o.package_id.clone()), t)) }) .collect(); let mut updates_available = Vec::new(); let mut removals_available = Vec::new(); for bundle_pkg in &bundle_packages { let Some(pkg_id) = &bundle_pkg.package_id else { continue; }; let Some(installed_version_id) = &bundle_pkg.bundle_version_id else { continue; }; let installed_key = bundle_package_key(bundle_pkg, &all_linked_by_hash, pkg_id); let Some(bundle_name) = &bundle_pkg.bundle_name else { continue; }; if unavailable_tracked.contains(bundle_name) { continue; } let mut matched_target = bundle_versions.get(bundle_name).and_then(|files_map| { files_map .get(&installed_key) .map(|(new_version_id, new_file)| { ( bundle_name.clone(), new_version_id.clone(), new_file.clone(), ) }) }); if matched_target.is_none() { for candidate in &subscribed_bundle_names { if *candidate == bundle_name.as_str() { continue; } if let Some((new_version_id, new_file)) = bundle_versions .get(*candidate) .and_then(|m| m.get(&installed_key)) { matched_target = Some(( (*candidate).to_string(), new_version_id.clone(), new_file.clone(), )); break; } } } if let Some((resolved_bundle_name, new_version_id, new_file)) = matched_target { if installed_version_id != &new_version_id { updates_available.push(BundlePackageUpdate { cluster_id, installed_hash: bundle_pkg.hash.clone(), installed_version_id: installed_version_id.clone(), bundle_name: resolved_bundle_name, new_version_id, new_file, }); } } else { removals_available.push(BundlePackageRemoval { cluster_id, hash: bundle_pkg.hash.clone(), package_id: pkg_id.clone(), bundle_name: bundle_name.clone(), }); } } updates_available.retain(|u| { let file_id = u.new_file.kind.package_id(); !matches!( overrides_map.get(&(u.bundle_name.clone(), file_id)), Some(OverrideType::Removed) ) }); let mut additions_available = Vec::new(); let mut planned_addition_keys = all_installed_managed_keys.clone(); for hash in all_installed_external_hashes { planned_addition_keys.insert(external_bundle_key(&hash)); } for archive in &archives { if !subscribed_bundles.contains(&archive.manifest.name) { continue; } for file in &archive.manifest.files { if !file.enabled { continue; } let file_key = match &file.kind { BundleFileKind::Managed { provider, project_id, .. } => managed_bundle_key(*provider, project_id), BundleFileKind::External(ext) => external_bundle_key(&ext.sha1), }; if planned_addition_keys.contains(&file_key) { continue; } let file_id = file.kind.package_id(); if matches!( overrides_map.get(&(archive.manifest.name.clone(), file_id.clone())), Some(OverrideType::Removed) ) { continue; } additions_available.push(BundlePackageAddition { cluster_id, bundle_name: archive.manifest.name.clone(), new_file: file.clone(), }); planned_addition_keys.insert(file_key); } } Ok(BundleUpdateCheckResult { cluster_id, updates_available, removals_available, additions_available, }) } pub async fn apply_bundle_updates( cluster_id: i64, bundles: &BundlesManager, services: &LauncherServices, ) -> LauncherResult { let lock = cluster_lock(cluster_id); let _guard = lock.lock().await; let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; let check = check_bundle_updates_inner(cluster_id, bundles, services, &overrides).await?; let mut result = ApplyBundleUpdatesResult::default(); for removal in check.removals_available { match remove_artifact_from_cluster(cluster_id, &removal.hash, false, services).await { Ok(()) => result.removals_applied.push(removal), Err(err) => result .removals_failed .push(format!("{}: {err:#}", removal.package_id)), } } let session = (!check.updates_available.is_empty() || !check.additions_available.is_empty()) .then(|| { crate::notification::GroupedProgressSession::start( &services.notifier, "Updating bundle content", ) }); for update in check.updates_available { let child = session.as_ref().map(|s| { let c = s.child(update.new_file.display_name(), update.new_file.size.max(1)); c.set_phase(crate::notification::TaskPhase::Downloading); c }); match apply_single_update(&update, &overrides, child.as_ref(), services).await { Ok(_) => result.updates_applied.push(update), Err(err) => result.updates_failed.push(err.to_string()), } } for addition in check.additions_available { let file_id = addition.new_file.kind.package_id(); let child = session.as_ref().map(|s| { let c = s.child(addition.new_file.display_name(), addition.new_file.size.max(1)); c.set_phase(crate::notification::TaskPhase::Downloading); c }); match apply_single_addition(&addition, &overrides, child.as_ref(), services).await { Ok(_) => result.additions_applied.push(addition), Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")), } } if let Some(session) = session { session.finish(); } { let cluster = PackageStore::get_cluster(cluster_id, services).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); if let Ok(archives) = bundles .archives_for(services, &cluster.mc_version, loader) .await { for archive in archives { if let Err(err) = overrides::sync_bundle_overrides( &archive.bundle.path, &archive.manifest.name, &cluster, Some(&services.notifier), ) .await { tracing::warn!( bundle = %archive.manifest.name, error = %err, "failed to sync bundle overrides during update" ); } } } } let failures = result.updates_failed.len() + result.removals_failed.len() + result.additions_failed.len(); if failures == 0 { sync_cluster_loader_version_from_bundles(cluster_id, bundles, services).await?; } Ok(result) } async fn apply_single_update( update: &BundlePackageUpdate, overrides: &[ClusterBundleOverrideRow], child: Option<&crate::notification::GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult<()> { let hash = install_package_from_bundle( &update.new_file, update.cluster_id, &update.bundle_name, true, child, services, ) .await?; if let Some(child) = child { child.set_phase(crate::notification::TaskPhase::Installing); child.finish(); } let file_id = update.new_file.kind.package_id(); if should_be_disabled(&update.bundle_name, &file_id, overrides) { PackageStore::toggle_artifact_enabled(update.cluster_id, &hash, services).await?; } if hash == update.installed_hash { return Ok(()); } remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, services).await } async fn apply_single_addition( addition: &BundlePackageAddition, overrides: &[ClusterBundleOverrideRow], child: Option<&crate::notification::GroupedProgressChild>, services: &LauncherServices, ) -> LauncherResult<()> { let hash = install_package_from_bundle( &addition.new_file, addition.cluster_id, &addition.bundle_name, true, child, services, ) .await?; if let Some(child) = child { child.set_phase(crate::notification::TaskPhase::Installing); child.finish(); } let file_id = addition.new_file.kind.package_id(); if should_be_disabled(&addition.bundle_name, &file_id, overrides) { PackageStore::toggle_artifact_enabled(addition.cluster_id, &hash, services).await?; } Ok(()) } fn should_be_disabled( bundle_name: &str, package_id: &str, overrides: &[ClusterBundleOverrideRow], ) -> bool { overrides.iter().any(|o| { o.bundle_name == bundle_name && o.package_id == package_id && OverrideType::parse(&o.override_type) == Some(OverrideType::Disabled) }) } async fn sync_cluster_loader_version_from_bundles( cluster_id: i64, bundles: &BundlesManager, services: &LauncherServices, ) -> LauncherResult<()> { let cluster = PackageStore::get_cluster(cluster_id, services).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); let bundle_packages = bundle_dao::list_bundle_tracked(&services.db, cluster_id).await?; let all_linked = PackageStore::list_linked_artifacts(cluster_id, services).await?; let archives = bundles .archives_for(services, &cluster.mc_version, loader) .await?; let (managed_keys, _) = installed_bundle_keys(services, &all_linked).await?; let subscribed: HashSet = bundle_packages .iter() .filter_map(|bp| bp.bundle_name.clone()) .chain(infer_subscribed_from_archives(&archives, &managed_keys)) .collect(); let target = select_highest_loader_version( archives .iter() .filter(|a| subscribed.contains(&a.manifest.name)) .map(|a| a.manifest.loader_version.as_str()), ); let Some(target) = target else { return Ok(()); }; if cluster.mc_loader_version.as_deref() == Some(target.as_str()) { return Ok(()); } cluster_dao::update( &services.db, cluster_id, &ClusterPatch { mc_loader_version: Some(Some(target)), ..Default::default() }, ) .await?; Ok(()) } pub async fn get_bundles_with_update_status( cluster_id: i64, bundles: &BundlesManager, services: &LauncherServices, ) -> LauncherResult> { let cluster = PackageStore::get_cluster(cluster_id, services).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); let bundle_packages = bundle_dao::list_bundle_tracked(&services.db, cluster_id).await?; let all_linked = PackageStore::list_linked_artifacts(cluster_id, services).await?; let all_linked_by_hash: HashMap = all_linked.iter().map(|a| (a.hash.clone(), a)).collect(); let mut installed_map: HashMap = HashMap::new(); for bundle_pkg in &bundle_packages { let Some(pkg_id) = &bundle_pkg.package_id else { continue; }; let key = bundle_package_key(bundle_pkg, &all_linked_by_hash, pkg_id); installed_map.insert(key, bundle_pkg); } let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; let overrides_map: HashMap<(String, String), OverrideType> = overrides .iter() .filter_map(|o| { OverrideType::parse(&o.override_type) .map(|t| ((o.bundle_name.clone(), o.package_id.clone()), t)) }) .collect(); let archives = bundles .archives_for(services, &cluster.mc_version, loader) .await?; let mut results = Vec::new(); for archive in archives { let mut files = Vec::new(); let mut has_updates = false; for file in &archive.manifest.files { let status = match &file.kind { BundleFileKind::Managed { provider, project_id, version_id, .. } => { let key = managed_bundle_key(*provider, project_id); if let Some(installed) = installed_map.get(&key) { let installed_version = installed.bundle_version_id.as_deref().unwrap_or(""); if installed_version == version_id.as_str() { FileUpdateStatus::UpToDate } else { has_updates = true; FileUpdateStatus::UpdateAvailable { installed_version_id: installed_version.to_string(), new_version_id: version_id.clone(), } } } else if matches!( overrides_map.get(&(archive.manifest.name.clone(), project_id.clone())), Some(OverrideType::Removed) ) { FileUpdateStatus::RemovedByUser } else { FileUpdateStatus::NotInstalled } } BundleFileKind::External(ext) => { let key = external_bundle_key(&ext.sha1); if installed_map.contains_key(&key) { FileUpdateStatus::UpToDate } else if matches!( overrides_map.get(&(archive.manifest.name.clone(), ext.sha1.clone())), Some(OverrideType::Removed) ) { FileUpdateStatus::RemovedByUser } else { FileUpdateStatus::NotInstalled } } }; files.push((file.clone(), status)); } results.push(BundleWithUpdateStatus { archive, files, has_updates, }); } Ok(results) } pub async fn apply_bundle_updates_for_all_clusters( bundles: &BundlesManager, services: &LauncherServices, ) -> LauncherResult> { let mut changed = Vec::new(); for cluster in cluster_dao::list_all(&services.db).await? { match apply_bundle_updates(cluster.id, bundles, services).await { Ok(result) => { if !result.updates_applied.is_empty() || !result.additions_applied.is_empty() || !result.removals_applied.is_empty() { changed.push((cluster.id, result)); } } Err(err) => tracing::warn!( cluster_id = cluster.id, error = %err, "bundle update apply failed for cluster" ), } } Ok(changed) } fn bundle_package_key( bundle_pkg: &BundleTrackedArtifactRow, linked: &HashMap, package_id: &str, ) -> String { if package_id == bundle_pkg.hash { external_bundle_key(&bundle_pkg.hash) } else if let Some(info) = linked.get(&bundle_pkg.hash) { managed_bundle_key(info.provider.unwrap_or(ProviderId::Modrinth), package_id) } else { managed_bundle_key(ProviderId::Modrinth, package_id) } } async fn installed_bundle_keys( services: &LauncherServices, linked: &[LinkedArtifactInfo], ) -> LauncherResult<(HashSet, HashSet)> { let mut managed = HashSet::new(); let mut external = HashSet::new(); for item in linked { if let Some(release) = artifact_dao::get_release_by_hash(&services.db, &item.hash).await? { let provider = ProviderId::from_repr(release.provider as u8).unwrap_or(ProviderId::Modrinth); managed.insert(managed_bundle_key(provider, &release.project_id)); } else { external.insert(item.hash.clone()); } } Ok((managed, external)) } fn collect_inferable_managed_keys( bundle_files_map: &HashMap, hidden_dependency_keys: &HashSet, hidden_explicit_keys: &HashSet, ) -> HashSet { bundle_files_map .keys() .filter(|key| { key.starts_with("m:") && !hidden_dependency_keys.contains(*key) && !hidden_explicit_keys.contains(*key) }) .cloned() .collect() } fn infer_bundle_names_from_unique_installed_keys( candidate_keys_by_bundle: &HashMap>, all_installed_managed_keys: &HashSet, ) -> HashSet { let mut key_occurrence_count: HashMap = HashMap::new(); for candidate_keys in candidate_keys_by_bundle.values() { for key in candidate_keys { *key_occurrence_count.entry(key.clone()).or_default() += 1; } } candidate_keys_by_bundle .iter() .filter_map(|(bundle_name, candidate_keys)| { let has_unique = candidate_keys.iter().any(|key| { all_installed_managed_keys.contains(key) && key_occurrence_count.get(key).copied().unwrap_or_default() == 1 }); if has_unique { Some(bundle_name.clone()) } else { None } }) .collect() } fn infer_subscribed_from_archives( archives: &[BundleArchive], managed_keys: &HashSet, ) -> HashSet { let mut candidate_keys_by_bundle = HashMap::new(); for archive in archives { let mut keys = HashSet::new(); for file in &archive.manifest.files { if !file.enabled { continue; } if let BundleFileKind::Managed { provider, project_id, .. } = &file.kind && !file.hidden { keys.insert(managed_bundle_key(*provider, project_id)); } } candidate_keys_by_bundle.insert(archive.manifest.name.clone(), keys); } infer_bundle_names_from_unique_installed_keys(&candidate_keys_by_bundle, managed_keys) } 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: Vec = left .split(|c: char| !c.is_ascii_alphanumeric()) .filter(|s| !s.is_empty()) .map(str::to_ascii_lowercase) .collect(); let right_segments: Vec = right .split(|c: char| !c.is_ascii_alphanumeric()) .filter(|s| !s.is_empty()) .map(str::to_ascii_lowercase) .collect(); 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(_), None) => return Ordering::Greater, (None, Some(_)) => 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(str::to_string) }