diff --git a/packages/app-lib/src/api/pack/hosted.rs b/packages/app-lib/src/api/pack/hosted.rs index ad1cb13..1bc6023 100644 --- a/packages/app-lib/src/api/pack/hosted.rs +++ b/packages/app-lib/src/api/pack/hosted.rs @@ -436,16 +436,23 @@ pub async fn create( // e.g. `/`. Avoid a `versions/` layout: that shape // is reserved for externally linked launcher instances and would make the // launcher expect a Minecraft version JSON beside the pack. - let game_dir_override = game_dir_root + let game_dir_override = match game_dir_root .as_deref() .map(str::trim) .filter(|root| !root.is_empty()) - .map(|root| { - Path::new(root) - .join(&publication.manifest.name) - .to_string_lossy() - .into_owned() - }); + { + Some(root) => { + // The pack's game files live in their own folder under the chosen + // root, e.g. `/`. If that folder already exists + // (a previous install of the same pack, or a name clash), pick a + // suffixed sibling instead of sharing the folder with another + // instance. + let base = Path::new(root).join(&publication.manifest.name); + let resolved = unique_game_dir(&base); + Some(resolved.to_string_lossy().into_owned()) + } + None => None, + }; let instance = crate::state::create_instance( crate::state::CreateInstance { name: publication.manifest.name.clone(), @@ -480,6 +487,29 @@ pub async fn create( Ok(instance.id) } +/// Returns `base` when its directory does not exist yet; otherwise returns the +/// first `base (n)` (n = 1, 2, …) whose directory is still free. Mirrors the +/// instance-folder de-duplication in `create_instance::resolve_instance_path`, +/// so re-installing the same hosted pack no longer makes two instances share a +/// single game folder. +fn unique_game_dir(base: &Path) -> PathBuf { + if !base.exists() { + return base.to_path_buf(); + } + let parent = base.parent().unwrap_or_else(|| Path::new("")); + let name = base + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "instance".to_string()); + let mut which = 1u32; + loop { + let candidate = parent.join(format!("{name} ({which})")); + if !candidate.exists() { + return candidate; + } + which += 1; + } +} pub async fn binding(instance_id: &str) -> crate::Result> { read_json(&crate::instance::get_full_path(instance_id).await?, BINDING) .await diff --git a/packages/app-lib/src/state/instances/commands/remove_instance.rs b/packages/app-lib/src/state/instances/commands/remove_instance.rs index d2d8993..4b18b0b 100644 --- a/packages/app-lib/src/state/instances/commands/remove_instance.rs +++ b/packages/app-lib/src/state/instances/commands/remove_instance.rs @@ -32,12 +32,17 @@ pub(crate) async fn remove_instance( .game_dir_override .as_deref() .map(PathBuf::from) - .filter(|path| is_version_isolated_game_dir(path)) + .filter(|path| { + // Delete the external game directory when the instance owns it. + // A version-isolated `versions/` folder is obviously + // exclusive; a plain `/` folder is also owned by + // this instance. A shared `.minecraft` root, however, holds the + // game's libraries/assets and must never be deleted with one + // instance. + is_version_isolated_game_dir(path) + || !is_shared_minecraft_root(path) + }) { - // New instances created against a configured `.minecraft` root use - // a private `versions/` directory. Remove that external - // directory when the instance is deleted, while preserving shared - // (non-isolated) overrides for backwards compatibility. game_dir_override } else { state.directories.instances_dir().join(&instance.path) @@ -69,3 +74,12 @@ fn is_version_isolated_game_dir(path: &Path) -> bool { .and_then(|name| name.to_str()) == Some("versions") } + +/// Heuristic: a shared `.minecraft` root holds the game's libraries and +/// assets, which must survive the removal of any single instance that points +/// at it. Instance-owned external folders (e.g. a hosted modpack's +/// `/` directory) contain only mods/saves/config and no such +/// shared game body. +fn is_shared_minecraft_root(path: &Path) -> bool { + path.join("libraries").is_dir() || path.join("assets").is_dir() +}