From 87cb86943cb208a1296932a131ba155576170606 Mon Sep 17 00:00:00 2001 From: Wyvest Date: Mon, 23 Feb 2026 22:36:12 -0500 Subject: [PATCH] Add bundle update checking/apply and API Introduce full bundle update management: adds bundle_updates module with checking, apply flow, per-cluster locking, subscription inference, and apply helpers for additions/updates/removals (plus unit and e2e test scaffold). Exposes new IPC/API methods (download_package_from_bundle, update_bundle_packages, is_bundle_syncing) and integrates bundle tracking in desktop commands. Adds bundle DAO/entity and migrations to support bundle_packages and bundle_overrides, updates related package/DAO/modpack code, and minor frontend bindings/components/hooks to surface the new functionality. Also adds sea-orm as a dev-dependency for the desktop crate. Co-Authored-By: Jacob --- Cargo.lock | 1 + apps/oneclient/desktop/Cargo.toml | 3 + apps/oneclient/desktop/src/api/commands.rs | 89 +- .../desktop/src/oneclient/bundle_updates.rs | 1283 +++++++++++++++++ .../src/oneclient/bundle_updates_test.rs | 408 ++++++ .../desktop/src/oneclient/bundles.rs | 71 +- apps/oneclient/desktop/src/oneclient/mod.rs | 160 +- apps/oneclient/frontend/src/bindings.gen.ts | 41 +- .../frontend/src/components/Bundle/Bundle.tsx | 3 +- .../src/components/Bundle/ModList.tsx | 10 +- .../frontend/src/components/DownloadMods.tsx | 76 +- .../overlay/SuperSecretDevOptions.tsx | 6 +- .../frontend/src/hooks/useClusterDirWatch.ts | 6 + .../src/routes/app/settings/developer.tsx | 5 - .../routes/onboarding/preferences/version.tsx | 8 +- .../preferences/versionCategory.tsx | 64 +- packages/core/src/api/packages/bundle_dao.rs | 301 ++++ packages/core/src/api/packages/dao.rs | 8 + packages/core/src/api/packages/mod.rs | 180 ++- .../core/src/api/packages/modpack/data.rs | 2 + .../core/src/api/packages/modpack/mrpack.rs | 176 +++ .../src/api/packages/modpack/polymrpack.rs | 8 +- packages/core/src/api/tauri/commands.rs | 3 +- packages/core/src/store/settings.rs | 2 - packages/core/src/utils/http.rs | 16 - .../src/entities/cluster_bundle_overrides.rs | 47 + .../entity/src/entities/cluster_packages.rs | 4 + packages/entity/src/entities/mod.rs | 1 + packages/entity/src/entities/packages.rs | 5 +- packages/entity/src/entities/prelude.rs | 4 + packages/macro/src/error.rs | 1 + packages/migration/src/lib.rs | 12 +- ...m20260222_000001_create_bundle_packages.rs | 144 ++ ...23_000001_create_bundle_overrides_table.rs | 127 ++ 34 files changed, 3120 insertions(+), 155 deletions(-) create mode 100644 apps/oneclient/desktop/src/oneclient/bundle_updates.rs create mode 100644 apps/oneclient/desktop/src/oneclient/bundle_updates_test.rs create mode 100644 packages/core/src/api/packages/bundle_dao.rs create mode 100644 packages/entity/src/entities/cluster_bundle_overrides.rs create mode 100644 packages/migration/src/m20260222_000001_create_bundle_packages.rs create mode 100644 packages/migration/src/m20260223_000001_create_bundle_overrides_table.rs diff --git a/Cargo.lock b/Cargo.lock index 39da9f10..1c67fc9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3732,6 +3732,7 @@ dependencies = [ "objc2-foundation 0.3.2", "onelauncher_core", "reqwest", + "sea-orm", "serde", "serde_json", "specta", diff --git a/apps/oneclient/desktop/Cargo.toml b/apps/oneclient/desktop/Cargo.toml index 7dab2fbf..079bfb4e 100644 --- a/apps/oneclient/desktop/Cargo.toml +++ b/apps/oneclient/desktop/Cargo.toml @@ -74,6 +74,9 @@ features = [ "specta" ] +[dev-dependencies] +sea-orm = { workspace = true } + [features] default = [ "custom-protocol", diff --git a/apps/oneclient/desktop/src/api/commands.rs b/apps/oneclient/desktop/src/api/commands.rs index 7ed07b76..aeb56580 100644 --- a/apps/oneclient/desktop/src/api/commands.rs +++ b/apps/oneclient/desktop/src/api/commands.rs @@ -1,12 +1,14 @@ use std::collections::HashMap; use std::path::PathBuf; +use onelauncher_core::api; use onelauncher_core::api::cluster::dao::ClusterId; -use onelauncher_core::api::packages::modpack::data::ModpackArchive; -use onelauncher_core::entity::clusters; +use onelauncher_core::api::packages::modpack::data::{ModpackArchive, ModpackFileKind}; +use onelauncher_core::entity::{clusters, packages}; use onelauncher_core::error::LauncherResult; use crate::ext::updater::Update; +use crate::oneclient::bundle_updates::ApplyBundleUpdatesResult; use crate::oneclient::bundles::BundlesManager; use crate::oneclient::clusters::{OnlineClusterManifest, get_data_storage_versions}; use tauri::{AppHandle, Runtime}; @@ -35,6 +37,22 @@ pub trait OneClientApi { #[taurpc(alias = "installUpdate")] async fn install_update(app_handle: AppHandle) -> LauncherResult<()>; + + #[taurpc(alias = "downloadPackageFromBundle")] + async fn download_package_from_bundle( + package: ModpackFileKind, + cluster_id: ClusterId, + bundle_name: String, + skip_compatibility: Option, + ) -> LauncherResult; + + #[taurpc(alias = "updateBundlePackages")] + async fn update_bundle_packages( + cluster_id: ClusterId, + ) -> LauncherResult; + + #[taurpc(alias = "isBundleSyncing")] + async fn is_bundle_syncing() -> LauncherResult; } #[taurpc::ipc_type] @@ -50,14 +68,12 @@ impl OneClientApi for OneClientApiImpl { let mut mapped: HashMap> = HashMap::new(); for cluster in clusters { - // Assuming `mc_version` is a String and you have a function to parse major version let split = &mut cluster.mc_version.split('.'); split.next(); if let Some(major) = split.next() { - // Convert the major version to a u32 let major: u32 = match major.parse() { Ok(v) => v, - Err(_) => continue, // Skip if parsing fails + Err(_) => continue, }; mapped.entry(major).or_default().push(cluster); @@ -127,4 +143,67 @@ impl OneClientApi for OneClientApiImpl { .await .map_err(|e| onelauncher_core::error::LauncherError::from(anyhow::anyhow!(e))) } + + async fn download_package_from_bundle( + self, + package: ModpackFileKind, + cluster_id: ClusterId, + bundle_name: String, + skip_compatibility: Option, + ) -> LauncherResult { + let cluster = api::cluster::dao::get_cluster_by_id(cluster_id) + .await? + .ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", cluster_id))?; + + match package { + ModpackFileKind::Managed((pkg, version)) => { + let model = api::packages::download_package(&pkg, &version, None, None).await?; + api::packages::link_package(&model, &cluster, skip_compatibility).await?; + api::packages::bundle_dao::track_bundle_package( + &cluster, + &model, + &bundle_name, + &version.version_id, + ) + .await?; + Ok(model) + } + + ModpackFileKind::External(ext_package) => { + let model = api::packages::download_external_package( + &ext_package, + &cluster, + None, + skip_compatibility.or(Some(true)), + None, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("Failed to download external package"))?; + + api::packages::link_package(&model, &cluster, skip_compatibility.or(Some(true))) + .await?; + + api::packages::bundle_dao::track_bundle_package( + &cluster, + &model, + &bundle_name, + &ext_package.sha1, + ) + .await?; + + Ok(model) + } + } + } + + async fn update_bundle_packages( + self, + cluster_id: ClusterId, + ) -> LauncherResult { + crate::oneclient::bundle_updates::apply_bundle_updates(cluster_id).await + } + + async fn is_bundle_syncing(self) -> LauncherResult { + Ok(crate::oneclient::is_bundle_syncing()) + } } diff --git a/apps/oneclient/desktop/src/oneclient/bundle_updates.rs b/apps/oneclient/desktop/src/oneclient/bundle_updates.rs new file mode 100644 index 00000000..636cbd9a --- /dev/null +++ b/apps/oneclient/desktop/src/oneclient/bundle_updates.rs @@ -0,0 +1,1283 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock}; + +use chrono::{DateTime, Utc}; +use onelauncher_core::api::cluster::dao::ClusterId; +use onelauncher_core::api::packages::bundle_dao; +use onelauncher_core::api::packages::modpack::data::{ + ModpackArchive, ModpackFile, ModpackFileKind, +}; +use onelauncher_core::entity::cluster_packages; +use onelauncher_core::entity::package::Provider; +use onelauncher_core::error::LauncherResult; +use onelauncher_core::{api, send_error}; + +use crate::oneclient::bundles::BundlesManager; + +/// Per-cluster mutex map to prevent concurrent bundle updates on the same cluster. +static CLUSTER_UPDATE_LOCKS: OnceLock>>>> = + OnceLock::new(); + +fn get_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(tokio::sync::Mutex::new(()))) + .clone() +} + +fn managed_bundle_key(provider: &Provider, package_id: &str) -> String { + format!("m:{}:{}", provider.name(), package_id) +} + +fn external_bundle_key(sha1: &str) -> String { + format!("e:{sha1}") +} + +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_installed_key = candidate_keys.iter().any(|key| { + all_installed_managed_keys.contains(key) + && key_occurrence_count.get(key).copied().unwrap_or_default() == 1 + }); + + if has_unique_installed_key { + Some(bundle_name.clone()) + } else { + None + } + }) + .collect() +} + +#[taurpc::ipc_type] +pub struct BundlePackageUpdate { + pub cluster_id: ClusterId, + pub installed_package_hash: String, + pub installed_version_id: String, + pub bundle_name: String, + pub new_version_id: String, + pub new_file: ModpackFile, + pub installed_at: DateTime, +} + +#[taurpc::ipc_type] +pub struct BundlePackageRemoval { + pub cluster_id: ClusterId, + pub package_hash: String, + pub package_id: String, + pub bundle_name: String, + pub installed_at: DateTime, +} + +#[taurpc::ipc_type] +pub struct BundlePackageAddition { + pub cluster_id: ClusterId, + pub bundle_name: String, + pub new_file: ModpackFile, +} + +#[taurpc::ipc_type] +pub struct BundleUpdateCheckResult { + pub cluster_id: ClusterId, + pub updates_available: Vec, + pub removals_available: Vec, + pub additions_available: Vec, + pub checked_at: DateTime, +} + +pub async fn check_bundle_updates( + cluster_id: ClusterId, +) -> LauncherResult { + let overrides = bundle_dao::get_bundle_overrides(cluster_id).await?; + check_bundle_updates_inner(cluster_id, &overrides).await +} + +async fn check_bundle_updates_inner( + cluster_id: ClusterId, + overrides: &[onelauncher_core::entity::cluster_bundle_overrides::Model], +) -> LauncherResult { + tracing::debug!(cluster_id = %cluster_id, "Starting bundle update check"); + + let cluster = onelauncher_core::api::cluster::dao::get_cluster_by_id(cluster_id) + .await? + .ok_or_else(|| { + onelauncher_core::error::LauncherError::from(anyhow::anyhow!( + "cluster with id {} not found", + cluster_id + )) + })?; + + tracing::debug!( + cluster_id = %cluster_id, + cluster_name = %cluster.name, + mc_version = %cluster.mc_version, + mc_loader = ?cluster.mc_loader, + "Found cluster for update check" + ); + + let bundle_packages = bundle_dao::get_bundle_packages_for_cluster(cluster_id).await?; + + tracing::debug!( + cluster_id = %cluster_id, + package_count = %bundle_packages.len(), + "Retrieved bundle packages from database" + ); + + // Fetch ALL packages in the cluster (not just bundle-tracked ones) so we can + // infer bundle subscriptions from packages installed via the regular flow. + let all_linked_packages = api::packages::dao::get_linked_packages(&cluster).await?; + let all_linked_by_hash: std::collections::HashMap< + String, + &onelauncher_core::entity::packages::Model, + > = all_linked_packages + .iter() + .map(|pkg| (pkg.hash.clone(), pkg)) + .collect(); + + let all_installed_managed_keys: std::collections::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(); + let all_installed_external_hashes: std::collections::HashSet = all_linked_packages + .iter() + .filter(|pkg| pkg.package_id == pkg.hash) + .map(|pkg| pkg.hash.clone()) + .collect(); + + tracing::debug!( + cluster_id = %cluster_id, + total_installed = %all_linked_packages.len(), + "Retrieved all linked packages for subscription inference" + ); + + let bundles = BundlesManager::get() + .await + .get_bundles_for(&cluster.mc_version, cluster.mc_loader) + .await?; + + tracing::debug!( + cluster_id = %cluster_id, + bundle_count = %bundles.len(), + bundle_names = ?bundles.iter().map(|b| &b.manifest.name).collect::>(), + "Retrieved available bundles" + ); + + let mut bundle_versions: std::collections::HashMap< + String, + std::collections::HashMap, // key: managed(provider+id) or external(sha1) + > = std::collections::HashMap::new(); + let mut hidden_dependency_keys_by_bundle: std::collections::HashMap< + String, + std::collections::HashSet, + > = std::collections::HashMap::new(); + let mut hidden_explicit_keys_by_bundle: std::collections::HashMap< + String, + std::collections::HashSet, + > = std::collections::HashMap::new(); + + for bundle in &bundles { + let mut enabled_count = 0; + let mut disabled_count = 0; + let mut files_map = std::collections::HashMap::new(); + let mut hidden_dependency_keys = std::collections::HashSet::new(); + let mut hidden_explicit_keys = std::collections::HashSet::new(); + + for file in &bundle.manifest.files { + if !file.enabled { + disabled_count += 1; + continue; + } + enabled_count += 1; + + match &file.kind { + ModpackFileKind::Managed((pkg, version)) => { + let key = managed_bundle_key(&pkg.provider, &pkg.id); + tracing::trace!( + bundle_name = %bundle.manifest.name, + provider = %pkg.provider, + package_id = %pkg.id, + version_id = %version.version_id, + "Indexed managed bundle package" + ); + files_map.insert(key, (version.version_id.clone(), file.clone())); + if file.hidden { + hidden_explicit_keys.insert(managed_bundle_key(&pkg.provider, &pkg.id)); + } + + // Dependencies that are not explicit files are treated as hidden mods + // for subscription inference. + 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)); + } + } + } + ModpackFileKind::External(ext) => { + let key = external_bundle_key(&ext.sha1); + tracing::trace!( + bundle_name = %bundle.manifest.name, + sha1 = %ext.sha1, + "Indexed external bundle package" + ); + files_map.insert(key, (ext.sha1.clone(), file.clone())); + if file.hidden { + hidden_explicit_keys.insert(external_bundle_key(&ext.sha1)); + } + } + } + } + + // If a dependency is also an explicit bundle file, don't treat it as an + // inferred hidden dependency. + for visible_key in files_map.keys() { + hidden_dependency_keys.remove(visible_key); + } + + tracing::debug!( + bundle_name = %bundle.manifest.name, + enabled_packages = %enabled_count, + disabled_packages = %disabled_count, + total_files = %bundle.manifest.files.len(), + "Indexed bundle" + ); + hidden_dependency_keys_by_bundle + .insert(bundle.manifest.name.clone(), hidden_dependency_keys); + hidden_explicit_keys_by_bundle.insert(bundle.manifest.name.clone(), hidden_explicit_keys); + bundle_versions.insert(bundle.manifest.name.clone(), files_map); + } + + tracing::debug!( + total_indexed_bundles = %bundle_versions.len(), + "Finished indexing all bundle versions" + ); + + // Start with explicitly tracked bundles (packages installed via the bundle-aware path). + let mut subscribed_bundles: std::collections::HashSet = bundle_packages + .iter() + .filter_map(|bp| bp.bundle_name.clone()) + .collect(); + + // Infer subscriptions only from installed managed keys unique to a single bundle in this + // mc_version + loader set. Shared keys must not infer multiple bundles. + let mut candidate_keys_by_bundle: HashMap> = HashMap::new(); + for bundle in &bundles { + let Some(bundle_files_map) = bundle_versions.get(&bundle.manifest.name) else { + continue; + }; + let hidden_keys = hidden_dependency_keys_by_bundle + .get(&bundle.manifest.name) + .cloned() + .unwrap_or_default(); + let hidden_explicit_keys = hidden_explicit_keys_by_bundle + .get(&bundle.manifest.name) + .cloned() + .unwrap_or_default(); + let inferable_keys = + collect_inferable_managed_keys(bundle_files_map, &hidden_keys, &hidden_explicit_keys); + candidate_keys_by_bundle.insert(bundle.manifest.name.clone(), inferable_keys); + } + + let inferred_bundles = infer_bundle_names_from_unique_installed_keys( + &candidate_keys_by_bundle, + &all_installed_managed_keys, + ); + for bundle_name in inferred_bundles { + if subscribed_bundles.contains(&bundle_name) { + continue; + } + tracing::debug!( + bundle_name = %bundle_name, + "Inferring bundle subscription from unique installed package match" + ); + subscribed_bundles.insert(bundle_name); + } + + let subscribed_bundle_names: Vec<&str> = bundles + .iter() + .filter(|bundle| subscribed_bundles.contains(&bundle.manifest.name)) + .map(|bundle| bundle.manifest.name.as_str()) + .collect(); + + tracing::debug!( + cluster_id = %cluster_id, + subscribed_bundles = ?subscribed_bundles, + "Bundles this cluster is subscribed to" + ); + + let overrides_map: std::collections::HashMap< + (String, String), + onelauncher_core::entity::cluster_bundle_overrides::OverrideType, + > = overrides + .iter() + .map(|o| { + ( + (o.bundle_name.clone(), o.package_id.clone()), + o.override_type.clone(), + ) + }) + .collect(); + + let mut updates_available = Vec::new(); + let mut removals_available = Vec::new(); + let mut skipped_no_package_id = 0; + let mut skipped_no_version_id = 0; + let mut skipped_no_provider = 0; + let mut not_in_bundle = 0; + + for bundle_pkg in &bundle_packages { + let Some(ref pkg_id) = bundle_pkg.package_id else { + skipped_no_package_id += 1; + tracing::debug!( + package_hash = %bundle_pkg.package_hash, + "Skipping package: missing package_id" + ); + continue; + }; + let Some(ref installed_version_id) = bundle_pkg.bundle_version_id else { + skipped_no_version_id += 1; + tracing::debug!( + package_hash = %bundle_pkg.package_hash, + package_id = %pkg_id, + "Skipping package: missing bundle_version_id" + ); + continue; + }; + + let installed_key = if pkg_id == &bundle_pkg.package_hash { + external_bundle_key(&bundle_pkg.package_hash) + } else { + let Some(installed_pkg) = all_linked_by_hash.get(&bundle_pkg.package_hash) else { + skipped_no_provider += 1; + tracing::debug!( + package_hash = %bundle_pkg.package_hash, + package_id = %pkg_id, + "Skipping package: missing linked package metadata for provider-aware matching" + ); + continue; + }; + managed_bundle_key(&installed_pkg.provider, pkg_id) + }; + + if let Some(ref bundle_name) = bundle_pkg.bundle_name { + let mut matched_target: Option<(String, String, ModpackFile)> = + 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(), + ) + }) + }); + + // Bundles are combinable: if a package moved out of the tracked bundle but is still in + // any subscribed bundle, do not remove it. + if matched_target.is_none() { + for candidate_bundle_name in &subscribed_bundle_names { + if *candidate_bundle_name == bundle_name.as_str() { + continue; + } + if let Some((new_version_id, new_file)) = bundle_versions + .get(*candidate_bundle_name) + .and_then(|files_map| files_map.get(&installed_key)) + { + matched_target = Some(( + (*candidate_bundle_name).to_string(), + new_version_id.clone(), + new_file.clone(), + )); + break; + } + } + } + + if let Some((resolved_bundle_name, new_version_id, new_file)) = matched_target { + tracing::debug!( + package_id = %pkg_id, + installed_version = %installed_version_id, + bundle_version = %new_version_id, + bundle_name = %resolved_bundle_name, + "Checking bundle package for updates" + ); + if installed_version_id != &new_version_id { + tracing::info!( + package_id = %pkg_id, + installed_version = %installed_version_id, + bundle_version = %new_version_id, + bundle_name = %resolved_bundle_name, + "Update available for bundle package" + ); + updates_available.push(BundlePackageUpdate { + cluster_id, + installed_package_hash: bundle_pkg.package_hash.clone(), + installed_version_id: installed_version_id.clone(), + bundle_name: resolved_bundle_name, + new_version_id, + new_file, + installed_at: bundle_pkg.installed_at.unwrap_or_else(Utc::now), + }); + } else { + tracing::debug!( + package_id = %pkg_id, + version = %installed_version_id, + "Bundle package is up to date" + ); + } + } else { + not_in_bundle += 1; + tracing::info!( + package_id = %pkg_id, + package_hash = %bundle_pkg.package_hash, + bundle_name = %bundle_name, + "Package no longer in any subscribed bundle, marking for removal" + ); + removals_available.push(BundlePackageRemoval { + cluster_id, + package_hash: bundle_pkg.package_hash.clone(), + package_id: pkg_id.clone(), + bundle_name: bundle_name.clone(), + installed_at: bundle_pkg.installed_at.unwrap_or_else(Utc::now), + }); + } + } else { + tracing::debug!( + package_id = %pkg_id, + package_hash = %bundle_pkg.package_hash, + "Package not found in any bundle (no bundle name tracked)" + ); + } + } + + // Filter out updates for packages the user has explicitly removed. + // Without this, apply_single_update would try to replace a package that no longer exists. + updates_available.retain(|u| { + let file_id = match &u.new_file.kind { + ModpackFileKind::Managed((pkg, _)) => pkg.id.clone(), + ModpackFileKind::External(ext) => ext.sha1.clone(), + }; + !matches!( + overrides_map.get(&(u.bundle_name.clone(), file_id)), + Some(onelauncher_core::entity::cluster_bundle_overrides::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 bundle in &bundles { + if !subscribed_bundles.contains(&bundle.manifest.name) { + tracing::debug!( + bundle_name = %bundle.manifest.name, + "Skipping bundle - not subscribed" + ); + continue; + } + + for file in &bundle.manifest.files { + if !file.enabled { + continue; + } + + let file_key = match &file.kind { + ModpackFileKind::Managed((pkg, _)) => managed_bundle_key(&pkg.provider, &pkg.id), + ModpackFileKind::External(ext) => external_bundle_key(&ext.sha1), + }; + if planned_addition_keys.contains(&file_key) { + continue; + } + + let file_id = match &file.kind { + ModpackFileKind::Managed((pkg, _)) => pkg.id.clone(), + ModpackFileKind::External(ext) => ext.sha1.clone(), + }; + + // Check user overrides + if let Some(override_type) = + overrides_map.get(&(bundle.manifest.name.clone(), file_id.clone())) + { + if *override_type + == onelauncher_core::entity::cluster_bundle_overrides::OverrideType::Removed + { + tracing::info!( + bundle_name = %bundle.manifest.name, + file_id = %file_id, + "Skipping package addition due to user override 'Removed'" + ); + continue; + } + } + + tracing::info!( + bundle_name = %bundle.manifest.name, + file_id = %file_id, + "New package found in subscribed bundle, marking for addition" + ); + additions_available.push(BundlePackageAddition { + cluster_id, + bundle_name: bundle.manifest.name.clone(), + new_file: file.clone(), + }); + planned_addition_keys.insert(file_key); + } + } + + tracing::info!( + cluster_id = %cluster_id, + total_packages_checked = %bundle_packages.len(), + updates_found = %updates_available.len(), + removals_found = %removals_available.len(), + additions_found = %additions_available.len(), + skipped_no_package_id = %skipped_no_package_id, + skipped_no_version_id = %skipped_no_version_id, + skipped_no_provider = %skipped_no_provider, + not_in_bundle = %not_in_bundle, + "Bundle update check completed" + ); + + Ok(BundleUpdateCheckResult { + cluster_id, + updates_available, + removals_available, + additions_available, + checked_at: Utc::now(), + }) +} + +pub async fn get_bundles_with_update_status( + cluster_id: ClusterId, +) -> LauncherResult> { + let cluster = onelauncher_core::api::cluster::dao::get_cluster_by_id(cluster_id) + .await? + .ok_or_else(|| { + onelauncher_core::error::LauncherError::from(anyhow::anyhow!( + "cluster with id {} not found", + cluster_id + )) + })?; + + 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 all_linked_by_hash: std::collections::HashMap< + String, + &onelauncher_core::entity::packages::Model, + > = all_linked_packages + .iter() + .map(|pkg| (pkg.hash.clone(), pkg)) + .collect(); + + let mut installed_map: std::collections::HashMap = + std::collections::HashMap::new(); + for bundle_pkg in &bundle_packages { + let Some(pkg_id) = &bundle_pkg.package_id else { + continue; + }; + + let key = if pkg_id == &bundle_pkg.package_hash { + external_bundle_key(&bundle_pkg.package_hash) + } else if let Some(installed_pkg) = all_linked_by_hash.get(&bundle_pkg.package_hash) { + managed_bundle_key(&installed_pkg.provider, pkg_id) + } else { + continue; + }; + + installed_map.insert(key, bundle_pkg); + } + + let overrides = bundle_dao::get_bundle_overrides(cluster_id).await?; + let overrides_map: std::collections::HashMap< + (String, String), + onelauncher_core::entity::cluster_bundle_overrides::OverrideType, + > = overrides + .iter() + .map(|o| { + ( + (o.bundle_name.clone(), o.package_id.clone()), + o.override_type.clone(), + ) + }) + .collect(); + + let bundles = BundlesManager::get() + .await + .get_bundles_for(&cluster.mc_version, cluster.mc_loader) + .await?; + + let mut results = Vec::new(); + + for bundle in bundles { + let mut files_with_status = Vec::new(); + let mut has_updates = false; + + for file in &bundle.manifest.files { + let update_status = match &file.kind { + ModpackFileKind::Managed((pkg, version)) => { + let key = managed_bundle_key(&pkg.provider, &pkg.id); + if let Some(installed) = installed_map.get(&key) { + let installed_version = + installed.bundle_version_id.as_deref().unwrap_or(""); + if installed_version != version.version_id { + has_updates = true; + FileUpdateStatus::UpdateAvailable { + installed_version_id: installed_version.to_string(), + new_version_id: version.version_id.clone(), + } + } else { + FileUpdateStatus::UpToDate + } + } else { + let key = (bundle.manifest.name.clone(), pkg.id.clone()); + if matches!( + overrides_map.get(&key), + Some(onelauncher_core::entity::cluster_bundle_overrides::OverrideType::Removed) + ) { + FileUpdateStatus::RemovedByUser + } else { + FileUpdateStatus::NotInstalled + } + } + } + ModpackFileKind::External(ext) => { + let key = external_bundle_key(&ext.sha1); + if installed_map.contains_key(&key) { + FileUpdateStatus::UpToDate + } else { + let key = (bundle.manifest.name.clone(), ext.sha1.clone()); + if matches!( + overrides_map.get(&key), + Some(onelauncher_core::entity::cluster_bundle_overrides::OverrideType::Removed) + ) { + FileUpdateStatus::RemovedByUser + } else { + FileUpdateStatus::NotInstalled + } + } + } + }; + + files_with_status.push(FileWithUpdateStatus { + file: file.clone(), + status: update_status, + }); + } + + results.push(BundleWithUpdateStatus { + bundle, + files: files_with_status, + has_updates, + }); + } + + Ok(results) +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)] +pub enum FileUpdateStatus { + NotInstalled, + RemovedByUser, + UpToDate, + UpdateAvailable { + installed_version_id: String, + new_version_id: String, + }, +} + +#[taurpc::ipc_type] +pub struct FileWithUpdateStatus { + pub file: ModpackFile, + pub status: FileUpdateStatus, +} + +#[taurpc::ipc_type] +pub struct BundleWithUpdateStatus { + pub bundle: ModpackArchive, + pub files: Vec, + pub has_updates: bool, +} + +async fn apply_single_update( + update: &BundlePackageUpdate, + overrides: &[onelauncher_core::entity::cluster_bundle_overrides::Model], +) -> LauncherResult { + tracing::info!( + cluster_id = %update.cluster_id, + package_hash = %update.installed_package_hash, + bundle_name = %update.bundle_name, + old_version = %update.installed_version_id, + new_version = %update.new_version_id, + "Applying bundle package update" + ); + + let cluster = api::cluster::dao::get_cluster_by_id(update.cluster_id) + .await? + .ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", update.cluster_id))?; + + // Download the new version first. If it fails the old package remains untouched. + let model = match &update.new_file.kind { + ModpackFileKind::Managed((pkg, version)) => { + tracing::debug!( + package_id = %pkg.id, + version_id = %version.version_id, + "Downloading new managed package version" + ); + api::packages::download_package(pkg, version, None, None).await? + } + ModpackFileKind::External(ext_package) => { + tracing::debug!( + url = %ext_package.url, + sha1 = %ext_package.sha1, + "Downloading new external package version" + ); + api::packages::download_external_package(ext_package, &cluster, None, Some(true), None) + .await? + .ok_or_else(|| anyhow::anyhow!("Failed to download external package"))? + } + }; + + // Link and track the new package BEFORE removing the old one. + // If linking or tracking fails, the old package remains untouched. + tracing::debug!( + package_hash = %model.hash, + "Linking new package to cluster" + ); + api::packages::link_package(&model, &cluster, Some(true)).await?; + + let version_id = match &update.new_file.kind { + ModpackFileKind::Managed((_, version)) => version.version_id.clone(), + ModpackFileKind::External(ext) => ext.sha1.clone(), + }; + + tracing::debug!( + package_hash = %model.hash, + bundle_name = %update.bundle_name, + "Tracking new package as bundle package" + ); + api::packages::bundle_dao::track_bundle_package( + &cluster, + &model, + &update.bundle_name, + &version_id, + ) + .await?; + + // Check if this package should be disabled based on user overrides (passed in from caller) + let file_id = match &update.new_file.kind { + ModpackFileKind::Managed((pkg, _)) => pkg.id.clone(), + ModpackFileKind::External(ext) => ext.sha1.clone(), + }; + + let should_be_disabled = overrides.iter().any(|o| { + o.bundle_name == update.bundle_name + && o.package_id == file_id + && o.override_type + == onelauncher_core::entity::cluster_bundle_overrides::OverrideType::Disabled + }); + + if should_be_disabled { + if model.file_name.ends_with(".disabled") { + tracing::debug!( + package_hash = %model.hash, + bundle_name = %update.bundle_name, + "Updated package is already disabled; skipping toggle" + ); + } else { + tracing::info!( + package_hash = %model.hash, + bundle_name = %update.bundle_name, + "Re-applying disabled state to updated package" + ); + // toggle_package internally disables the mod if it's currently enabled + api::packages::toggle_package(update.cluster_id, model.hash.clone()).await?; + } + } + + // If the replacement resolves to the same package hash, there is nothing to remove. + // Removing here would unlink the package we just kept/retagged. + if model.hash == update.installed_package_hash { + tracing::debug!( + package_hash = %model.hash, + "Update resolved to the same package hash; skipping old-package removal" + ); + return Ok(model); + } + + // Remove the old package only after the new one is fully installed and tracked. + // Pass record_override=false: this is a system-initiated replacement, not a user removal. + tracing::debug!( + package_hash = %update.installed_package_hash, + "Removing old package" + ); + api::packages::remove_package( + update.cluster_id, + update.installed_package_hash.clone(), + false, + ) + .await?; + + tracing::info!( + new_hash = %model.hash, + "Successfully updated package" + ); + Ok(model) +} + +async fn apply_single_removal(removal: &BundlePackageRemoval) -> LauncherResult<()> { + tracing::info!( + cluster_id = %removal.cluster_id, + package_hash = %removal.package_hash, + package_id = %removal.package_id, + bundle_name = %removal.bundle_name, + "Removing package that was removed from bundle" + ); + + tracing::debug!( + package_hash = %removal.package_hash, + "Removing package from cluster" + ); + // Pass record_override=false: removal is bundle-driven (publisher dropped the package), + // not a user choice. If the publisher re-adds it later, it should be reinstalled. + api::packages::remove_package(removal.cluster_id, removal.package_hash.clone(), false).await?; + + tracing::info!( + package_id = %removal.package_id, + package_hash = %removal.package_hash, + "Successfully removed package that was removed from bundle" + ); + + Ok(()) +} + +async fn apply_single_addition( + addition: &BundlePackageAddition, + overrides: &[onelauncher_core::entity::cluster_bundle_overrides::Model], +) -> LauncherResult { + let file_id = match &addition.new_file.kind { + ModpackFileKind::Managed((pkg, _)) => pkg.id.clone(), + ModpackFileKind::External(ext) => ext.sha1.clone(), + }; + + tracing::info!( + cluster_id = %addition.cluster_id, + bundle_name = %addition.bundle_name, + file_id = %file_id, + "Installing new package from bundle" + ); + + let cluster = api::cluster::dao::get_cluster_by_id(addition.cluster_id) + .await? + .ok_or_else(|| anyhow::anyhow!("cluster with id {} not found", addition.cluster_id))?; + + match &addition.new_file.kind { + ModpackFileKind::Managed((pkg, version)) => { + tracing::debug!( + package_id = %pkg.id, + version_id = %version.version_id, + "Downloading new managed package" + ); + let model = api::packages::download_package(pkg, version, None, None).await?; + + tracing::debug!( + package_hash = %model.hash, + "Linking new package to cluster" + ); + api::packages::link_package(&model, &cluster, Some(true)).await?; + + tracing::debug!( + package_hash = %model.hash, + bundle_name = %addition.bundle_name, + "Tracking new package as bundle package" + ); + api::packages::bundle_dao::track_bundle_package( + &cluster, + &model, + &addition.bundle_name, + &version.version_id, + ) + .await?; + + tracing::info!( + package_id = %pkg.id, + package_hash = %model.hash, + "Successfully installed new managed package from bundle" + ); + + let should_be_disabled = overrides.iter().any(|o| { + o.bundle_name == addition.bundle_name + && o.package_id == file_id + && o.override_type == onelauncher_core::entity::cluster_bundle_overrides::OverrideType::Disabled + }); + + if should_be_disabled { + if model.file_name.ends_with(".disabled") { + tracing::debug!( + package_hash = %model.hash, + bundle_name = %addition.bundle_name, + "Added package is already disabled; skipping toggle" + ); + } else { + tracing::info!( + package_hash = %model.hash, + bundle_name = %addition.bundle_name, + "Applying disabled state to newly added package due to overrides" + ); + api::packages::toggle_package(addition.cluster_id, model.hash.clone()).await?; + } + } + + Ok(model) + } + ModpackFileKind::External(ext_package) => { + tracing::debug!( + url = %ext_package.url, + sha1 = %ext_package.sha1, + "Downloading new external package" + ); + let model = api::packages::download_external_package( + ext_package, + &cluster, + None, + Some(true), + None, + ) + .await? + .ok_or_else(|| anyhow::anyhow!("Failed to download external package"))?; + + tracing::debug!( + package_hash = %model.hash, + "Linking new external package to cluster" + ); + api::packages::link_package(&model, &cluster, Some(true)).await?; + + tracing::debug!( + package_hash = %model.hash, + bundle_name = %addition.bundle_name, + "Tracking new external package as bundle package" + ); + api::packages::bundle_dao::track_bundle_package( + &cluster, + &model, + &addition.bundle_name, + &ext_package.sha1, + ) + .await?; + + tracing::info!( + url = %ext_package.url, + package_hash = %model.hash, + "Successfully installed new external package from bundle" + ); + + let should_be_disabled = overrides.iter().any(|o| { + o.bundle_name == addition.bundle_name + && o.package_id == file_id + && o.override_type == onelauncher_core::entity::cluster_bundle_overrides::OverrideType::Disabled + }); + + if should_be_disabled { + if model.file_name.ends_with(".disabled") { + tracing::debug!( + package_hash = %model.hash, + bundle_name = %addition.bundle_name, + "Added external package is already disabled; skipping toggle" + ); + } else { + tracing::info!( + package_hash = %model.hash, + bundle_name = %addition.bundle_name, + "Applying disabled state to newly added package due to overrides" + ); + api::packages::toggle_package(addition.cluster_id, model.hash.clone()).await?; + } + } + + Ok(model) + } + } +} + +#[taurpc::ipc_type] +pub struct ApplyBundleUpdatesResult { + pub updates_applied: Vec, + pub removals_applied: Vec, + pub additions_applied: Vec, + pub updates_failed: Vec, + pub removals_failed: Vec, + pub additions_failed: Vec, +} + +pub async fn apply_bundle_updates( + cluster_id: ClusterId, +) -> LauncherResult { + // Hold a per-cluster lock for the duration of the update to prevent concurrent + // apply calls on the same cluster from racing each other. + let cluster_lock = get_cluster_lock(cluster_id); + let _guard = cluster_lock.lock().await; + + // Fetch overrides once: shared by both the check and the apply pass. + let cluster_overrides = api::packages::bundle_dao::get_bundle_overrides(cluster_id).await?; + let check_result = check_bundle_updates_inner(cluster_id, &cluster_overrides).await?; + + let mut updates_applied = Vec::new(); + let mut removals_applied = Vec::new(); + let mut additions_applied = Vec::new(); + let mut updates_failed = Vec::new(); + let mut removals_failed = Vec::new(); + let mut additions_failed = Vec::new(); + + for removal in check_result.removals_available { + match apply_single_removal(&removal).await { + Ok(_) => { + removals_applied.push(removal); + } + Err(e) => { + let msg = format!( + "Failed to remove bundle package '{}': {}", + removal.package_id, e + ); + send_error!("{}", msg); + removals_failed.push(msg); + } + } + } + + for update in check_result.updates_available { + match apply_single_update(&update, &cluster_overrides).await { + Ok(_) => { + updates_applied.push(update); + } + Err(e) => { + let msg = format!("Failed to update bundle package: {}", e); + send_error!("{}", msg); + updates_failed.push(msg); + } + } + } + + for addition in check_result.additions_available { + let file_id = match &addition.new_file.kind { + ModpackFileKind::Managed((pkg, _)) => pkg.id.clone(), + ModpackFileKind::External(ext) => ext.sha1.clone(), + }; + match apply_single_addition(&addition, &cluster_overrides).await { + Ok(_) => { + additions_applied.push(addition); + } + Err(e) => { + let msg = format!("Failed to install new bundle package '{}': {}", file_id, e); + send_error!("{}", msg); + additions_failed.push(msg); + } + } + } + + // Re-extract overrides from any bundle that had changes applied. + // This keeps config files, resource packs, and other overridden assets in sync. + let has_changes = !updates_applied.is_empty() + || !removals_applied.is_empty() + || !additions_applied.is_empty(); + + if has_changes { + let mut affected_bundles: std::collections::HashSet = + std::collections::HashSet::new(); + + for u in &updates_applied { + affected_bundles.insert(u.bundle_name.clone()); + } + for r in &removals_applied { + affected_bundles.insert(r.bundle_name.clone()); + } + for a in &additions_applied { + affected_bundles.insert(a.bundle_name.clone()); + } + + if !affected_bundles.is_empty() { + match api::cluster::dao::get_cluster_by_id(cluster_id).await { + Ok(Some(cluster)) => { + match BundlesManager::get() + .await + .get_bundles_for(&cluster.mc_version, cluster.mc_loader) + .await + { + Ok(bundles) => { + for bundle in &bundles { + if affected_bundles.contains(&bundle.manifest.name) { + tracing::info!( + bundle_name = %bundle.manifest.name, + cluster_id = %cluster_id, + "Re-extracting overrides from updated bundle" + ); + if let Err(e) = + onelauncher_core::api::packages::modpack::mrpack::copy_overrides_folder_no_overwrite( + &cluster, + &bundle.path, + ) + .await + { + send_error!( + "Failed to extract overrides from bundle '{}': {}", + bundle.manifest.name, + e + ); + } + } + } + } + Err(e) => { + tracing::error!( + cluster_id = %cluster_id, + "Failed to retrieve bundles for override extraction after update: {}", + e + ); + } + } + } + Ok(None) => { + tracing::error!( + cluster_id = %cluster_id, + "Cluster not found during post-update override extraction" + ); + } + Err(e) => { + tracing::error!( + cluster_id = %cluster_id, + "Failed to retrieve cluster for override extraction after update: {}", + e + ); + } + } + } + } + + Ok(ApplyBundleUpdatesResult { + updates_applied, + removals_applied, + additions_applied, + updates_failed, + removals_failed, + additions_failed, + }) +} + +#[cfg(test)] +mod inference_tests { + use std::collections::{HashMap, HashSet}; + + use super::infer_bundle_names_from_unique_installed_keys; + + fn set(values: &[&str]) -> HashSet { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn shared_only_installed_key_does_not_infer_any_bundle() { + let mut candidate_keys_by_bundle: HashMap> = HashMap::new(); + candidate_keys_by_bundle.insert("QoL".to_string(), set(&["m:modrinth:overflowparticles"])); + candidate_keys_by_bundle.insert( + "SkyBlock".to_string(), + set(&["m:modrinth:overflowparticles"]), + ); + + let inferred = infer_bundle_names_from_unique_installed_keys( + &candidate_keys_by_bundle, + &set(&["m:modrinth:overflowparticles"]), + ); + assert!(inferred.is_empty()); + } + + #[test] + fn unique_installed_key_infers_only_its_bundle() { + let mut candidate_keys_by_bundle: HashMap> = HashMap::new(); + candidate_keys_by_bundle.insert( + "QoL".to_string(), + set(&["m:modrinth:overflowparticles", "m:modrinth:autotip"]), + ); + candidate_keys_by_bundle.insert( + "SkyBlock".to_string(), + set(&["m:modrinth:overflowparticles"]), + ); + + let inferred = infer_bundle_names_from_unique_installed_keys( + &candidate_keys_by_bundle, + &set(&["m:modrinth:overflowparticles", "m:modrinth:autotip"]), + ); + assert_eq!(inferred, set(&["QoL"])); + } + + #[test] + fn explicit_subscriptions_remain_even_without_unique_matches() { + let mut candidate_keys_by_bundle: HashMap> = HashMap::new(); + candidate_keys_by_bundle.insert("QoL".to_string(), set(&["m:modrinth:overflowparticles"])); + candidate_keys_by_bundle.insert( + "SkyBlock".to_string(), + set(&["m:modrinth:overflowparticles"]), + ); + + let inferred = infer_bundle_names_from_unique_installed_keys( + &candidate_keys_by_bundle, + &set(&["m:modrinth:overflowparticles"]), + ); + + let mut subscribed = set(&["SkyBlock"]); + subscribed.extend(inferred); + assert_eq!(subscribed, set(&["SkyBlock"])); + } + + #[test] + fn single_bundle_can_still_be_inferred() { + let mut candidate_keys_by_bundle: HashMap> = HashMap::new(); + candidate_keys_by_bundle.insert( + "QoL".to_string(), + set(&["m:modrinth:overflowparticles", "m:modrinth:autotip"]), + ); + + let inferred = infer_bundle_names_from_unique_installed_keys( + &candidate_keys_by_bundle, + &set(&["m:modrinth:overflowparticles"]), + ); + assert_eq!(inferred, set(&["QoL"])); + } +} + +#[cfg(test)] +#[path = "bundle_updates_test.rs"] +mod bundle_updates_test; diff --git a/apps/oneclient/desktop/src/oneclient/bundle_updates_test.rs b/apps/oneclient/desktop/src/oneclient/bundle_updates_test.rs new file mode 100644 index 00000000..9ecb62ca --- /dev/null +++ b/apps/oneclient/desktop/src/oneclient/bundle_updates_test.rs @@ -0,0 +1,408 @@ +#[cfg(test)] +mod tests { + use onelauncher_core::entity::loader::GameLoader; + use onelauncher_core::store::{Core, CoreOptions}; + use std::path::PathBuf; + use tokio::fs; + + use crate::oneclient::bundle_updates::{apply_bundle_updates, check_bundle_updates}; + use crate::oneclient::bundles::BundlesManager; + use onelauncher_core::api::cluster::{create_cluster, dao}; + use onelauncher_core::utils::DatabaseModelExt; + + // We only run this if we ignore it by default, since it hits the network and modifies DB + // but we can run it explicitly. + #[tokio::test] + #[ignore] + async fn test_bundle_updates_e2e() { + // 1. Initialize DB and paths + Core::initialize(CoreOptions::default()).await.unwrap(); + + // 2. Create a fresh cluster + let cluster_name = format!( + "Bundle Test {}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let cluster = create_cluster(&cluster_name, "1.21.1", GameLoader::Fabric, None, None) + .await + .unwrap(); + + let cluster_dir = cluster.path().await.unwrap(); + + // Ensure BundlesManager is initialized and fetches remote bundles + println!("Loaded BundlesManager: {:#?}", BundlesManager::get().await); + let retrieved_bundles = BundlesManager::get() + .await + .get_bundles_for(&cluster.mc_version, cluster.mc_loader) + .await + .unwrap(); + println!( + "get_bundles_for manually returned: {} bundles", + retrieved_bundles.len() + ); + for b in &retrieved_bundles { + println!( + "Bundle '{}' has {} files", + b.manifest.name, + b.manifest.files.len() + ); + } + + // Subscribe the cluster to one of the bundles manually to trigger additions + use sea_orm::{ActiveModelTrait, Set}; + + let dummy_hash = uuid::Uuid::new_v4().to_string(); + let dummy_base_pkg = onelauncher_core::entity::packages::ActiveModel { + hash: Set(dummy_hash.clone()), + file_name: Set("dummy.jar".to_string()), + version_id: Set("dummy_version".to_string()), + display_name: Set("Dummy".to_string()), + display_version: Set("1.0.0".to_string()), + package_type: Set(onelauncher_core::entity::package::PackageType::Mod), + provider: Set(onelauncher_core::entity::package::Provider::Modrinth), + package_id: Set("dummy_id".to_string()), + mc_versions: Set(onelauncher_core::entity::utility::DbVec(vec![ + "1.21.1".to_string(), + ])), + mc_loader: Set(onelauncher_core::entity::utility::DbVec(vec![ + onelauncher_core::entity::loader::GameLoader::Fabric, + ])), + published_at: Set(chrono::Utc::now()), + ..Default::default() + }; + dummy_base_pkg + .insert(&onelauncher_core::store::State::get().await.unwrap().db) + .await + .unwrap(); + + let dummy_pkg = onelauncher_core::entity::cluster_packages::ActiveModel { + cluster_id: Set(cluster.id), + package_hash: Set(dummy_hash.clone()), + bundle_name: Set(Some("OneClient 1.21.1 Fabric [Performance]".to_string())), + ..Default::default() + }; + dummy_pkg + .insert(&onelauncher_core::store::State::get().await.unwrap().db) + .await + .unwrap(); + + // 3. First apply: should detect additions for all bundle packages + let check1 = check_bundle_updates(cluster.id).await.unwrap(); + assert!( + !check1.additions_available.is_empty(), + "Expected fresh cluster to have bundle additions available" + ); + assert!(check1.updates_available.is_empty(), "Expected 0 updates"); + assert!(check1.removals_available.is_empty(), "Expected 0 removals"); + + let applied1 = apply_bundle_updates(cluster.id).await.unwrap(); + assert_eq!( + applied1.additions_applied.len(), + check1.additions_available.len() + ); + + // 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 + let user_config = cluster_dir.join("config").join("user_custom.txt"); + fs::create_dir_all(user_config.parent().unwrap()) + .await + .unwrap(); + fs::write(&user_config, b"user content").await.unwrap(); + + // We will modify an *existing* extracted override (if one exists), or we just let it be. + // For the sake of the test, let's artificially remove one package from the DB so the updater runs again + let installed_pkgs = + onelauncher_core::api::packages::bundle_dao::get_bundle_packages_for_cluster( + cluster.id, + ) + .await + .unwrap(); + let hash_to_remove = installed_pkgs + .into_iter() + .find(|p| { + p.package_hash != dummy_hash + && p.bundle_name.as_deref() == Some("OneClient 1.21.1 Fabric [Performance]") + }) + .unwrap() + .package_hash; + + // Remove it from the database and filesystem so the updater thinks it's missing. + // record_override=false: this simulates a missing package, not a deliberate user removal. + println!("Removing package hash: {}", hash_to_remove); + onelauncher_core::api::packages::remove_package(cluster.id, hash_to_remove, false) + .await + .unwrap(); + + // 5. Second apply: should detect 1 addition (the one we removed from DB) + let check2 = check_bundle_updates(cluster.id).await.unwrap(); + println!( + "check2 additions count: {}", + check2.additions_available.len() + ); + if check2.additions_available.len() != 1 { + println!( + "check2 additions: {:#?}", + check2 + .additions_available + .iter() + .map(|a| &a.new_file.kind) + .collect::>() + ); + } + assert_eq!( + check2.additions_available.len(), + 1, + "Expected exactly 1 addition available after DB deletion" + ); + assert_eq!(check2.updates_available.len(), 0); + assert_eq!(check2.removals_available.len(), 0); + + // Record modification time of the user_config + let mdata_before = fs::metadata(&user_config).await.unwrap(); + let mtime_before = mdata_before.modified().unwrap(); + + let applied2 = apply_bundle_updates(cluster.id).await.unwrap(); + assert_eq!(applied2.additions_applied.len(), 1); + + // 6. Verify user config was NOT overwritten or wiped (no_overwrite preserved it) + let mdata_after = fs::metadata(&user_config).await.unwrap(); + let mtime_after = mdata_after.modified().unwrap(); + + assert_eq!( + mtime_before, mtime_after, + "User config was modified during bundle update!" + ); + let content = fs::read_to_string(&user_config).await.unwrap(); + assert_eq!(content, "user content"); + + // Clean up + let _ = onelauncher_core::api::packages::dao::delete_package_by_id(dummy_hash).await; + let _ = dao::delete_cluster_by_id(cluster.id).await; + let _ = fs::remove_dir_all(cluster_dir).await; + println!("Bundle updates E2E test passed successfully"); + } + + #[tokio::test] + #[ignore] + async fn test_bundle_user_overrides() { + // 1. Initialize DB and paths + Core::initialize(CoreOptions::default()).await.unwrap(); + + // 2. Create a fresh cluster + let cluster_name = format!( + "Bundle Overrides Test {}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let cluster = create_cluster(&cluster_name, "1.21.1", GameLoader::Fabric, None, None) + .await + .unwrap(); + + let bundles = BundlesManager::get() + .await + .get_bundles_for(&cluster.mc_version, cluster.mc_loader) + .await + .unwrap(); + let target_bundle = bundles + .into_iter() + .find(|b| b.manifest.name.contains("Performance")) + .expect("Expected to find a Performance bundle"); + + // Subscribe cluster to bundle to trigger initial fetch + use sea_orm::{ActiveModelTrait, Set}; + + let dummy_hash = uuid::Uuid::new_v4().to_string(); + let dummy_base_pkg = onelauncher_core::entity::packages::ActiveModel { + hash: Set(dummy_hash.clone()), + file_name: Set("dummy.jar".to_string()), + version_id: Set("dummy_version".to_string()), + display_name: Set("Dummy".to_string()), + display_version: Set("1.0.0".to_string()), + package_type: Set(onelauncher_core::entity::package::PackageType::Mod), + provider: Set(onelauncher_core::entity::package::Provider::Modrinth), + package_id: Set("dummy_id".to_string()), + mc_versions: Set(onelauncher_core::entity::utility::DbVec(vec![ + "1.21.1".to_string(), + ])), + mc_loader: Set(onelauncher_core::entity::utility::DbVec(vec![ + onelauncher_core::entity::loader::GameLoader::Fabric, + ])), + published_at: Set(chrono::Utc::now()), + ..Default::default() + }; + dummy_base_pkg + .insert(&onelauncher_core::store::State::get().await.unwrap().db) + .await + .unwrap(); + + let dummy_pkg = onelauncher_core::entity::cluster_packages::ActiveModel { + cluster_id: Set(cluster.id), + package_hash: Set(dummy_hash.clone()), + bundle_name: Set(Some(target_bundle.manifest.name.clone())), + ..Default::default() + }; + dummy_pkg + .insert(&onelauncher_core::store::State::get().await.unwrap().db) + .await + .unwrap(); + + // 3. First apply: installs real bundle packages + let applied1 = apply_bundle_updates(cluster.id).await.unwrap(); + assert!( + !applied1.additions_applied.is_empty(), + "Expected initial additions from bundle" + ); + + // Get all installed packages mapping to the bundle + let installed_pkgs = + onelauncher_core::api::packages::bundle_dao::get_bundle_packages_for_cluster( + cluster.id, + ) + .await + .unwrap(); + + // Find two packages to test the two override types (Disabled, Removed) + let mut real_packages = installed_pkgs + .into_iter() + .filter(|p| p.package_hash != dummy_hash && p.package_id.is_some()); + + let disabled_target = real_packages + .next() + .expect("Expected at least 1 real package installed"); + let removed_target = real_packages + .next() + .expect("Expected at least 2 real packages installed"); + + // 4. Test "Removed" override behavior + println!( + "Testing Removed override on package: {}", + removed_target.package_id.as_ref().unwrap() + ); + + // Unlink/remove package, which should internally trigger the Overrides logic. + // record_override=true: this is an explicit user-initiated removal. + onelauncher_core::api::packages::remove_package( + cluster.id, + removed_target.package_hash.clone(), + true, + ) + .await + .unwrap(); + + // Check updates. The package should NOT appear as an addition since the user explicitly removed it. + let check_removed = check_bundle_updates(cluster.id).await.unwrap(); + + let is_offered_as_addition = check_removed.additions_available.iter().any(|a| { + let file_id = match &a.new_file.kind { + onelauncher_core::api::packages::modpack::data::ModpackFileKind::Managed(( + pkg, + _, + )) => pkg.id.clone(), + onelauncher_core::api::packages::modpack::data::ModpackFileKind::External(ext) => { + ext.sha1.clone() + } + }; + &file_id == removed_target.package_id.as_ref().unwrap() + }); + assert!( + !is_offered_as_addition, + "Package was removed by user but offered as an addition during bundle check!" + ); + + // 5. Test "Disabled" override behavior + println!( + "Testing Disabled override on package: {}", + disabled_target.package_id.as_ref().unwrap() + ); + + // Toggle package to disabled, should trigger Overrides logic + let is_now_enabled = onelauncher_core::api::packages::toggle_package( + cluster.id, + disabled_target.package_hash.clone(), + ) + .await + .unwrap(); + assert!(!is_now_enabled, "Package was not disabled successfully"); + + // Let's fake an update for the disabled package by altering its installed version ID + let dummy_pkg = onelauncher_core::entity::cluster_packages::ActiveModel { + cluster_id: Set(disabled_target.cluster_id), + package_hash: Set(disabled_target.package_hash.clone()), + bundle_version_id: Set(Some("old_fake_version_trigger_update".to_string())), + ..disabled_target.clone().into() + }; + // Ignore warnings about updating Primary Key fields + dummy_pkg + .update(&onelauncher_core::store::State::get().await.unwrap().db) + .await + .unwrap(); + + // Apply updates. The disabled package should be updated, BUT should remain disabled. + let _pre_update_path = onelauncher_core::api::packages::dao::get_package_by_hash( + disabled_target.package_hash.clone(), + ) + .await + .unwrap() + .unwrap() + .path() + .await + .unwrap(); + + let check_disabled = check_bundle_updates(cluster.id).await.unwrap(); + assert!( + check_disabled + .updates_available + .iter() + .any(|u| u.installed_package_hash == disabled_target.package_hash), + "Disabled package should still be marked for update" + ); + + let applied2 = apply_bundle_updates(cluster.id).await.unwrap(); + + let updated_pkg_hash = applied2 + .updates_applied + .into_iter() + .find(|u| u.installed_package_hash == disabled_target.package_hash) + .expect("Expected the disabled package to be updated") + .new_file; + + let file_id = match &updated_pkg_hash.kind { + onelauncher_core::api::packages::modpack::data::ModpackFileKind::Managed((pkg, _)) => { + pkg.id.clone() + } + onelauncher_core::api::packages::modpack::data::ModpackFileKind::External(ext) => { + ext.sha1.clone() + } + }; + + // Check the new package database entry and filesystem to ensure it's still disabled + let new_bundle_mapping = + onelauncher_core::api::packages::bundle_dao::get_bundle_package_by_package_id( + cluster.id, &file_id, + ) + .await + .unwrap() + .unwrap(); + let new_pkg_data = onelauncher_core::api::packages::dao::get_package_by_hash( + new_bundle_mapping.package_hash, + ) + .await + .unwrap() + .unwrap(); + + assert!( + new_pkg_data.file_name.ends_with(".disabled"), + "Updated bundle package was re-enabled! It should have kept the .disabled extension." + ); + + // Clean up + let _ = dao::delete_cluster_by_id(cluster.id).await; + let _ = fs::remove_dir_all(cluster.path().await.unwrap()).await; + } +} diff --git a/apps/oneclient/desktop/src/oneclient/bundles.rs b/apps/oneclient/desktop/src/oneclient/bundles.rs index 10430565..79c070ee 100644 --- a/apps/oneclient/desktop/src/oneclient/bundles.rs +++ b/apps/oneclient/desktop/src/oneclient/bundles.rs @@ -49,25 +49,31 @@ impl BundlesManager { .await } - #[tracing::instrument] + #[tracing::instrument(skip(self))] pub async fn get_bundles_for( &self, mc_version: &str, loader: onelauncher_core::entity::loader::GameLoader, ) -> LauncherResult> { let manifest = self.manifest.read().await; - let bundles_lock = self.bundles.read().await; + // Fast path: check cache under read lock. + let bundles_lock = self.bundles.read().await; if let Some(entry) = bundles_lock.get(mc_version) { if let Some(bundles) = entry.get(&loader) { return Ok(bundles.clone()); } } - - // drop read lock as we're gonna acquire a write lock this time drop(bundles_lock); + // Slow path: acquire write lock and re-check before populating (double-checked locking). + // This closes the TOCTOU gap between dropping the read lock and acquiring the write lock. let mut bundles_lock = self.bundles.write().await; + if let Some(entry) = bundles_lock.get(mc_version) { + if let Some(bundles) = entry.get(&loader) { + return Ok(bundles.clone()); + } + } let mut found = Vec::new(); @@ -180,42 +186,61 @@ async fn download_and_load_bundle( disk_path: &PathBuf, ) -> LauncherResult> { let url = format!("{}{}", crate::constants::META_URL_BASE, url_path); + // Sidecar file that stores the server ETag for cache freshness checks. + let etag_path = PathBuf::from(format!("{}.etag", disk_path.display())); if disk_path.exists() { - // we check if the remote file is different to the local file + // Check if the remote file differs from the local cached copy. let res = http::request(Method::HEAD, &url).await?; if !res.status().is_success() { return Err(anyhow::anyhow!("failed to download bundle from remote: {}", url).into()); } - if res.headers().get(reqwest::header::CONTENT_LENGTH).is_none() { - return Err(anyhow::anyhow!( - "bundle at {url} missing content-length header, skipping..." - ) - .into()); - } - - // TODO: check hash if provided in future, for now we check file size :( - let content_length = res + let server_etag = res .headers() - .get(header::CONTENT_LENGTH) + .get(reqwest::header::ETAG) .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); + .map(|s| s.to_string()); + let stored_etag: Option = tokio::fs::read_to_string(&etag_path).await.ok(); - let file_size = io::stat(disk_path).await.map(|m| m.len()).unwrap_or(0); + if let (Some(server), Some(stored)) = (&server_etag, &stored_etag) { + // ETag comparison: most reliable cache validation method. + if server == stored { + tracing::debug!("bundle cache hit via ETag for: {url}"); + return Ok(ModpackFormat::from_file(disk_path).await?); + } + } else { + // Fall back to Content-Length comparison when no ETag is available. + let content_length = res + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); - tracing::debug!("bundle content length: {content_length}, local file size: {file_size}"); - if content_length == file_size { - // file is up to date, load from disk - return Ok(ModpackFormat::from_file(disk_path).await?); + if let Some(length) = content_length { + let file_size = io::stat(disk_path).await.map(|m| m.len()).unwrap_or(0); + if length == file_size { + return Ok(ModpackFormat::from_file(disk_path).await?); + } + } + // If neither ETag nor Content-Length are available, fall through to re-download. } } tracing::debug!("downloading bundle from remote: {url}"); - // if we are at this point, it means we either need to update or download http::download(Method::GET, &url, disk_path, None, None).await?; + // After a fresh download, persist the server ETag for next time. + if let Ok(head_res) = http::request(Method::HEAD, &url).await { + if let Some(etag) = head_res + .headers() + .get(reqwest::header::ETAG) + .and_then(|v| v.to_str().ok()) + { + let _ = tokio::fs::write(&etag_path, etag).await; + } + } + Ok(ModpackFormat::from_file(disk_path).await?) } diff --git a/apps/oneclient/desktop/src/oneclient/mod.rs b/apps/oneclient/desktop/src/oneclient/mod.rs index fa501716..573f5257 100644 --- a/apps/oneclient/desktop/src/oneclient/mod.rs +++ b/apps/oneclient/desktop/src/oneclient/mod.rs @@ -1,6 +1,20 @@ +pub mod bundle_updates; pub mod bundles; pub mod clusters; +use std::sync::atomic::{AtomicBool, Ordering}; + +use onelauncher_core::api::cluster::dao::get_all_clusters; +use onelauncher_core::send_info; + +/// Global flag indicating whether a bundle sync is currently in progress. +/// Used by the frontend FS watcher to skip `syncCluster` calls during updates. +static BUNDLE_SYNCING: AtomicBool = AtomicBool::new(false); + +pub fn is_bundle_syncing() -> bool { + BUNDLE_SYNCING.load(Ordering::Relaxed) +} + pub async fn initialize_oneclient() { if let Err(err) = clusters::init_clusters().await { tracing::error!("failed to initialize clusters: {err}"); @@ -10,7 +24,151 @@ pub async fn initialize_oneclient() { tracing::error!("failed to sync clusters: {err}"); } + bundles::BundlesManager::get().await; tokio::spawn(async { - bundles::BundlesManager::get().await; + check_and_apply_all_bundle_updates().await; }); } + +async fn check_and_apply_all_bundle_updates() { + BUNDLE_SYNCING.store(true, Ordering::Relaxed); + tracing::info!("checking for bundle updates..."); + + let clusters = match get_all_clusters().await { + Ok(clusters) => clusters, + Err(err) => { + tracing::error!("failed to get clusters for bundle update check: {err}"); + BUNDLE_SYNCING.store(false, Ordering::Relaxed); + return; + } + }; + + let mut total_updates_applied = 0; + let mut total_removals_applied = 0; + let mut total_additions_applied = 0; + let mut total_clusters_failed = 0; + + for cluster in clusters { + tracing::debug!( + cluster_id = %cluster.id, + cluster_name = %cluster.name, + "Checking and applying bundle updates for cluster" + ); + + match bundle_updates::apply_bundle_updates(cluster.id).await { + Ok(result) => { + let update_count = result.updates_applied.len(); + let removal_count = result.removals_applied.len(); + let addition_count = result.additions_applied.len(); + let failure_count = result.updates_failed.len() + + result.removals_failed.len() + + result.additions_failed.len(); + if failure_count > 0 { + tracing::warn!( + "{failure_count} bundle operation(s) failed for cluster '{}'", + cluster.name + ); + } + + if update_count > 0 || removal_count > 0 || addition_count > 0 { + total_updates_applied += update_count; + total_removals_applied += removal_count; + total_additions_applied += addition_count; + + if update_count > 0 { + tracing::info!( + "applied {} bundle update(s) for cluster '{}' (id: {})", + update_count, + cluster.name, + cluster.id + ); + + for update in &result.updates_applied { + tracing::info!( + " - updated package from bundle '{}': {} -> {}", + update.bundle_name, + update.installed_version_id, + update.new_version_id + ); + } + } + + if removal_count > 0 { + tracing::info!( + "removed {} package(s) no longer in bundles for cluster '{}' (id: {})", + removal_count, + cluster.name, + cluster.id + ); + + for removal in &result.removals_applied { + tracing::info!( + " - removed package '{}' (was from bundle '{}')", + removal.package_id, + removal.bundle_name + ); + } + } + + if addition_count > 0 { + tracing::info!( + "installed {} new package(s) from bundles for cluster '{}' (id: {})", + addition_count, + cluster.name, + cluster.id + ); + + for addition in &result.additions_applied { + let file_id = match &addition.new_file.kind { + onelauncher_core::api::packages::modpack::data::ModpackFileKind::Managed((pkg, _)) => pkg.id.clone(), + onelauncher_core::api::packages::modpack::data::ModpackFileKind::External(ext) => ext.sha1.clone(), + }; + tracing::info!( + " - installed new package '{}' from bundle '{}'", + file_id, + addition.bundle_name + ); + } + } + } else { + tracing::debug!("no bundle updates needed for cluster '{}'", cluster.name); + } + } + Err(err) => { + total_clusters_failed += 1; + tracing::warn!( + "failed to apply bundle updates for cluster '{}': {err}", + cluster.name + ); + } + } + } + + if total_updates_applied > 0 || total_removals_applied > 0 || total_additions_applied > 0 { + let mut message_parts = Vec::new(); + if total_updates_applied > 0 { + message_parts.push(format!("{} mod(s) updated", total_updates_applied)); + } + if total_removals_applied > 0 { + message_parts.push(format!("{} mod(s) removed", total_removals_applied)); + } + if total_additions_applied > 0 { + message_parts.push(format!("{} mod(s) added", total_additions_applied)); + } + send_info!("Bundle sync: {}", message_parts.join(", ")); + tracing::info!( + "bundle sync complete: {} updates applied, {} removals applied, {} additions applied", + total_updates_applied, + total_removals_applied, + total_additions_applied + ); + } else if total_clusters_failed == 0 { + tracing::info!("all bundle packages are up to date"); + } + + if total_clusters_failed > 0 { + tracing::warn!("failed to apply updates for {total_clusters_failed} cluster(s)"); + } + + BUNDLE_SYNCING.store(false, Ordering::Relaxed); +} diff --git a/apps/oneclient/frontend/src/bindings.gen.ts b/apps/oneclient/frontend/src/bindings.gen.ts index 5eb347f2..59f0b667 100644 --- a/apps/oneclient/frontend/src/bindings.gen.ts +++ b/apps/oneclient/frontend/src/bindings.gen.ts @@ -5,6 +5,14 @@ import { createTauRPCProxy as createProxy, type InferCommandOutput } from 'taurp type TAURI_CHANNEL = (response: T) => void +export type ApplyBundleUpdatesResult = { updates_applied: BundlePackageUpdate[]; removals_applied: BundlePackageRemoval[]; additions_applied: BundlePackageAddition[]; updates_failed: string[]; removals_failed: string[]; additions_failed: string[] } + +export type BundlePackageAddition = { cluster_id: number; bundle_name: string; new_file: ModpackFile } + +export type BundlePackageRemoval = { cluster_id: number; package_hash: string; package_id: string; bundle_name: string; installed_at: string } + +export type BundlePackageUpdate = { cluster_id: number; installed_package_hash: string; installed_version_id: string; bundle_name: string; new_version_id: string; new_file: ModpackFile; installed_at: string } + export type ClusterError = { type: "InvalidVersion"; data: string } | { type: "MismatchedVersion"; data: string } | { type: "MismatchedLoader"; data: string } | { type: "MissingJavaVersion"; data: string } | { type: "ClusterDownloading"; data: string } | { type: "ClusterAlreadyRunning"; data: string } export type ClusterModel = { id: number; folder_name: string; stage: ClusterStage; created_at: string; group_id: number | null; name: string; mc_version: string; mc_loader: GameLoader; mc_loader_version: string | null; last_played: string | null; overall_played: number | null; icon_url: Icon | null; setting_profile_name: string | null; linked_modpack_hash: string | null } @@ -99,7 +107,7 @@ expires: string } export type ModpackArchive = { manifest: ModpackManifest; path: string; format: ModpackFormat } -export type ModpackFile = { enabled: boolean; kind: ModpackFileKind; overrides?: PackageOverrides | null } +export type ModpackFile = { enabled: boolean; hidden?: boolean; kind: ModpackFileKind; overrides?: PackageOverrides | null } export type ModpackFileKind = { Managed: [ManagedPackage, ManagedVersion] } | { External: ExternalPackage } @@ -234,7 +242,7 @@ gallery: string[] } export type SettingProfileModel = { name: string; java_id: number | null; res: Resolution | null; force_fullscreen: boolean | null; mem_max: number | null; launch_args: string | null; launch_env: string | null; hook_pre: string | null; hook_wrapper: string | null; hook_post: string | null; os_extra: SettingsOsExtra | null } -export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; seen_onboarding: boolean; mod_list_use_grid: boolean; parallel_mod_downloading: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean; show_tanstack_dev_tools: boolean } +export type Settings = { global_game_settings: SettingProfileModel; allow_parallel_running_clusters: boolean; enable_gamemode: boolean; discord_enabled: boolean; seen_onboarding: boolean; mod_list_use_grid: boolean; max_concurrent_requests: number; settings_version: number; native_window_frame: boolean; show_tanstack_dev_tools: boolean } export type SettingsOsExtra = Record @@ -303,8 +311,22 @@ export type VersionType = */ "old_beta" -const ARGS_MAP = { 'core':'{"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"syncCluster":["cluster_id"],"getGlobalProfile":[],"updateClusterProfile":["name","profile"],"setClusterStage":["id","stage"],"getClusters":[],"refreshAccounts":[],"getLinkedPackages":["cluster_id"],"removePackage":["cluster_id","package_hash"],"removeCluster":["id"],"isClusterRunning":["cluster_id"],"getPackage":["provider","slug"],"getDefaultUser":["fallback"],"getPackageBody":["provider","body"],"readSettings":[],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"fetchMinecraftProfile":["uuid"],"installModpack":["modpack","cluster_id"],"changeSkin":["access_token","skin_url","skin_variant"],"refreshAccount":["uuid"],"convertUsernameUUID":["username_uuid"],"open":["input"],"getLogByName":["id","name"],"getUser":["uuid"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"launchCluster":["id","uuid","search_for_java"],"getScreenshots":["id"],"writeSettings":["setting"],"getRunningProcesses":[],"changeCape":["access_token","cape_uuid"],"createCluster":["options"],"getClusterById":["id"],"togglePackage":["cluster_id","package_hash"],"removeCape":["access_token"],"getGameVersions":[],"setDiscordRPCMessage":["message"],"getLoadersForVersion":["mc_version"],"killProcess":["pid"],"getUsersFromAuthor":["provider","author"],"getLogs":["id"],"fetchLoggedInProfile":["access_token"],"getUsers":[],"updateClusterById":["id","request"],"openMsaLogin":[],"getMultiplePackages":["provider","slugs"],"removeUser":["uuid"],"getRunningProcessesByClusterId":["cluster_id"],"getProfileOrDefault":["name"],"getWorlds":["id"],"createSettingsProfile":["name"],"searchPackages":["provider","query"],"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"setDefaultUser":["uuid"]}', 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'oneclient':'{"checkForUpdate":[],"installUpdate":[],"extractBundleOverrides":["bundle_path","cluster_id"],"getVersions":[],"getClustersGroupedByMajor":[],"getBundlesFor":["cluster_id"]}', 'debug':'{"getPlatform":[],"getArch":[],"getPackageVersion":[],"isInDev":[],"getOsVersion":[],"openDevTools":[],"getLocale":[],"getType":[],"getGitCommitHash":[],"getBuildTimestamp":[],"getFamily":[]}', 'events':'{"process":["event"],"ingress":["event"],"message":["event"]}' } -export type Router = { 'core': { getClusters: () => Promise, +const ARGS_MAP = { 'folders':'{"openCluster":["folder_name"],"fromCluster":["folder_name"]}', 'oneclient':'{"getClustersGroupedByMajor":[],"checkForUpdate":[],"downloadPackageFromBundle":["package","cluster_id","bundle_name","skip_compatibility"],"extractBundleOverrides":["bundle_path","cluster_id"],"updateBundlePackages":["cluster_id"],"getVersions":[],"isBundleSyncing":[],"installUpdate":[],"getBundlesFor":["cluster_id"]}', 'events':'{"ingress":["event"],"message":["event"],"process":["event"]}', 'debug':'{"getGitCommitHash":[],"getFamily":[],"getArch":[],"getType":[],"isInDev":[],"getPlatform":[],"openDevTools":[],"getOsVersion":[],"getBuildTimestamp":[],"getPackageVersion":[],"getLocale":[]}', 'core':'{"downloadPackage":["provider","package_id","version_id","cluster_id","skip_compatibility"],"uploadSkinBytes":["access_token","skin_data","image_format","skin_variant"],"getPackageBody":["provider","body"],"createCluster":["options"],"getMultiplePackages":["provider","slugs"],"refreshAccounts":[],"changeSkin":["access_token","skin_url","skin_variant"],"fetchLoggedInProfile":["access_token"],"getLinkedPackages":["cluster_id"],"searchPackages":["provider","query"],"removeCape":["access_token"],"installModpack":["modpack","cluster_id"],"updateClusterProfile":["name","profile"],"getLoadersForVersion":["mc_version"],"getUsers":[],"isClusterRunning":["cluster_id"],"setDefaultUser":["uuid"],"getUsersFromAuthor":["provider","author"],"syncCluster":["cluster_id"],"fetchMinecraftProfile":["uuid"],"killProcess":["pid"],"setDiscordRPCMessage":["message"],"getPackage":["provider","slug"],"changeCape":["access_token","cape_uuid"],"getGameVersions":[],"getLogs":["id"],"getRunningProcessesByClusterId":["cluster_id"],"getProfileOrDefault":["name"],"getPackageVersions":["provider","slug","mc_version","loader","offset","limit"],"setClusterStage":["id","stage"],"createSettingsProfile":["name"],"getDefaultUser":["fallback"],"removeUser":["uuid"],"writeSettings":["setting"],"downloadExternalPackage":["package","cluster_id","force","skip_compatibility"],"refreshAccount":["uuid"],"getClusterById":["id"],"removePackage":["cluster_id","package_hash"],"getUser":["uuid"],"getWorlds":["id"],"togglePackage":["cluster_id","package_hash"],"convertUsernameUUID":["username_uuid"],"getLogByName":["id","name"],"launchCluster":["id","uuid","search_for_java"],"updateClusterById":["id","request"],"removeCluster":["id"],"getScreenshots":["id"],"getClusters":[],"getRunningProcesses":[],"getGlobalProfile":[],"openMsaLogin":[],"readSettings":[],"open":["input"]}' } +export type Router = { '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 }, +'events': { ingress: (event: IngressPayload) => Promise, +message: (event: MessagePayload) => Promise, +process: (event: ProcessPayload) => Promise }, +'folders': { fromCluster: (folderName: string) => Promise, +openCluster: (folderName: string) => Promise }, +'core': { getClusters: () => Promise, getClusterById: (id: number) => Promise, removeCluster: (id: number) => Promise, createCluster: (options: CreateCluster) => Promise, @@ -357,17 +379,6 @@ removeCape: (accessToken: string) => Promise, convertUsernameUUID: (usernameUuid: string) => Promise, setDiscordRPCMessage: (message: string) => Promise, open: (input: string) => Promise }, -'events': { ingress: (event: IngressPayload) => Promise, -message: (event: MessagePayload) => Promise, -process: (event: ProcessPayload) => Promise }, -'oneclient': { getClustersGroupedByMajor: () => Promise>, -getBundlesFor: (clusterId: number) => Promise, -getVersions: () => Promise, -extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise, -checkForUpdate: () => Promise, -installUpdate: () => Promise }, -'folders': { fromCluster: (folderName: string) => Promise, -openCluster: (folderName: string) => Promise }, 'debug': { openDevTools: () => Promise, isInDev: () => Promise, getArch: () => Promise, diff --git a/apps/oneclient/frontend/src/components/Bundle/Bundle.tsx b/apps/oneclient/frontend/src/components/Bundle/Bundle.tsx index d076496f..65fa0cac 100644 --- a/apps/oneclient/frontend/src/components/Bundle/Bundle.tsx +++ b/apps/oneclient/frontend/src/components/Bundle/Bundle.tsx @@ -13,11 +13,12 @@ export function Bundle({ files, cluster }: BundleProps) { const { useVerticalGridLayout } = useModCardContext(); const { setting } = useSettings(); const useGridLayout = setting('mod_list_use_grid'); + const visibleFiles = files.filter(file => !file.hidden); return (
- {files.map(file => ( + {visibleFiles.map(file => ( ))}
diff --git a/apps/oneclient/frontend/src/components/Bundle/ModList.tsx b/apps/oneclient/frontend/src/components/Bundle/ModList.tsx index ff22e990..1c7aec44 100644 --- a/apps/oneclient/frontend/src/components/Bundle/ModList.tsx +++ b/apps/oneclient/frontend/src/components/Bundle/ModList.tsx @@ -22,7 +22,15 @@ export function ModList({ bundles, cluster, selectedTab, onTabChange, toggleBund const { createSetting } = useSettings(); const [useGridLayout, setUseGrid] = createSetting('mod_list_use_grid'); - const nonEmptyBundles = bundles.filter(b => b.manifest.files.length > 0); + const nonEmptyBundles = bundles + .map(bundle => ({ + ...bundle, + manifest: { + ...bundle.manifest, + files: bundle.manifest.files.filter(file => !file.hidden), + }, + })) + .filter(b => b.manifest.files.length > 0); return ( diff --git a/apps/oneclient/frontend/src/components/DownloadMods.tsx b/apps/oneclient/frontend/src/components/DownloadMods.tsx index b50b2b1e..911fdbf3 100644 --- a/apps/oneclient/frontend/src/components/DownloadMods.tsx +++ b/apps/oneclient/frontend/src/components/DownloadMods.tsx @@ -1,6 +1,5 @@ -import type { ExternalPackage, ManagedVersionDependency, ModpackFile, Provider } from '@/bindings.gen'; +import type { ExternalPackage, ManagedVersionDependency, ModpackFile, ModpackFileKind, Provider } from '@/bindings.gen'; import { getModMetaDataName, Overlay } from '@/components'; -import { useSettings } from '@/hooks/useSettings'; import { bindings } from '@/main'; import { useCommandMut } from '@onelauncher/common'; import { Button } from '@onelauncher/common/components'; @@ -16,6 +15,8 @@ export interface BaseModData { name: string; clusterId: number; managed: boolean; + bundleName: string | null; + fileKind: ModpackFileKind; } export interface ManagedModData extends BaseModData { @@ -36,7 +37,12 @@ export function isManagedMod(mod: ModData): mod is ManagedModData { return mod.managed === true; } -export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record>; ref: React.Ref }) { +export interface ModWithBundle { + file: ModpackFile; + bundleName: string; +} + +export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record>; ref: React.Ref }) { const navigate = useNavigate(); const [isOpen, setOpen] = useState(false); const [mods, setMods] = useState([]); @@ -44,14 +50,16 @@ export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record { const modsList: ModDataArray = []; - for (const [clusterId, mods] of Object.entries(modsPerCluster)) - for (const mod of mods) { + for (const [clusterId, modsWithBundle] of Object.entries(modsPerCluster)) + for (const { file: mod, bundleName } of modsWithBundle) { if ('External' in mod.kind) modsList.push({ name: getModMetaDataName(mod), clusterId: Number(clusterId), managed: false, package: mod.kind.External, + bundleName, + fileKind: mod.kind, }); if ('Managed' in mod.kind) { @@ -64,6 +72,8 @@ export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record { const downloadAll = async () => { - if (useParallelModDownloading) - await downloadModsParallel(mods, 10, async (mod) => { - setModName(mod.name); - try { - await download.mutateAsync(mod); - } - finally { - setDownloadedMods(prev => prev + 1); - } - }); else - for (const mod of mods) { - setModName(mod.name); - try { - await download.mutateAsync(mod); - } - finally { - setDownloadedMods(prev => prev + 1); - } + await downloadModsParallel(mods, 10, async (mod) => { + setModName(mod.name); + try { + await download.mutateAsync(mod); } + finally { + setDownloadedMods(prev => prev + 1); + } + }); }; downloadAll(); diff --git a/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx b/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx index 8707f68b..afbbb8e6 100644 --- a/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx +++ b/apps/oneclient/frontend/src/components/overlay/SuperSecretDevOptions.tsx @@ -4,7 +4,7 @@ import { bindings } from '@/main'; import { Button } from '@onelauncher/common/components'; import { Link } from '@tanstack/react-router'; import { dataDir, join } from '@tauri-apps/api/path'; -import { BatteryEmptyIcon, Code02Icon } from '@untitled-theme/icons-react'; +import { Code02Icon } from '@untitled-theme/icons-react'; import { useEffect, useState } from 'react'; export function SuperSecretDevOptions() { @@ -37,10 +37,6 @@ export function SuperSecretDevOptions() { - } title="Use Parallel Mod Downloading"> - - -
diff --git a/apps/oneclient/frontend/src/hooks/useClusterDirWatch.ts b/apps/oneclient/frontend/src/hooks/useClusterDirWatch.ts index 63c02060..6f39aef3 100644 --- a/apps/oneclient/frontend/src/hooks/useClusterDirWatch.ts +++ b/apps/oneclient/frontend/src/hooks/useClusterDirWatch.ts @@ -24,6 +24,12 @@ export function useAllClusterDirWatch() { watch( clusterDir, async () => { + // Skip sync while the backend is applying bundle updates to + // avoid race conditions (watcher would re-sync mid-download). + const syncing = await bindings.oneclient.isBundleSyncing().catch(() => false); + if (syncing) + return; + try { await bindings.core.syncCluster(cluster.id); await queryClient.invalidateQueries({ queryKey: ['getLinkedPackages', cluster.id] }); diff --git a/apps/oneclient/frontend/src/routes/app/settings/developer.tsx b/apps/oneclient/frontend/src/routes/app/settings/developer.tsx index d9e5cd62..196cb999 100644 --- a/apps/oneclient/frontend/src/routes/app/settings/developer.tsx +++ b/apps/oneclient/frontend/src/routes/app/settings/developer.tsx @@ -66,11 +66,6 @@ function RouteComponent() { - Mod Downloading - - - -
); diff --git a/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx b/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx index 3776e5a0..28c771bb 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/preferences/version.tsx @@ -10,7 +10,7 @@ import { useState } from 'react'; import { Button as AriaButton } from 'react-aria-components'; import { twMerge } from 'tailwind-merge'; -export interface StrippedCLuster { +export interface StrippedCluster { mc_version: string; mc_loader: GameLoader; } @@ -23,7 +23,7 @@ function RouteComponent() { const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions()); // const { data: clusters } = useCommandSuspense(['getClusters'], () => bindings.core.getClusters()); - const [selectedClusters, setSelectedClusters] = useState>([]); + const [selectedClusters, setSelectedClusters] = useState>([]); return ( <> @@ -63,12 +63,12 @@ function RouteComponent() { ); } -function VersionCard({ cluster, versionData, version, fullVersionName, setSelectedClusters }: { cluster: OnlineCluster; versionData: VersionInfo; version: OnlineClusterEntry; fullVersionName: string; setSelectedClusters: React.Dispatch>> }) { +function VersionCard({ cluster, versionData, version, fullVersionName, setSelectedClusters }: { cluster: OnlineCluster; versionData: VersionInfo; version: OnlineClusterEntry; fullVersionName: string; setSelectedClusters: React.Dispatch>> }) { const [isSelected, setSelected] = useState(false); const toggle = () => { setSelected(prev => !prev); setSelectedClusters((prev) => { - let updatedClusters: Array = []; + let updatedClusters: Array = []; const exists = prev.some(strippedCluster => strippedCluster.mc_version === fullVersionName && strippedCluster.mc_loader === version.loader); if (exists) updatedClusters = prev.filter(strippedCluster => !(strippedCluster.mc_version === fullVersionName && strippedCluster.mc_loader === version.loader)); diff --git a/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx b/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx index 8a852a7f..6c009557 100644 --- a/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx +++ b/apps/oneclient/frontend/src/routes/onboarding/preferences/versionCategory.tsx @@ -1,6 +1,6 @@ import type { ClusterModel, ModpackArchive, ModpackFile } from '@/bindings.gen'; -import type { DownloadModsRef, ModCardContextApi, onClickOnMod } from '@/components'; -import type { StrippedCLuster } from '@/routes/onboarding/preferences/version'; +import type { DownloadModsRef, ModCardContextApi, ModWithBundle, onClickOnMod } from '@/components'; +import type { StrippedCluster } from '@/routes/onboarding/preferences/version'; import { BundleModListModal, DownloadMods, ModCardContext, Overlay } from '@/components'; import { bindings } from '@/main'; import { OnboardingNavigation } from '@/routes/onboarding/route'; @@ -30,7 +30,7 @@ function HandleCLuster(cluster: ClusterModel): Array { } function RouteComponent() { - const selectedClusters: Array = JSON.parse(localStorage.getItem('selectedClusters') ?? '[]'); + const selectedClusters: Array = JSON.parse(localStorage.getItem('selectedClusters') ?? '[]'); const { data: versions } = useCommandSuspense(['getVersions'], () => bindings.oneclient.getVersions()); const { data: clusters } = useCommandSuspense(['getClusters'], () => bindings.core.getClusters()); @@ -49,16 +49,18 @@ function RouteComponent() { }; }); - const [modsPerCluster, setModsPerCluster] = useState>>( + const [modsPerCluster, setModsPerCluster] = useState>>( clusters.reduce((acc, cluster, i) => { const bundles = bundleQueries[i]; const enabledMods = bundles .filter(bundle => bundle.manifest.enabled) - .flatMap(bundle => bundle.manifest.files.filter(file => file.enabled)); + .flatMap(bundle => bundle.manifest.files + .filter(file => file.enabled) + .map(file => ({ file, bundleName: bundle.manifest.name }))); acc[cluster.id] = enabledMods; return acc; - }, {} as Record>), + }, {} as Record>), ); const bundlesPerCluster = useMemo(() => { @@ -125,7 +127,7 @@ function RouteComponent() { ); } -function ModCategory({ bundleData, name, modsPerCluster, setModsPerCluster }: { bundleData: BundleData; name: string; modsPerCluster: Record>; setModsPerCluster: React.Dispatch>>> }) { +function ModCategory({ bundleData, name, modsPerCluster, setModsPerCluster }: { bundleData: BundleData; name: string; modsPerCluster: Record>; setModsPerCluster: React.Dispatch>>> }) { return (

{name}

@@ -146,16 +148,25 @@ function ModCategory({ bundleData, name, modsPerCluster, setModsPerCluster }: { ); } -function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterId }: { fullVersionName: string; art: string; bundle: ModpackArchive; mods: Array; setMods: React.Dispatch>>; clusterId: number }) { +function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterId }: { fullVersionName: string; art: string; bundle: ModpackArchive; mods: Array; setMods: React.Dispatch>>; clusterId: number }) { const files = bundle.manifest.files; - const isSelected = files.filter(file => file.enabled).every(file => mods.includes(file)); + const enabledFiles = files.filter(file => file.enabled); + const hiddenEnabledFiles = enabledFiles.filter(file => file.hidden); + const visibleEnabledFiles = enabledFiles.filter(file => !file.hidden); + const bundleName = bundle.manifest.name; + + const isSelected = visibleEnabledFiles.length > 0 + && visibleEnabledFiles.every(file => mods.some(m => m.file === file && m.bundleName === bundleName)); + const handleDownload = () => { setMods((prevMods) => { if (isSelected) { - return prevMods.filter(mod => !files.includes(mod)); + return prevMods.filter(mod => mod.bundleName !== bundleName || !enabledFiles.includes(mod.file)); } else { - const filesToAdd = files.filter(file => file.enabled && !prevMods.includes(file)); + const filesToAdd = enabledFiles + .filter(file => !prevMods.some(m => m.file === file && m.bundleName === bundleName)) + .map(file => ({ file, bundleName })); return [...filesToAdd, ...prevMods]; } }); @@ -163,18 +174,35 @@ function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterI const onClickOnMod: onClickOnMod = (file) => { setMods((prevMods) => { - if (prevMods.includes(file)) - return prevMods.filter(mod => mod !== file); - else - return [file, ...prevMods]; + const existingIndex = prevMods.findIndex(m => m.file === file && m.bundleName === bundleName); + const nextMods = existingIndex >= 0 + ? prevMods.filter((_, i) => i !== existingIndex) + : [{ file, bundleName }, ...prevMods]; + + const hasVisibleSelection = nextMods.some(mod => ( + mod.bundleName === bundleName + && mod.file.enabled + && !mod.file.hidden + )); + + if (!hasVisibleSelection) + return nextMods.filter(mod => mod.bundleName !== bundleName || !hiddenEnabledFiles.includes(mod.file)); + + const hiddenToAdd = hiddenEnabledFiles + .filter(hiddenFile => !nextMods.some(mod => mod.file === hiddenFile && mod.bundleName === bundleName)) + .map(hiddenFile => ({ file: hiddenFile, bundleName })); + + return [...hiddenToAdd, ...nextMods]; }); }; + const modsForContext = mods.filter(m => !m.file.hidden).map(m => m.file); + const context = useMemo(() => ({ onClickOnMod, useVerticalGridLayout: true, - mods, - }), [mods]); + mods: modsForContext, + }), [modsForContext]); return ( @@ -187,7 +215,7 @@ function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterI
- {files.length} Mods {isSelected ? 'Selected' : ''} + {visibleEnabledFiles.length} Mods {isSelected ? 'Selected' : ''}
diff --git a/packages/core/src/api/packages/bundle_dao.rs b/packages/core/src/api/packages/bundle_dao.rs new file mode 100644 index 00000000..678c6fa3 --- /dev/null +++ b/packages/core/src/api/packages/bundle_dao.rs @@ -0,0 +1,301 @@ +use chrono::Utc; +use onelauncher_entity::{cluster_packages, clusters, packages}; +use sea_orm::sea_query::{Expr, Value}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + +use crate::error::{DaoError, LauncherResult}; +use crate::store::State; +use onelauncher_entity::cluster_bundle_overrides::{self, OverrideType}; + +pub async fn track_bundle_package( + cluster: &clusters::Model, + package: &packages::Model, + bundle_name: &str, + bundle_version_id: &str, +) -> LauncherResult { + let state = State::get().await?; + let db = &state.db; + + let existing = cluster_packages::Entity::find_by_id((cluster.id, package.hash.clone())) + .one(db) + .await?; + + let model = if let Some(existing) = existing { + let mut active: cluster_packages::ActiveModel = existing.into(); + active.bundle_name = Set(Some(bundle_name.to_string())); + active.bundle_version_id = Set(Some(bundle_version_id.to_string())); + active.package_id = Set(Some(package.package_id.clone())); + active.installed_at = Set(Some(Utc::now())); + active.update(db).await? + } else { + let active = cluster_packages::ActiveModel { + cluster_id: Set(cluster.id), + package_hash: Set(package.hash.clone()), + bundle_name: Set(Some(bundle_name.to_string())), + bundle_version_id: Set(Some(bundle_version_id.to_string())), + package_id: Set(Some(package.package_id.clone())), + installed_at: Set(Some(Utc::now())), + }; + active.insert(db).await? + }; + + Ok(model) +} + +pub async fn get_bundle_packages_for_cluster( + cluster_id: i64, +) -> LauncherResult> { + let state = State::get().await?; + let db = &state.db; + + let records = cluster_packages::Entity::find() + .filter(cluster_packages::Column::ClusterId.eq(cluster_id)) + .filter(cluster_packages::Column::BundleName.is_not_null()) + .all(db) + .await?; + + Ok(records) +} + +pub async fn get_packages_from_bundle( + cluster_id: i64, + bundle_name: &str, +) -> LauncherResult> { + let state = State::get().await?; + let db = &state.db; + + let records = cluster_packages::Entity::find() + .filter(cluster_packages::Column::ClusterId.eq(cluster_id)) + .filter(cluster_packages::Column::BundleName.eq(Some(bundle_name.to_string()))) + .all(db) + .await?; + + Ok(records) +} + +pub async fn get_bundle_package( + cluster_id: i64, + package_hash: &str, +) -> LauncherResult> { + let state = State::get().await?; + let db = &state.db; + + let record = cluster_packages::Entity::find_by_id((cluster_id, package_hash.to_string())) + .filter(cluster_packages::Column::BundleName.is_not_null()) + .one(db) + .await?; + + Ok(record) +} + +pub async fn get_bundle_package_by_package_id( + cluster_id: i64, + package_id: &str, +) -> LauncherResult> { + let state = State::get().await?; + let db = &state.db; + + let record = cluster_packages::Entity::find() + .filter(cluster_packages::Column::ClusterId.eq(cluster_id)) + .filter(cluster_packages::Column::PackageId.eq(Some(package_id.to_string()))) + .filter(cluster_packages::Column::BundleName.is_not_null()) + .one(db) + .await?; + + Ok(record) +} + +pub async fn remove_bundle_package_tracking( + cluster_id: i64, + package_hash: &str, +) -> LauncherResult<()> { + let state = State::get().await?; + let db = &state.db; + + let existing = cluster_packages::Entity::find_by_id((cluster_id, package_hash.to_string())) + .one(db) + .await?; + + if let Some(existing) = existing { + let mut active: cluster_packages::ActiveModel = existing.into(); + active.bundle_name = Set(None); + active.bundle_version_id = Set(None); + active.package_id = Set(None); + active.installed_at = Set(None); + active.update(db).await?; + Ok(()) + } else { + Err(DaoError::NotFound.into()) + } +} + +pub async fn remove_all_bundle_packages_for_cluster(cluster_id: i64) -> LauncherResult { + let state = State::get().await?; + let db = &state.db; + + let result = cluster_packages::Entity::update_many() + .col_expr( + cluster_packages::Column::BundleName, + Expr::value(Value::String(None)), + ) + .col_expr( + cluster_packages::Column::BundleVersionId, + Expr::value(Value::String(None)), + ) + .col_expr( + cluster_packages::Column::PackageId, + Expr::value(Value::String(None)), + ) + .col_expr( + cluster_packages::Column::InstalledAt, + Expr::value(Value::ChronoDateTimeUtc(None)), + ) + .filter(cluster_packages::Column::ClusterId.eq(cluster_id)) + .filter(cluster_packages::Column::BundleName.is_not_null()) + .exec(db) + .await?; + + Ok(result.rows_affected) +} + +pub async fn remove_bundle_packages_for_bundle( + cluster_id: i64, + bundle_name: &str, +) -> LauncherResult { + let state = State::get().await?; + let db = &state.db; + + let result = cluster_packages::Entity::update_many() + .col_expr( + cluster_packages::Column::BundleName, + Expr::value(Value::String(None)), + ) + .col_expr( + cluster_packages::Column::BundleVersionId, + Expr::value(Value::String(None)), + ) + .col_expr( + cluster_packages::Column::PackageId, + Expr::value(Value::String(None)), + ) + .col_expr( + cluster_packages::Column::InstalledAt, + Expr::value(Value::ChronoDateTimeUtc(None)), + ) + .filter(cluster_packages::Column::ClusterId.eq(cluster_id)) + .filter(cluster_packages::Column::BundleName.eq(Some(bundle_name.to_string()))) + .exec(db) + .await?; + + Ok(result.rows_affected) +} + +pub async fn is_package_from_bundle(cluster_id: i64, package_hash: &str) -> LauncherResult { + let record = get_bundle_package(cluster_id, package_hash).await?; + Ok(record.is_some()) +} + +pub async fn save_bundle_override( + cluster_id: i64, + bundle_name: &str, + package_id: &str, + override_type: OverrideType, +) -> LauncherResult<()> { + let state = State::get().await?; + let db = &state.db; + + let existing = cluster_bundle_overrides::Entity::find() + .filter(cluster_bundle_overrides::Column::ClusterId.eq(cluster_id)) + .filter(cluster_bundle_overrides::Column::BundleName.eq(bundle_name)) + .filter(cluster_bundle_overrides::Column::PackageId.eq(package_id)) + .one(db) + .await?; + + if let Some(existing) = existing { + let mut active: cluster_bundle_overrides::ActiveModel = existing.into(); + active.override_type = Set(override_type); + active.update(db).await?; + } else { + let active = cluster_bundle_overrides::ActiveModel { + cluster_id: Set(cluster_id), + bundle_name: Set(bundle_name.to_string()), + package_id: Set(package_id.to_string()), + override_type: Set(override_type), + ..Default::default() + }; + active.insert(db).await?; + } + + Ok(()) +} + +pub async fn remove_bundle_override( + cluster_id: i64, + bundle_name: &str, + package_id: &str, +) -> LauncherResult<()> { + let state = State::get().await?; + let db = &state.db; + + cluster_bundle_overrides::Entity::delete_many() + .filter(cluster_bundle_overrides::Column::ClusterId.eq(cluster_id)) + .filter(cluster_bundle_overrides::Column::BundleName.eq(bundle_name)) + .filter(cluster_bundle_overrides::Column::PackageId.eq(package_id)) + .exec(db) + .await?; + + Ok(()) +} + +/// Returns whether a specific bundle package mapping still exists in the cluster. +/// Useful to avoid deleting overrides during update replacements where the old hash +/// is removed after a new hash has already been linked and tracked. +pub async fn has_bundle_package_mapping( + cluster_id: i64, + bundle_name: &str, + package_id: &str, +) -> LauncherResult { + let state = State::get().await?; + let db = &state.db; + + let record = cluster_packages::Entity::find() + .filter(cluster_packages::Column::ClusterId.eq(cluster_id)) + .filter(cluster_packages::Column::BundleName.eq(Some(bundle_name.to_string()))) + .filter(cluster_packages::Column::PackageId.eq(Some(package_id.to_string()))) + .one(db) + .await?; + + Ok(record.is_some()) +} + +/// Removes all bundle overrides for a specific package in a cluster. +/// This is a broad cleanup helper that applies across all bundles. +pub async fn remove_overrides_for_package( + cluster_id: i64, + package_id: &str, +) -> LauncherResult { + let state = State::get().await?; + let db = &state.db; + + let result = cluster_bundle_overrides::Entity::delete_many() + .filter(cluster_bundle_overrides::Column::ClusterId.eq(cluster_id)) + .filter(cluster_bundle_overrides::Column::PackageId.eq(package_id)) + .exec(db) + .await?; + + Ok(result.rows_affected) +} + +pub async fn get_bundle_overrides( + cluster_id: i64, +) -> LauncherResult> { + let state = State::get().await?; + let db = &state.db; + + let overrides = cluster_bundle_overrides::Entity::find() + .filter(cluster_bundle_overrides::Column::ClusterId.eq(cluster_id)) + .all(db) + .await?; + + Ok(overrides) +} diff --git a/packages/core/src/api/packages/dao.rs b/packages/core/src/api/packages/dao.rs index ff14c12f..ec510b17 100644 --- a/packages/core/src/api/packages/dao.rs +++ b/packages/core/src/api/packages/dao.rs @@ -30,6 +30,10 @@ pub async fn link_package_to_cluster( let cluster_package = cluster_packages::Model { cluster_id: cluster.id, package_hash: package.hash.clone(), + bundle_name: None, + bundle_version_id: None, + package_id: None, + installed_at: None, } .into_active_model(); @@ -55,6 +59,10 @@ pub async fn link_many_packages_to_cluster( cluster_packages::Model { cluster_id: cluster.id, package_hash: package.hash.clone(), + bundle_name: None, + bundle_version_id: None, + package_id: None, + installed_at: None, } .into_active_model() }) diff --git a/packages/core/src/api/packages/mod.rs b/packages/core/src/api/packages/mod.rs index 037c9a44..24835a11 100644 --- a/packages/core/src/api/packages/mod.rs +++ b/packages/core/src/api/packages/mod.rs @@ -14,6 +14,7 @@ use crate::utils::crypto::HashAlgorithm; use crate::utils::io::IOError; use crate::utils::{DatabaseModelExt, http, icon, io}; +pub mod bundle_dao; pub mod categories; pub mod dao; pub mod data; @@ -71,11 +72,23 @@ pub async fn download_package( .find(|f| f.primary) .ok_or(PackageError::NoPrimaryFile)?; - if !force.unwrap_or(false) - && let Some(model) = dao::get_package_by_hash(primary_file.sha1.clone()).await? - { - tracing::debug!("package is already downloaded"); - return Ok(model); + let mut update_existing_package = false; + 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() { + tracing::debug!("package is already downloaded"); + return Ok(model); + } + + tracing::warn!( + "package '{}' was found in database but cached file is missing at '{}'; re-downloading", + model.hash, + cached_path.display() + ); + } + + update_existing_package = true; } let mut model = packages::Model { @@ -110,7 +123,7 @@ pub async fn download_package( } } - dao::insert_package(model.into()).await + persist_downloaded_package(model, update_existing_package).await } /// Downloads a package and conditionally returns the database entry if it's hash was already registered. @@ -128,20 +141,32 @@ pub async fn download_external_package( ); // check if already downloaded - if !force.unwrap_or(false) - && let Some(model) = dao::get_package_by_hash(package.sha1.clone()).await? - { - tracing::debug!( - "external package is already downloaded as '{}'", - model.display_name - ); + let mut update_existing_package = false; + if let Some(model) = dao::get_package_by_hash(package.sha1.clone()).await? { + 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 + ); - // 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?; + // 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)); + return Ok(Some(model)); + } + + tracing::warn!( + "external package '{}' was found in database but cached file is missing at '{}'; re-downloading", + model.hash, + cached_path.display() + ); + } + + update_existing_package = true; } let model = packages::Model { @@ -170,11 +195,49 @@ pub async fn download_external_package( ) .await?; - let inserted_model = dao::insert_package(model.into()).await?; + let inserted_model = persist_downloaded_package(model, update_existing_package).await?; Ok(Some(inserted_model)) } +async fn persist_downloaded_package( + model: packages::Model, + update_existing_package: bool, +) -> LauncherResult { + if !update_existing_package { + return dao::insert_package(model.into()).await; + } + + let hash = model.hash.clone(); + let file_name = model.file_name.clone(); + let version_id = model.version_id.clone(); + let published_at = model.published_at; + let display_name = model.display_name.clone(); + let display_version = model.display_version.clone(); + let package_type = model.package_type.clone(); + let provider = model.provider; + let package_id = model.package_id.clone(); + let mc_versions = model.mc_versions.clone(); + let mc_loader = model.mc_loader.clone(); + let icon = model.icon.clone(); + + dao::update_package_by_hash(hash, async |mut active| { + active.file_name = sea_orm::ActiveValue::Set(file_name); + active.version_id = sea_orm::ActiveValue::Set(version_id); + active.published_at = sea_orm::ActiveValue::Set(published_at); + active.display_name = sea_orm::ActiveValue::Set(display_name); + active.display_version = sea_orm::ActiveValue::Set(display_version); + active.package_type = sea_orm::ActiveValue::Set(package_type); + active.provider = sea_orm::ActiveValue::Set(provider); + active.package_id = sea_orm::ActiveValue::Set(package_id); + active.mc_versions = sea_orm::ActiveValue::Set(mc_versions); + active.mc_loader = sea_orm::ActiveValue::Set(mc_loader); + active.icon = sea_orm::ActiveValue::Set(icon); + Ok(active) + }) + .await +} + /// Links a package to a cluster on the file system and in database. /// * `skip_compatibility` - Checks whether the `mc_loader` and `mc_version` is compatible. Default is false #[tracing::instrument(level = "debug", skip_all)] @@ -309,8 +372,30 @@ impl DatabaseModelExt for packages::Model { /// Removes a package from a cluster. /// It is also deleted from the filesystem and database. -pub async fn remove_package(cluster_id: i64, package_hash: String) -> LauncherResult<()> { +/// +/// * `record_override` - When `true` and the package is tracked as a bundle package, a +/// `Removed` override is saved so future bundle syncs won't re-install it. Pass `false` +/// for system-initiated removals (bundle updates, bundle-driven removals) where the +/// removal is not a deliberate user choice. +pub async fn remove_package( + cluster_id: i64, + package_hash: String, + record_override: bool, +) -> LauncherResult<()> { if let Some(package) = dao::get_package_by_hash(package_hash.clone()).await? { + // Always look up bundle mapping: needed for recording a Removed override (user action) + // and for cleaning up stale Disabled overrides (system action). + let bundle_override_data: Option<(String, String)> = if let Ok(Some(bundle_mapping)) = + bundle_dao::get_bundle_package(cluster_id, &package_hash).await + { + match (bundle_mapping.bundle_name, bundle_mapping.package_id) { + (Some(bn), Some(pid)) => Some((bn, pid)), + _ => None, + } + } else { + None + }; + dao::unlink_package_from_cluster(&package_hash, cluster_id).await?; // Remove the hard link from the cluster's folder @@ -342,6 +427,31 @@ pub async fn remove_package(cluster_id: i64, package_hash: String) -> LauncherRe } dao::delete_package_by_id(package_hash).await?; } + + // Act on bundle override data AFTER all filesystem/DB mutations succeed. + if let Some((bundle_name, package_id)) = bundle_override_data { + if record_override { + // User-initiated removal: record intent to keep package out of future syncs. + bundle_dao::save_bundle_override( + cluster_id, + &bundle_name, + &package_id, + onelauncher_entity::cluster_bundle_overrides::OverrideType::Removed, + ) + .await?; + } else { + // System-initiated removal: clean up stale overrides for this specific + // bundle package, but preserve overrides when a replacement hash for the + // same bundle/package mapping was already linked and tracked. + let replacement_exists = + bundle_dao::has_bundle_package_mapping(cluster_id, &bundle_name, &package_id) + .await?; + if !replacement_exists { + bundle_dao::remove_bundle_override(cluster_id, &bundle_name, &package_id) + .await?; + } + } + } } Ok(()) @@ -372,7 +482,20 @@ pub async fn toggle_package(cluster_id: i64, package_hash: String) -> LauncherRe (format!("{}.disabled", file_name), false) }; - // Rename the hard link in the cluster folder + // Read bundle mapping data before any mutations so the override can be written + // after the filesystem rename succeeds (avoiding DB/disk desync on FS failure). + let bundle_override_data: Option<(String, String)> = if let Ok(Some(bundle_mapping)) = + bundle_dao::get_bundle_package(cluster_id, &package_hash).await + { + match (bundle_mapping.bundle_name, bundle_mapping.package_id) { + (Some(bn), Some(pid)) => Some((bn, pid)), + _ => None, + } + } else { + None + }; + + // Rename the hard link in the cluster folder FIRST let current_path = cluster_mods_dir.join(file_name); let new_path = cluster_mods_dir.join(&new_file_name); if current_path.exists() { @@ -381,6 +504,21 @@ pub async fn toggle_package(cluster_id: i64, package_hash: String) -> LauncherRe .map_err(IOError::from)?; } + // Write the bundle override AFTER the filesystem rename succeeds + if let Some((bundle_name, package_id)) = bundle_override_data { + if enabled { + bundle_dao::remove_bundle_override(cluster_id, &bundle_name, &package_id).await?; + } else { + bundle_dao::save_bundle_override( + cluster_id, + &bundle_name, + &package_id, + onelauncher_entity::cluster_bundle_overrides::OverrideType::Disabled, + ) + .await?; + } + } + // Rename the file in the central package store let old_store_path = package.path().await?; dao::update_package_by_hash(package_hash, async |mut model| { diff --git a/packages/core/src/api/packages/modpack/data.rs b/packages/core/src/api/packages/modpack/data.rs index e6025779..7817075c 100644 --- a/packages/core/src/api/packages/modpack/data.rs +++ b/packages/core/src/api/packages/modpack/data.rs @@ -37,6 +37,8 @@ fn default_false() -> bool { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ModpackFile { pub enabled: bool, + #[serde(default = "default_false")] + pub hidden: bool, pub kind: ModpackFileKind, #[serde(default)] pub overrides: Option, diff --git a/packages/core/src/api/packages/modpack/mrpack.rs b/packages/core/src/api/packages/modpack/mrpack.rs index 8693f881..60510dd8 100644 --- a/packages/core/src/api/packages/modpack/mrpack.rs +++ b/packages/core/src/api/packages/modpack/mrpack.rs @@ -21,6 +21,7 @@ use crate::error::LauncherResult; use crate::store::ingress::SubIngress; use crate::utils::DatabaseModelExt; use crate::utils::io::{self, IOError}; +use tokio_util::compat::TokioAsyncWriteCompatExt; pub struct MrPackFormatImpl { pub(super) archive: Option, @@ -283,6 +284,82 @@ pub async fn copy_overrides_folder( Ok(()) } +/// Like [`copy_overrides_folder`], but skips files that already exist on disk. +/// Used during bundle updates so that user customizations are preserved. +pub async fn copy_overrides_folder_no_overwrite( + cluster: &clusters::Model, + archive_path: &PathBuf, +) -> LauncherResult<()> { + let dest = cluster.path().await?; + extract_overrides_no_overwrite(archive_path, &dest).await +} + +/// Extracts the `overrides/` folder from a zip archive into `dest_path`, +/// but **skips** any file that already exists on disk. +pub async fn extract_overrides_no_overwrite( + archive_path: &PathBuf, + dest_path: &std::path::Path, +) -> LauncherResult<()> { + tracing::debug!( + "extracting overrides (skip existing) from modpack archive: {}", + archive_path.display() + ); + + let reader = async_zip::tokio::read::fs::ZipFileReader::new(archive_path) + .await + .map_err(io::IOError::from)?; + let entries = reader.file().entries(); + + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + + let name = match entry.filename().as_str() { + Ok(s) if s.starts_with("overrides/") => s.trim_start_matches("overrides/").to_string(), + _ => continue, + }; + + if name.is_empty() { + continue; + } + + let name = io::sanitize_path(name); + let path = dest_path.join(&name); + let is_dir = entry.dir().map_err(io::IOError::from)?; + + if is_dir { + io::create_dir_all(&path).await?; + } else { + // Skip files that already exist — preserve user customizations + if path.exists() { + tracing::trace!( + path = %path.display(), + "Skipping override file (already exists)" + ); + continue; + } + + if let Some(parent) = path.parent() { + io::create_dir_all(parent).await?; + } + + let file = tokio::fs::File::create(&path) + .await + .map_err(io::IOError::from)?; + let writer = tokio::io::BufWriter::new(file); + let entry_reader = reader + .reader_without_entry(index) + .await + .map_err(io::IOError::from)?; + + futures_lite::io::copy(entry_reader, &mut writer.compat_write()) + .await + .map_err(io::IOError::from)?; + } + } + + Ok(()) +} + async fn to_modpack_files(mrpack_files: &Vec) -> LauncherResult> { #[derive(Clone)] struct ToFetch { @@ -353,6 +430,7 @@ async fn to_modpack_files(mrpack_files: &Vec) -> LauncherResult) -> LauncherResult "bundle content" + // overrides/new_file.txt -> "new from bundle" + // overrides/sub/nested.txt -> "nested content" + // mods/not_an_override.jar -> "should be ignored" + create_test_zip( + &zip_path, + &[ + ("overrides/config.txt", b"bundle content"), + ("overrides/new_file.txt", b"new from bundle"), + ("overrides/sub/nested.txt", b"nested content"), + ("mods/not_an_override.jar", b"should be ignored"), + ], + ) + .await; + + // Pre-create config.txt with custom content (simulating user edit) + tokio::fs::write(dest.join("config.txt"), b"user customized") + .await + .unwrap(); + + // Run + extract_overrides_no_overwrite(&PathBuf::from(&zip_path), &dest) + .await + .unwrap(); + + // 1) Existing file should NOT be overwritten + let config = tokio::fs::read_to_string(dest.join("config.txt")) + .await + .unwrap(); + assert_eq!( + config, "user customized", + "existing file should be preserved, not overwritten" + ); + + // 2) New file should be extracted + let new_file = tokio::fs::read_to_string(dest.join("new_file.txt")) + .await + .unwrap(); + assert_eq!( + new_file, "new from bundle", + "new file should be extracted from zip" + ); + + // 3) Nested file in subdirectory should be extracted + let nested = tokio::fs::read_to_string(dest.join("sub/nested.txt")) + .await + .unwrap(); + assert_eq!(nested, "nested content", "nested file should be extracted"); + + // 4) Non-override file should NOT be extracted + assert!( + !dest.join("mods/not_an_override.jar").exists(), + "non-override file should not be extracted" + ); + } +} diff --git a/packages/core/src/api/packages/modpack/polymrpack.rs b/packages/core/src/api/packages/modpack/polymrpack.rs index 4a44e0a7..61f48aaa 100644 --- a/packages/core/src/api/packages/modpack/polymrpack.rs +++ b/packages/core/src/api/packages/modpack/polymrpack.rs @@ -190,6 +190,7 @@ async fn to_modpack_files(mrpack_files: &Vec) -> LauncherResult< struct FetchedPackage { version_id: String, enabled: bool, + hidden: bool, overrides: Option, } @@ -197,10 +198,6 @@ async fn to_modpack_files(mrpack_files: &Vec) -> LauncherResult< let mut files: Vec = Vec::new(); for file in mrpack_files { - if file.hidden { - continue; - } - let name = file .base .path @@ -229,6 +226,7 @@ async fn to_modpack_files(mrpack_files: &Vec) -> LauncherResult< FetchedPackage { version_id: version_id.to_string(), enabled: file.enabled, + hidden: file.hidden, overrides: file.base.overrides.clone(), }, ); @@ -258,6 +256,7 @@ async fn to_modpack_files(mrpack_files: &Vec) -> LauncherResult< files.push(ModpackFile { enabled: file.enabled, + hidden: file.hidden, kind: ModpackFileKind::External(ExternalPackage { name, url: download_url, @@ -292,6 +291,7 @@ async fn to_modpack_files(mrpack_files: &Vec) -> LauncherResult< let fetched = to_fetch.get(&fetched_pkg.id).unwrap(); files.push(ModpackFile { enabled: fetched.enabled, + hidden: fetched.hidden, kind: ModpackFileKind::Managed((fetched_pkg, version)), overrides: fetched.overrides.clone(), }); diff --git a/packages/core/src/api/tauri/commands.rs b/packages/core/src/api/tauri/commands.rs index 091bd745..0a7721ce 100644 --- a/packages/core/src/api/tauri/commands.rs +++ b/packages/core/src/api/tauri/commands.rs @@ -811,7 +811,8 @@ impl TauriLauncherApi for TauriLauncherApiImpl { cluster_id: ClusterId, package_hash: String, ) -> LauncherResult<()> { - api::packages::remove_package(cluster_id, package_hash).await + // record_override=true: user explicitly removed this package from the cluster + api::packages::remove_package(cluster_id, package_hash, true).await } async fn toggle_package( diff --git a/packages/core/src/store/settings.rs b/packages/core/src/store/settings.rs index 4ee4b53e..e94aefa0 100644 --- a/packages/core/src/store/settings.rs +++ b/packages/core/src/store/settings.rs @@ -16,7 +16,6 @@ pub struct Settings { pub discord_enabled: bool, pub seen_onboarding: bool, pub mod_list_use_grid: bool, - pub parallel_mod_downloading: bool, pub max_concurrent_requests: usize, pub settings_version: u32, pub native_window_frame: bool, @@ -33,7 +32,6 @@ impl Default for Settings { discord_enabled: true, seen_onboarding: false, mod_list_use_grid: true, - parallel_mod_downloading: true, enable_gamemode: false, max_concurrent_requests: 25, settings_version: 1, diff --git a/packages/core/src/utils/http.rs b/packages/core/src/utils/http.rs index d3f4f91e..ba89723e 100644 --- a/packages/core/src/utils/http.rs +++ b/packages/core/src/utils/http.rs @@ -90,7 +90,6 @@ pub async fn send_request(request: reqwest::Request) -> LauncherResult LauncherResult LauncherResult { if res.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { if let Some(reset) = get_ratelimit_reset(res.headers()) { - tracing::warn!( - "Rate limited. Waiting for {reset} seconds before retrying..." - ); tokio::time::sleep(std::time::Duration::from_secs(reset)).await; continue; } @@ -163,7 +153,6 @@ pub async fn fetch_advanced( if host == "api.modrinth.com" { let start = std::time::Instant::now(); MODRINTH_RATE_LIMITER.until_ready().await; - tracing::debug!("waited {:?} for modrinth rate limiter", start.elapsed()); } } } @@ -174,7 +163,6 @@ pub async fn fetch_advanced( let client = &REQWEST_CLIENT; let mut attempt = 0; - tracing::debug!("fetching {url} with method {method:?}"); let res = loop { attempt += 1; let mut req = client.request(method.clone(), url); @@ -203,9 +191,6 @@ pub async fn fetch_advanced( Ok(res) => { if res.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { if let Some(reset) = get_ratelimit_reset(res.headers()) { - tracing::warn!( - "Rate limited. Waiting for {reset} seconds before retrying..." - ); tokio::time::sleep(std::time::Duration::from_secs(reset)).await; continue; } @@ -261,7 +246,6 @@ pub async fn fetch_advanced( } } - tracing::debug!("finished fetching {url}"); Ok(bytes) } diff --git a/packages/entity/src/entities/cluster_bundle_overrides.rs b/packages/entity/src/entities/cluster_bundle_overrides.rs new file mode 100644 index 00000000..8619c1fe --- /dev/null +++ b/packages/entity/src/entities/cluster_bundle_overrides.rs @@ -0,0 +1,47 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "cluster_bundle_overrides")] +#[serde(rename = "ClusterBundleOverridesModel")] +#[onelauncher_macro::specta] +pub struct Model { + #[sea_orm(primary_key, auto_increment = true)] + pub id: i64, + pub cluster_id: i64, + pub bundle_name: String, + pub package_id: String, + pub override_type: OverrideType, +} + +#[derive(Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize)] +#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")] +#[onelauncher_macro::specta] +pub enum OverrideType { + #[sea_orm(string_value = "Removed")] + Removed, + #[sea_orm(string_value = "Disabled")] + Disabled, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::clusters::Entity", + from = "Column::ClusterId", + to = "super::clusters::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Clusters, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Clusters.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/packages/entity/src/entities/cluster_packages.rs b/packages/entity/src/entities/cluster_packages.rs index 7d5c7d96..a021ba0e 100644 --- a/packages/entity/src/entities/cluster_packages.rs +++ b/packages/entity/src/entities/cluster_packages.rs @@ -12,6 +12,10 @@ pub struct Model { pub cluster_id: i64, #[sea_orm(primary_key, auto_increment = false)] pub package_hash: String, + pub bundle_name: Option, + pub bundle_version_id: Option, + pub package_id: Option, + pub installed_at: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/packages/entity/src/entities/mod.rs b/packages/entity/src/entities/mod.rs index 60b6aa85..96776db8 100644 --- a/packages/entity/src/entities/mod.rs +++ b/packages/entity/src/entities/mod.rs @@ -2,6 +2,7 @@ pub mod prelude; +pub mod cluster_bundle_overrides; pub mod cluster_groups; pub mod cluster_packages; pub mod clusters; diff --git a/packages/entity/src/entities/packages.rs b/packages/entity/src/entities/packages.rs index da3b5da7..a8daff1d 100644 --- a/packages/entity/src/entities/packages.rs +++ b/packages/entity/src/entities/packages.rs @@ -3,7 +3,10 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{icon::Icon, loader::GameLoader, package::{PackageType, Provider}, utility::DbVec}; +use crate::icon::Icon; +use crate::loader::GameLoader; +use crate::package::{PackageType, Provider}; +use crate::utility::DbVec; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "packages")] diff --git a/packages/entity/src/entities/prelude.rs b/packages/entity/src/entities/prelude.rs index 258f4dfe..8e889ea4 100644 --- a/packages/entity/src/entities/prelude.rs +++ b/packages/entity/src/entities/prelude.rs @@ -1,6 +1,7 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.5 pub mod entity { + pub use crate::cluster_bundle_overrides::Entity as ClusterBundleOverrides; pub use crate::cluster_groups::Entity as ClusterGroups; pub use crate::cluster_packages::Entity as ClusterPackages; pub use crate::clusters::Entity as Clusters; @@ -10,6 +11,9 @@ pub mod entity { } pub mod model { + pub use crate::cluster_bundle_overrides::{ + ActiveModel as ClusterBundleOverridePartial, Model as ClusterBundleOverride, + }; pub use crate::cluster_groups::Model as ClusterGroup; pub use crate::cluster_packages::Model as ClusterPackage; pub use crate::clusters::Model as Cluster; diff --git a/packages/macro/src/error.rs b/packages/macro/src/error.rs index c1094bf3..048acbfc 100644 --- a/packages/macro/src/error.rs +++ b/packages/macro/src/error.rs @@ -2,6 +2,7 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; use syn::{Data, DeriveInput, Fields, parse_macro_input}; +#[allow(clippy::too_many_lines)] pub fn error_attr(_attr: TokenStream, item: TokenStream) -> TokenStream { let input = parse_macro_input!(item as DeriveInput); diff --git a/packages/migration/src/lib.rs b/packages/migration/src/lib.rs index 2c605afb..88841ea2 100644 --- a/packages/migration/src/lib.rs +++ b/packages/migration/src/lib.rs @@ -1,12 +1,18 @@ pub use sea_orm_migration::prelude::*; mod m20220101_000001_create_table; +mod m20260222_000001_create_bundle_packages; +mod m20260223_000001_create_bundle_overrides_table; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { - fn migrations() -> Vec> { - vec![Box::new(m20220101_000001_create_table::Migration)] - } + fn migrations() -> Vec> { + vec![ + Box::new(m20220101_000001_create_table::Migration), + Box::new(m20260222_000001_create_bundle_packages::Migration), + Box::new(m20260223_000001_create_bundle_overrides_table::Migration), + ] + } } diff --git a/packages/migration/src/m20260222_000001_create_bundle_packages.rs b/packages/migration/src/m20260222_000001_create_bundle_packages.rs new file mode 100644 index 00000000..e16a9fbf --- /dev/null +++ b/packages/migration/src/m20260222_000001_create_bundle_packages.rs @@ -0,0 +1,144 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ClusterPackages::Table) + .add_column(ColumnDef::new(ClusterPackages::BundleName).text().null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ClusterPackages::Table) + .add_column( + ColumnDef::new(ClusterPackages::BundleVersionId) + .text() + .null(), + ) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ClusterPackages::Table) + .add_column(ColumnDef::new(ClusterPackages::PackageId).text().null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ClusterPackages::Table) + .add_column( + ColumnDef::new(ClusterPackages::InstalledAt) + .date_time() + .null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("cluster_packages_bundle_name_idx") + .table(ClusterPackages::Table) + .col(ClusterPackages::ClusterId) + .col(ClusterPackages::BundleName) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("cluster_packages_package_id_idx") + .table(ClusterPackages::Table) + .col(ClusterPackages::PackageId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("cluster_packages_bundle_name_idx") + .table(ClusterPackages::Table) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("cluster_packages_package_id_idx") + .table(ClusterPackages::Table) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ClusterPackages::Table) + .drop_column(ClusterPackages::BundleName) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ClusterPackages::Table) + .drop_column(ClusterPackages::BundleVersionId) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ClusterPackages::Table) + .drop_column(ClusterPackages::PackageId) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(ClusterPackages::Table) + .drop_column(ClusterPackages::InstalledAt) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(Iden)] +enum ClusterPackages { + Table, + ClusterId, + BundleName, + BundleVersionId, + PackageId, + InstalledAt, +} diff --git a/packages/migration/src/m20260223_000001_create_bundle_overrides_table.rs b/packages/migration/src/m20260223_000001_create_bundle_overrides_table.rs new file mode 100644 index 00000000..7011c5f0 --- /dev/null +++ b/packages/migration/src/m20260223_000001_create_bundle_overrides_table.rs @@ -0,0 +1,127 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ClusterBundleOverrides::Table) + .if_not_exists() + .col( + ColumnDef::new(ClusterBundleOverrides::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(ClusterBundleOverrides::ClusterId) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(ClusterBundleOverrides::BundleName) + .text() + .not_null(), + ) + .col( + ColumnDef::new(ClusterBundleOverrides::PackageId) + .text() + .not_null(), + ) + .col( + ColumnDef::new(ClusterBundleOverrides::OverrideType) + .text() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .from( + ClusterBundleOverrides::Table, + ClusterBundleOverrides::ClusterId, + ) + .to(Clusters::Table, Clusters::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("cluster_bundle_overrides_cluster_id_idx") + .table(ClusterBundleOverrides::Table) + .col(ClusterBundleOverrides::ClusterId) + .to_owned(), + ) + .await?; + + // Unique constraint ensures save_bundle_override upserts remain correct + // even under concurrent callers, and prevents duplicate override rows. + manager + .create_index( + Index::create() + .name("cluster_bundle_overrides_unique_idx") + .table(ClusterBundleOverrides::Table) + .col(ClusterBundleOverrides::ClusterId) + .col(ClusterBundleOverrides::BundleName) + .col(ClusterBundleOverrides::PackageId) + .unique() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("cluster_bundle_overrides_unique_idx") + .table(ClusterBundleOverrides::Table) + .to_owned(), + ) + .await?; + + manager + .drop_index( + Index::drop() + .name("cluster_bundle_overrides_cluster_id_idx") + .table(ClusterBundleOverrides::Table) + .to_owned(), + ) + .await?; + + manager + .drop_table( + Table::drop() + .table(ClusterBundleOverrides::Table) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(Iden)] +enum ClusterBundleOverrides { + Table, + Id, + ClusterId, + BundleName, + PackageId, + OverrideType, +} + +#[derive(Iden)] +enum Clusters { + Table, + Id, +}