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 <contact@kath.lol>
This commit is contained in:
Wyvest 2026-02-23 22:36:12 -05:00
parent 64b3ec5f90
commit 87cb86943c
34 changed files with 3120 additions and 155 deletions

1
Cargo.lock generated
View file

@ -3732,6 +3732,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"onelauncher_core",
"reqwest",
"sea-orm",
"serde",
"serde_json",
"specta",

View file

@ -74,6 +74,9 @@ features = [
"specta"
]
[dev-dependencies]
sea-orm = { workspace = true }
[features]
default = [
"custom-protocol",

View file

@ -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<R: Runtime>(app_handle: AppHandle<R>) -> LauncherResult<()>;
#[taurpc(alias = "downloadPackageFromBundle")]
async fn download_package_from_bundle(
package: ModpackFileKind,
cluster_id: ClusterId,
bundle_name: String,
skip_compatibility: Option<bool>,
) -> LauncherResult<packages::Model>;
#[taurpc(alias = "updateBundlePackages")]
async fn update_bundle_packages(
cluster_id: ClusterId,
) -> LauncherResult<ApplyBundleUpdatesResult>;
#[taurpc(alias = "isBundleSyncing")]
async fn is_bundle_syncing() -> LauncherResult<bool>;
}
#[taurpc::ipc_type]
@ -50,14 +68,12 @@ impl OneClientApi for OneClientApiImpl {
let mut mapped: HashMap<u32, Vec<clusters::Model>> = 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<bool>,
) -> LauncherResult<packages::Model> {
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<ApplyBundleUpdatesResult> {
crate::oneclient::bundle_updates::apply_bundle_updates(cluster_id).await
}
async fn is_bundle_syncing(self) -> LauncherResult<bool> {
Ok(crate::oneclient::is_bundle_syncing())
}
}

File diff suppressed because it is too large Load diff

View file

@ -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::<Vec<_>>()
);
}
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;
}
}

View file

@ -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<Vec<ModpackArchive>> {
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<Box<dyn InstallableModpackFormatExt>> {
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::<u64>().ok())
.unwrap_or(0);
.map(|s| s.to_string());
let stored_etag: Option<String> = 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::<u64>().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?)
}

View file

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

View file

