feat: optionally copy configs into dedicated-folder targets on migration

This commit is contained in:
Julian Chang 2026-10-01 03:46:09 -04:00
parent 0e98be3d5a
commit 190723a4f8
No known key found for this signature in database
7 changed files with 153 additions and 8 deletions

View file

@ -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::<SelectionKey>::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<PackageTab>,
wont_open: State<bool>,
deps_open: State<bool>,
copy_configs: State<bool>,
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<PackageTab>,
wont_open: State<bool>,
deps_open: State<bool>,
copy_configs: State<bool>,
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<HashSet<SelectionKey>>,
copy_configs: State<bool>,
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()

View file

@ -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<ReleaseMigrationPackage>) {
pub fn migrate_release_packages(
&self,
packages: Vec<ReleaseMigrationPackage>,
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 {

View file

@ -75,6 +75,7 @@ pub enum ReleasePlanState {
pub struct ReleaseMigrationPrompt {
pub key: String,
pub target: Cluster,
pub target_dedicated: bool,
pub java_major: Option<u32>,
pub sources: Vec<Cluster>,
pub selected: i64,

View file

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

View file

@ -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<HashSet<String>> {
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)>,

View file

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

View file

@ -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<usize> {
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<String>,
) -> LauncherResult<usize> {
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"));