From 190723a4f82f32ded190212f8d2cdab62ae991d7 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Thu, 1 Oct 2026 03:46:09 -0400 Subject: [PATCH] feat: optionally copy configs into dedicated-folder targets on migration --- .../src/components/release_migration_popup.rs | 29 +++++- .../src/hooks/actions/release_migration.rs | 20 ++++- packages/oneclient_app/src/state.rs | 1 + packages/oneclient_content/src/bundles/mod.rs | 1 + .../src/bundles/overrides.rs | 19 +++- packages/oneclient_core/src/clusters/mod.rs | 2 +- .../src/clusters/release_migration.rs | 89 +++++++++++++++++++ 7 files changed, 153 insertions(+), 8 deletions(-) diff --git a/packages/oneclient_app/src/components/release_migration_popup.rs b/packages/oneclient_app/src/components/release_migration_popup.rs index 13fb81b3..6fa0543d 100644 --- a/packages/oneclient_app/src/components/release_migration_popup.rs +++ b/packages/oneclient_app/src/components/release_migration_popup.rs @@ -11,7 +11,9 @@ use oneclient_content::packages::release_migration::{ use oneclient_content::packages::{CachedPackageMeta, ProviderId}; use oneclient_core::clusters::Cluster; -use crate::components::{Button, Dropdown, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::components::{ + Button, Dropdown, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea, checkbox_labeled, +}; use crate::hooks::{ loaded_image, package_meta_batch, use_cached_image, use_dispatch, use_game_snapshot, use_launcher, use_notifications_snapshot, use_package_meta_batch, use_release_migration, @@ -103,6 +105,7 @@ impl Component for ReleaseMigrationPopup { let mut excluded = use_state(HashSet::::new); let mut tab = use_state(|| PackageTab::Mods); + let mut copy_configs = use_state(|| true); let settled = launcher.ready && !launcher.fetching && !launcher.syncing_bundles; let checked = use_state(|| false); @@ -159,6 +162,7 @@ impl Component for ReleaseMigrationPopup { tab.set_if_modified(PackageTab::Mods); wont_open.set_if_modified(true); deps_open.set_if_modified(false); + copy_configs.set_if_modified(true); return rect().into_element(); }; @@ -189,6 +193,7 @@ impl Component for ReleaseMigrationPopup { tab, wont_open, deps_open, + copy_configs, target_running, )), ) @@ -205,6 +210,7 @@ fn dialog( tab: State, wont_open: State, deps_open: State, + copy_configs: State, target_running: bool, ) -> impl IntoElement { let plan = match prompt.plan() { @@ -240,6 +246,7 @@ fn dialog( tab, wont_open, deps_open, + copy_configs, target_running, )) } @@ -419,6 +426,7 @@ fn content_panel( tab: State, wont_open: State, deps_open: State, + copy_configs: State, target_running: bool, ) -> impl IntoElement { let close = dispatch.clone(); @@ -493,7 +501,14 @@ fn content_panel( .height(Size::px(1.)) .background(colors::component_border()), ) - .child(footer(prompt, plan, dispatch, excluded, target_running)) + .child(footer( + prompt, + plan, + dispatch, + excluded, + copy_configs, + target_running, + )) } fn source_picker(prompt: &ReleaseMigrationPrompt, dispatch: crate::Actions) -> impl IntoElement { @@ -1117,6 +1132,7 @@ fn footer( plan: Option<&ReleaseMigrationPlan>, dispatch: crate::Actions, excluded: State>, + copy_configs: State, target_running: bool, ) -> impl IntoElement { let dismiss = dispatch.clone(); @@ -1159,7 +1175,9 @@ fn footer( .height(Size::px(52.)) .font_size(15.) .font_weight(FontWeight::SEMI_BOLD) - .on_press(move |_| dispatch.migrate_release_packages(chosen.clone())) + .on_press(move |_| { + dispatch.migrate_release_packages(chosen.clone(), *copy_configs.peek()); + }) .text("Migrate"); if target_running { migrate = migrate.tooltip(format!("Close {} before migrating", prompt.target.name)); @@ -1181,6 +1199,11 @@ fn footer( .max_lines(2) .color(colors::fg_secondary()), ) + .maybe_child( + prompt + .target_dedicated + .then(|| checkbox_labeled(copy_configs, "Copy configs")), + ) .child( Button::new() .ghost() diff --git a/packages/oneclient_app/src/hooks/actions/release_migration.rs b/packages/oneclient_app/src/hooks/actions/release_migration.rs index 74e32d26..11b9fc38 100644 --- a/packages/oneclient_app/src/hooks/actions/release_migration.rs +++ b/packages/oneclient_app/src/hooks/actions/release_migration.rs @@ -13,8 +13,8 @@ use oneclient_content::packages::{ }; use oneclient_core::ReleaseTarget; use oneclient_core::clusters::{ - Cluster, OfferLookup, ReleaseMigrationOffer, manual_migration_offer, record_new_versions, - release_migration_offer, + Cluster, OfferLookup, ReleaseMigrationOffer, copy_configs, manual_migration_offer, + record_new_versions, release_migration_offer, }; use oneclient_events::Level; @@ -255,6 +255,7 @@ impl Actions { self.write_release_migration(move |prompt| { *prompt = Some(ReleaseMigrationPrompt { key: offer.release.key(), + target_dedicated: offer.target.uses_dedicated_dir(), target: offer.target, java_major, sources, @@ -417,6 +418,7 @@ impl Actions { actions.write_release_migration(move |prompt| { *prompt = Some(ReleaseMigrationPrompt { key: release.key(), + target_dedicated: target.uses_dedicated_dir(), target, java_major: None, sources: vec![source], @@ -661,7 +663,11 @@ impl Actions { } } - pub fn migrate_release_packages(&self, packages: Vec) { + pub fn migrate_release_packages( + &self, + packages: Vec, + copy_configs_too: bool, + ) { let mut taken = None; self.write_release_migration(|prompt| taken = prompt.take()); let Some(prompt) = taken else { return }; @@ -708,6 +714,7 @@ impl Actions { spawn_forever(async move { let Ok(state) = launcher::state() else { return }; let content = state.services.content(); + let source = prompt.source().filter(|_| copy_configs_too).cloned(); let target = prompt.target; let total = packages.len() + dependencies.len(); @@ -788,6 +795,13 @@ impl Actions { tracing::warn!(error = %err, "could not add packages without a build to the migration waitlist"); } + if let Some(source) = &source { + match copy_configs(&state, source, &target).await { + Ok(copied) => tracing::info!(copied, "copied config files into the target"), + Err(err) => tracing::warn!(error = %err, "could not copy config files"), + } + } + let failed = total - migrated; let session_id = session.detach(); let spec = Some(NotificationSpec { diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index b9e12321..05d0ce9f 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -75,6 +75,7 @@ pub enum ReleasePlanState { pub struct ReleaseMigrationPrompt { pub key: String, pub target: Cluster, + pub target_dedicated: bool, pub java_major: Option, pub sources: Vec, pub selected: i64, diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs index 26ffc209..0d315a77 100644 --- a/packages/oneclient_content/src/bundles/mod.rs +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -23,6 +23,7 @@ pub use manifest::BundleManifest as RemoteBundleManifest; pub use optional::{ PendingOptionalMod, pending_optional_mods, resolve_optional_mods, skip_optional_mods, }; +pub use overrides::bundle_override_paths; pub use types::{ ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleFileType, BundleManifest, BundleOptionalPackage, BundlePackageAddition, BundlePackageRemoval, diff --git a/packages/oneclient_content/src/bundles/overrides.rs b/packages/oneclient_content/src/bundles/overrides.rs index 4df936be..afd1ad04 100644 --- a/packages/oneclient_content/src/bundles/overrides.rs +++ b/packages/oneclient_content/src/bundles/overrides.rs @@ -1,9 +1,12 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use oneclient_common::domain::GameLoader; use oneclient_db::models::ClusterRow; use serde::{Deserialize, Serialize}; +use crate::bundles::BundlesManager; +use crate::ctx::ContentCtx; use crate::error::ContentResult; use oneclient_events::EventBus; use polyio::{ZipEntryCursor, sha1_bytes, sha1_file}; @@ -82,6 +85,20 @@ async fn sync_bundle_overrides_at( .await } +pub async fn bundle_override_paths( + bundles: &BundlesManager, + ctx: &ContentCtx, + mc_version: &str, + loader: GameLoader, +) -> ContentResult> { + let mut paths = HashSet::new(); + for archive in bundles.archives_for(ctx, mc_version, loader).await? { + let layers = OverrideLayers::open(&archive.bundle.path, &[OVERRIDES_PREFIX]).await?; + paths.extend(layers.entries().iter().map(|(rel, _)| rel.clone())); + } + Ok(paths) +} + pub(crate) struct OverrideLayers { cursor: ZipEntryCursor, entries: Vec<(String, String)>, diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index f4a2e2db..883ba3b7 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -17,7 +17,7 @@ pub use prepare::{ }; pub use provision::{ensure_from_bundles, ensure_from_versions}; pub use release_migration::{ - OfferLookup, ReleaseMigrationOffer, can_migrate_manually, manual_migration_offer, + OfferLookup, ReleaseMigrationOffer, can_migrate_manually, copy_configs, manual_migration_offer, rank_migration_sources, record_new_versions, release_migration_offer, }; pub use unlink_legacy::{SweepReport, unlink_legacy_cluster_content}; diff --git a/packages/oneclient_core/src/clusters/release_migration.rs b/packages/oneclient_core/src/clusters/release_migration.rs index 2eff216d..f6a8233a 100644 --- a/packages/oneclient_core/src/clusters/release_migration.rs +++ b/packages/oneclient_core/src/clusters/release_migration.rs @@ -1,9 +1,12 @@ use std::cmp::Reverse; +use std::collections::HashSet; +use std::path::Path; use oneclient_common::domain::GameLoader; use oneclient_cluster::Cluster; use oneclient_common::version::parse_mc_version; +use oneclient_content::bundles::bundle_override_paths; use oneclient_content::packages::release_migration::{ has_migratable_packages, has_packages_to_migrate, }; @@ -220,10 +223,96 @@ async fn offer_for( }))) } +pub async fn copy_configs( + state: &LauncherState, + source: &Cluster, + target: &Cluster, +) -> LauncherResult { + if !target.uses_dedicated_dir() { + return Ok(0); + } + let from = source.game_dir()?.join("config"); + let to = target.game_dir()?.join("config"); + if from == to || !polyio::try_exists(&from).await? { + return Ok(0); + } + let bundled = bundle_override_paths( + &state.bundles, + &state.services.content(), + &target.mc_version, + target.mc_loader, + ) + .await?; + copy_missing(&from, &to, "config", &bundled).await +} + +async fn copy_missing( + from: &Path, + to: &Path, + rel: &str, + skip: &HashSet, +) -> LauncherResult { + let mut copied = 0; + let mut stack = vec![(from.to_path_buf(), to.to_path_buf(), rel.to_string())]; + while let Some((src, dst, rel)) = stack.pop() { + polyio::create_dir_all(&dst).await?; + let mut entries = polyio::read_dir(&src).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let child = dst.join(&name); + let child_rel = format!("{rel}/{}", name.to_string_lossy()); + let file_type = entry.file_type().await?; + if file_type.is_dir() { + stack.push((entry.path(), child, child_rel)); + } else if file_type.is_file() + && !skip.contains(&child_rel) + && !polyio::try_exists(&child).await? + { + polyio::copy(entry.path(), &child).await?; + copied += 1; + } + } + } + Ok(copied) +} + #[cfg(test)] mod tests { use super::*; + #[tokio::test] + async fn copy_missing_keeps_existing_and_bundled_files() { + let src = polyio::tempdir().await.expect("src dir"); + let dst = polyio::tempdir().await.expect("dst dir"); + let (src, dst) = (src.dir_path(), dst.dir_path()); + + polyio::create_dir_all(src.join("sub")).await.unwrap(); + polyio::write(src.join("a.json"), b"old").await.unwrap(); + polyio::write(src.join("sub/b.json"), b"b").await.unwrap(); + polyio::write(src.join("sub/bundled.json"), b"bundled") + .await + .unwrap(); + polyio::write(dst.join("a.json"), b"keep").await.unwrap(); + + let skip = HashSet::from(["config/sub/bundled.json".to_string()]); + assert_eq!(copy_missing(src, dst, "config", &skip).await.unwrap(), 1); + assert!( + !polyio::try_exists(dst.join("sub/bundled.json")) + .await + .unwrap() + ); + assert_eq!( + polyio::read_to_string(dst.join("a.json")).await.unwrap(), + "keep" + ); + assert_eq!( + polyio::read_to_string(dst.join("sub/b.json")) + .await + .unwrap(), + "b" + ); + } + #[test] fn newer_minor_orders_above_older_minor() { assert!(version_order("26.2") < version_order("26.3"));