@ -5,6 +5,14 @@ import { createTauRPCProxy as createProxy, type InferCommandOutput } from 'taurp
type TAURI_CHANNEL<T> = (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<string, never>
@ -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<ClusterModel[]>,
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<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
getVersions: () => Promise<OnlineClusterManifest>,
extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise<null>,
checkForUpdate: () => Promise<Update | null>,
installUpdate: () => Promise<null>,
downloadPackageFromBundle: (package: ModpackFileKind, clusterId: number, bundleName: string, skipCompatibility: boolean | null) => Promise<PackageModel>,
updateBundlePackages: (clusterId: number) => Promise<ApplyBundleUpdatesResult>,
isBundleSyncing: () => Promise<boolean> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'core': { getClusters: () => Promise<ClusterModel[]>,
getClusterById: (id: number) => Promise<ClusterModel | null>,
removeCluster: (id: number) => Promise<null>,
createCluster: (options: CreateCluster) => Promise<ClusterModel>,
@ -357,17 +379,6 @@ removeCape: (accessToken: string) => Promise<MojangFullPlayerProfile>,
convertUsernameUUID: (usernameUuid: string) => Promise<MowojangProfile>,
setDiscordRPCMessage: (message: string) => Promise<null>,
open: (input: string) => Promise<null> },
'events': { ingress: (event: IngressPayload) => Promise<void>,
message: (event: MessagePayload) => Promise<void>,
process: (event: ProcessPayload) => Promise<void> },
'oneclient': { getClustersGroupedByMajor: () => Promise<Partial<{ [key in number]: ClusterModel[] }>>,
getBundlesFor: (clusterId: number) => Promise<ModpackArchive[]>,
getVersions: () => Promise<OnlineClusterManifest>,
extractBundleOverrides: (bundlePath: string, clusterId: number) => Promise<null>,
checkForUpdate: () => Promise<Update | null>,
installUpdate: () => Promise<null> },
'folders': { fromCluster: (folderName: string) => Promise<string>,
openCluster: (folderName: string) => Promise<null> },
'debug': { openDevTools: () => Promise<void>,
isInDev: () => Promise<boolean>,
getArch: () => Promise<string>,

View file

@ -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 (
<OverlayScrollbarsComponent className="bg-page-elevated rounded-lg">
<div className={twMerge('grid gap-2 p-1', useGridLayout || useVerticalGridLayout ? 'grid-cols-3' : 'grid-cols-1', useVerticalGridLayout ? 'max-h-112' : 'max-h-148')}>
{files.map(file => (
{visibleFiles.map(file => (
<ModCard cluster={cluster} file={file} key={'Managed' in file.kind ? file.kind.Managed[0].id : file.kind.External.url} />
))}
<div className="h-1" />

View file

@ -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 (
<Tabs defaultValue={selectedTab ?? getBundleName(nonEmptyBundles[0]?.manifest.name ?? bundles[0].manifest.name)} onTabChange={onTabChange}>

View file

@ -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<string, Array<ModpackFile>>; ref: React.Ref<DownloadModsRef> }) {
export interface ModWithBundle {
file: ModpackFile;
bundleName: string;
}
export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record<string, Array<ModWithBundle>>; ref: React.Ref<DownloadModsRef> }) {
const navigate = useNavigate();
const [isOpen, setOpen] = useState<boolean>(false);
const [mods, setMods] = useState<ModDataArray>([]);
@ -44,14 +50,16 @@ export function DownloadMods({ modsPerCluster, ref }: { modsPerCluster: Record<s
useEffect(() => {
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<s
id: pkg.id,
versionId: version.version_id,
dependencies: version.dependencies,
bundleName,
fileKind: mod.kind,
});
}
}
@ -124,35 +134,43 @@ function DownloadingMods({ mods, setOpen, nextPath }: { mods: ModDataArray; setO
await bindings.core.downloadPackage(mod.provider, slug, versions.items[0].version_id, cluster.id, null);
}
}
await bindings.core.downloadPackage(mod.provider, mod.id, mod.versionId, mod.clusterId, true);
}
else { await bindings.core.downloadExternalPackage(mod.package, mod.clusterId, null, null); }
});
const { setting } = useSettings();
let useParallelModDownloading = setting('parallel_mod_downloading');
if (mod.bundleName)
await bindings.oneclient.downloadPackageFromBundle(
mod.fileKind,
mod.clusterId,
mod.bundleName,
true,
);
else
await bindings.core.downloadPackage(mod.provider, mod.id, mod.versionId, mod.clusterId, true);
}
else {
if (mod.bundleName)
await bindings.oneclient.downloadPackageFromBundle(
mod.fileKind,
mod.clusterId,
mod.bundleName,
true,
);
else
await bindings.core.downloadExternalPackage(mod.package, mod.clusterId, null, null);
}
});
useEffect(() => {
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();

View file

@ -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() {
<SettingsSwitch setting={createSetting('show_tanstack_dev_tools')} />
</SettingsRow>
<SettingsRow description="Use Parallel Mod Downloading for speed. This can create some issues sometimes" icon={<BatteryEmptyIcon />} title="Use Parallel Mod Downloading">
<SettingsSwitch setting={createSetting('parallel_mod_downloading')} />
</SettingsRow>
<div className="grid grid-cols-3 gap-3 justify-items-center">
<Button onPress={bindings.debug.openDevTools} size="normal">Dev Tools</Button>

View file

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

View file

@ -66,11 +66,6 @@ function RouteComponent() {
<SettingsSwitch setting={createSetting('mod_list_use_grid')} />
</SettingsRow>
<SettingsRow.Header>Mod Downloading</SettingsRow.Header>
<SettingsRow description="Use Parallel Mod Downloading for speed. This can create some issues sometimes" title="Use Parallel Mod Downloading">
<SettingsSwitch setting={createSetting('parallel_mod_downloading')} />
</SettingsRow>
</div>
</Sidebar.Page>
);

View file

@ -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<Array<StrippedCLuster>>([]);
const [selectedClusters, setSelectedClusters] = useState<Array<StrippedCluster>>([]);
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<React.SetStateAction<Array<StrippedCLuster>>> }) {
function VersionCard({ cluster, versionData, version, fullVersionName, setSelectedClusters }: { cluster: OnlineCluster; versionData: VersionInfo; version: OnlineClusterEntry; fullVersionName: string; setSelectedClusters: React.Dispatch<React.SetStateAction<Array<StrippedCluster>>> }) {
const [isSelected, setSelected] = useState<boolean>(false);
const toggle = () => {
setSelected(prev => !prev);
setSelectedClusters((prev) => {
let updatedClusters: Array<StrippedCLuster> = [];
let updatedClusters: Array<StrippedCluster> = [];
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));

View file

@ -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<ModpackArchive> {
}
function RouteComponent() {
const selectedClusters: Array<StrippedCLuster> = JSON.parse(localStorage.getItem('selectedClusters') ?? '[]');
const selectedClusters: Array<StrippedCluster> = 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<Record<string, Array<ModpackFile>>>(
const [modsPerCluster, setModsPerCluster] = useState<Record<string, Array<ModWithBundle>>>(
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<string, Array<ModpackFile>>),
}, {} as Record<string, Array<ModWithBundle>>),
);
const bundlesPerCluster = useMemo(() => {
@ -125,7 +127,7 @@ function RouteComponent() {
);
}
function ModCategory({ bundleData, name, modsPerCluster, setModsPerCluster }: { bundleData: BundleData; name: string; modsPerCluster: Record<string, Array<ModpackFile>>; setModsPerCluster: React.Dispatch<React.SetStateAction<Record<string, Array<ModpackFile>>>> }) {
function ModCategory({ bundleData, name, modsPerCluster, setModsPerCluster }: { bundleData: BundleData; name: string; modsPerCluster: Record<string, Array<ModWithBundle>>; setModsPerCluster: React.Dispatch<React.SetStateAction<Record<string, Array<ModWithBundle>>>> }) {
return (
<div>
<h1 className="text-3xl font-semibold my-2">{name}</h1>
@ -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<ModpackFile>; setMods: React.Dispatch<React.SetStateAction<Array<ModpackFile>>>; clusterId: number }) {
function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterId }: { fullVersionName: string; art: string; bundle: ModpackArchive; mods: Array<ModWithBundle>; setMods: React.Dispatch<React.SetStateAction<Array<ModWithBundle>>>; 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<ModCardContextApi>(() => ({
onClickOnMod,
useVerticalGridLayout: true,
mods,
}), [mods]);
mods: modsForContext,
}), [modsForContext]);
return (
<AriaButton className={twMerge('group cursor-pointer w-full rounded-xl transition-[outline] outline-2 hover:outline-brand', isSelected ? 'outline-brand' : 'outline-ghost-overlay')} onPress={handleDownload}>
@ -187,7 +215,7 @@ function ModCategoryCard({ art, fullVersionName, bundle, mods, setMods, clusterI
<div className={twMerge('absolute -top-2 right-3', isSelected ? 'block' : 'hidden group-hover:block')}>
<div className="bg-[#D0D7F3] rounded-xl text-brand text-sm px-2 py-1">
{files.length} Mods {isSelected ? 'Selected' : ''}
{visibleEnabledFiles.length} Mods {isSelected ? 'Selected' : ''}
</div>
</div>

View file

@ -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<cluster_packages::Model> {
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<Vec<cluster_packages::Model>> {
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<Vec<cluster_packages::Model>> {
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<Option<cluster_packages::Model>> {
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<Option<cluster_packages::Model>> {
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<u64> {
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<u64> {
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<bool> {
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<bool> {
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<u64> {
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<Vec<cluster_bundle_overrides::Model>> {
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)
}

View file

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

View file

@ -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<packages::Model> {
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| {

View file

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

View file

@ -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<PathBuf>,
@ -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<MrPackFile>) -> LauncherResult<Vec<ModpackFile>> {
#[derive(Clone)]
struct ToFetch {
@ -353,6 +430,7 @@ async fn to_modpack_files(mrpack_files: &Vec<MrPackFile>) -> LauncherResult<Vec<
}),
overrides: file.overrides.clone(),
enabled: true,
hidden: false,
});
}
}
@ -389,6 +467,7 @@ async fn to_modpack_files(mrpack_files: &Vec<MrPackFile>) -> LauncherResult<Vec<
files.push(ModpackFile {
kind: ModpackFileKind::Managed((fetched_pkg, version)),
enabled: true,
hidden: false,
overrides,
});
} else {
@ -435,3 +514,100 @@ pub(super) struct MrPackFileEnv {
pub client: PackageSide,
pub server: PackageSide,
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use async_zip::base::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use tokio_util::compat::TokioAsyncWriteCompatExt;
use super::extract_overrides_no_overwrite;
/// Helper: creates a .mrpack zip at `zip_path` with the given entries.
/// Each entry is (filename_in_zip, content_bytes).
async fn create_test_zip(zip_path: &std::path::Path, entries: &[(&str, &[u8])]) {
let file = tokio::fs::File::create(zip_path).await.unwrap();
let mut writer = ZipFileWriter::new(file.compat_write());
for &(name, content) in entries {
let entry = ZipEntryBuilder::new(name.into(), Compression::Stored);
writer.write_entry_whole(entry, content).await.unwrap();
}
writer.close().await.unwrap();
}
#[tokio::test]
async fn test_extract_overrides_no_overwrite_skips_existing() {
let tmp = std::env::temp_dir().join(format!(
"onelauncher_test_overrides_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
tokio::fs::create_dir_all(&tmp).await.unwrap();
let zip_path = tmp.join("test.mrpack");
let dest = tmp.join("cluster");
tokio::fs::create_dir_all(&dest).await.unwrap();
// Create a zip with:
// overrides/config.txt -> "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"
);
}
}

View file

@ -190,6 +190,7 @@ async fn to_modpack_files(mrpack_files: &Vec<PolyMrPackFile>) -> LauncherResult<
struct FetchedPackage {
version_id: String,
enabled: bool,
hidden: bool,
overrides: Option<PackageOverrides>,
}
@ -197,10 +198,6 @@ async fn to_modpack_files(mrpack_files: &Vec<PolyMrPackFile>) -> LauncherResult<
let mut files: Vec<ModpackFile> = 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<PolyMrPackFile>) -> 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<PolyMrPackFile>) -> 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<PolyMrPackFile>) -> 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(),
});

View file

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

View file

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

View file

@ -90,7 +90,6 @@ pub async fn send_request(request: reqwest::Request) -> LauncherResult<reqwest::
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());
}
}
@ -101,12 +100,6 @@ pub async fn send_request(request: reqwest::Request) -> LauncherResult<reqwest::
let mut attempt = 0;
tracing::debug!(
"fetching {:?} with method {:?}",
request.url(),
request.method()
);
let res = loop {
attempt += 1;
@ -130,9 +123,6 @@ pub async fn send_request(request: reqwest::Request) -> LauncherResult<reqwest::
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;
}
@ -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)
}

View file

@ -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<super::clusters::Entity> for Entity {
fn to() -> RelationDef {
Relation::Clusters.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -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<String>,
pub bundle_version_id: Option<String>,
pub package_id: Option<String>,
pub installed_at: Option<DateTimeUtc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View file

@ -2,6 +2,7 @@
pub mod prelude;
pub mod cluster_bundle_overrides;
pub mod cluster_groups;
pub mod cluster_packages;
pub mod clusters;

View file

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

View file

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

View file

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

View file

@ -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<Box<dyn MigrationTrait>> {
vec![Box::new(m20220101_000001_create_table::Migration)]
}
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
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),
]
}
}

View file

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

View file

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