From 02e8ced71798a3dfac07f51187422f1c4dbfa73c Mon Sep 17 00:00:00 2001 From: nxroot Date: Thu, 22 Oct 2026 12:30:05 +0100 Subject: [PATCH] Mod manager: offer to uninstall what a mod pack brought in Installing a mod pack installs its dependencies, but uninstalling it left them all behind. The uninstall confirmation now lists the mods the chosen ones list as dependencies (and theirs in turn) that no mod staying installed still lists, with "Also uninstall the N mods it brought in, which nothing else needs" ticked. A dependency another mod needs is never offered; unticked, only the chosen mods go, as before. Co-Authored-By: Claude Opus 5.5 --- Launcher/Test/mod_manager_tests.cpp | 24 ++++++++++ Launcher/gui_internal.h | 4 ++ Launcher/gui_mods.cpp | 37 ++++++++++++--- Launcher/mod_manager.cpp | 73 +++++++++++++++++++++++++++++ Launcher/mod_manager.h | 8 ++++ 5 files changed, 139 insertions(+), 7 deletions(-) diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index a418dec0..7dc01df8 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -466,6 +466,30 @@ int main() { "Only a retired mod is left out"); } + // A mod pack's dependencies: what it brought in and nothing else needs, through their own dependencies. + { + const auto packs = root / L"pack-mods"; + const auto manifest = [&](const char* folder, const char* dependencies) { + write(packs / folder / "manifest.json", + std::string(R"({"name":"x","version_number":"1.0.0","dependencies":)") + dependencies + "}"); + }; + manifest("Me-Pack", R"(["Team-A-1.0.0","Team-B-2.0.0","Team-Gone-1.0.0"])"); + manifest("Team-A", R"(["Team-C-1.0.0"])"); + manifest("Team-B", "[]"); + manifest("Team-C", "[]"); + manifest("Me-Other", R"(["team-b-2.0.0"])"); + manifest("Team-D", "[]"); + const std::vector all{"Team-D", "Team-C", "Me-Other", "Team-B", "Team-A", "Me-Pack"}; + check(launcher_mods::orphaned_dependencies(packs, {"Me-Pack"}, all) == std::vector{"Team-C", "Team-A"}, + "A pack's dependencies (and theirs) were not offered, or one another mod needs was"); + check(launcher_mods::orphaned_dependencies(packs, {"Me-Pack", "Me-Other"}, all) == + std::vector{"Team-C", "Team-B", "Team-A"}, + "A dependency only removed mods need was not offered"); + check(launcher_mods::orphaned_dependencies(packs, {"Team-D"}, all).empty(), "A mod without dependencies offered some"); + check(launcher_mods::orphaned_dependencies(packs, {"Me-Pack", "Team-A"}, all) == std::vector{"Team-C"}, + "A dependency removed already was offered again"); + } + std::error_code ignored; fs::remove_all(root, ignored); if (failures) { diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 610cf583..5118f80a 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -608,6 +608,10 @@ struct ModsPanel { std::string message; bool message_error{}; std::vector confirm_remove; // folders awaiting "Uninstall" confirmation + // The mods those brought in that nothing else needs (launcher_mods::orphaned_dependencies), + // offered with them; worked out once for the folders in remove_extras_for. + std::vector remove_extras, remove_extras_for; + bool remove_extras_on{true}; fs::path conflict_source; // install waiting for "Replace" confirmation std::string conflict_name; diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index cd5c7684..a7d738c2 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -1910,24 +1910,45 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo if (!panel.conflict_name.empty() && !ImGui::IsPopupOpen("Replace mod")) ImGui::OpenPopup("Replace mod"); ImGui::SetNextWindowSize(ImVec2(S(460), 0)); if (ImGui::BeginPopupModal(uninstall, nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) { - const auto names = panel.confirm_remove; + const auto chosen = panel.confirm_remove; const auto title = [&](const std::string& name) -> const std::string& { for (const auto& entry : entries) if (entry.mod.name == name) return entry.mod.title; return name; }; + // A mod pack's dependencies stay installed unless asked: offer the ones nothing else needs. + if (panel.remove_extras_for != chosen) { + std::vector everything; + for (const auto& entry : entries) everything.push_back(entry.mod.name); + panel.remove_extras = launcher_mods::orphaned_dependencies(panel.root, chosen, everything); + panel.remove_extras_for = chosen; + panel.remove_extras_on = true; + } + auto names = chosen; + if (panel.remove_extras_on) names.insert(names.end(), panel.remove_extras.begin(), panel.remove_extras.end()); std::uint64_t bytes{}; for (const auto& name : names) if (const auto found = panel.sizes.find(name); found != panel.sizes.end()) bytes += found->second; ImGui::PushTextWrapPos(0); - if (names.size() == 1) { - ImGui::Text("Uninstall \"%s\"? Its folder goes to the Recycle Bin.", title(names.front()).c_str()); + if (chosen.size() == 1) { + ImGui::Text("Uninstall \"%s\"? Its folder goes to the Recycle Bin.", title(chosen.front()).c_str()); } else { - ImGui::Text("Uninstall these %d mods? Their folders go to the Recycle Bin.", static_cast(names.size())); + ImGui::Text("Uninstall these %d mods? Their folders go to the Recycle Bin.", static_cast(chosen.size())); ImGui::Spacing(); - const std::size_t listed = std::min(names.size(), 8); - for (std::size_t i = 0; i < listed; ++i) ImGui::TextDisabled("%s", title(names[i]).c_str()); - if (names.size() > listed) ImGui::TextDisabled("and %d more", static_cast(names.size() - listed)); + const std::size_t listed = std::min(chosen.size(), 8); + for (std::size_t i = 0; i < listed; ++i) ImGui::TextDisabled("%s", title(chosen[i]).c_str()); + if (chosen.size() > listed) ImGui::TextDisabled("and %d more", static_cast(chosen.size() - listed)); + } + if (!panel.remove_extras.empty()) { + ImGui::Spacing(); + const auto count = static_cast(panel.remove_extras.size()); + const auto label = count == 1 ? std::string("Also uninstall the mod it brought in, which nothing else needs") + : std::format("Also uninstall the {} mods it brought in, which nothing else needs", count); + ImGui::Checkbox(label.c_str(), &panel.remove_extras_on); + const std::size_t listed = std::min(panel.remove_extras.size(), 8); + for (std::size_t i = 0; i < listed; ++i) ImGui::TextDisabled("%s", title(panel.remove_extras[i]).c_str()); + if (panel.remove_extras.size() > listed) + ImGui::TextDisabled("and %d more", static_cast(panel.remove_extras.size() - listed)); } if (bytes) ImGui::TextDisabled("That frees %s.", size_text(bytes).c_str()); ImGui::PopTextWrapPos(); @@ -1935,6 +1956,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo // Escape (and a controller's B) answers Cancel, never the destructive choice. if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { panel.confirm_remove.clear(); + panel.remove_extras_for.clear(); ImGui::CloseCurrentPopup(); } ImGui::SameLine(); @@ -1970,6 +1992,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo panel.message = message; panel.message_error = error; panel.confirm_remove.clear(); + panel.remove_extras_for.clear(); ImGui::CloseCurrentPopup(); } pop_primary_button(); diff --git a/Launcher/mod_manager.cpp b/Launcher/mod_manager.cpp index cd44bb60..5e1e92e7 100644 --- a/Launcher/mod_manager.cpp +++ b/Launcher/mod_manager.cpp @@ -436,6 +436,79 @@ std::string install(const fs::path& mods, const fs::path& source, bool replace, } } +namespace { +// The mod folders `folder`'s manifest.json lists as dependencies, folded to lower case. +std::vector listed_dependencies(const fs::path& folder) { + std::vector result; + try { + std::string text; + { + std::ifstream input(folder / mods::manifest_file, std::ios::binary); + if (!input) return result; + text.assign(std::istreambuf_iterator(input), {}); + } + if (text.starts_with("\xef\xbb\xbf")) text.erase(0, 3); + const auto json = Json::parse(text, JsonLimits{1024 * 1024, 16, 65536}); + if (!json.is_object() || !json.contains("dependencies") || !json.at("dependencies").is_array()) return result; + for (const auto& item : json.at("dependencies")) { + if (!item.is_string()) continue; + const auto& name = item.string(); + // Namespace-Name-1.2.3: the folder is everything before the version. + const auto dash = name.rfind('-'); + if (dash == std::string::npos || name.find('-') == dash) continue; + result.push_back(lower(std::string_view(name).substr(0, dash))); + } + } catch (...) { + result.clear(); + } + return result; +} +} + +std::vector orphaned_dependencies(const fs::path& mods, const std::vector& removing, + const std::vector& installed) { + std::vector folded; + for (const auto& name : installed) folded.push_back(lower(name)); + const auto is_installed = [&](const std::string& name) { return std::find(folded.begin(), folded.end(), name) != folded.end(); }; + std::vector gone; + for (const auto& name : removing) gone.push_back(lower(name)); + const auto listed = [&](const std::string& name) { + const auto at = std::find(folded.begin(), folded.end(), name); + return at == folded.end() ? std::vector{} : listed_dependencies(mods / from_utf8(installed[at - folded.begin()])); + }; + // Everything the removed mods bring in, through their dependencies' dependencies. + std::vector candidates, queue = gone; + while (!queue.empty()) { + const auto name = queue.back(); + queue.pop_back(); + for (const auto& dependency : listed(name)) + if (is_installed(dependency) && std::find(gone.begin(), gone.end(), dependency) == gone.end() && + std::find(candidates.begin(), candidates.end(), dependency) == candidates.end()) { + candidates.push_back(dependency); + queue.push_back(dependency); + } + } + // What a mod that stays still lists stays too, with what that one lists in turn. + for (bool changed = true; changed;) { + changed = false; + for (std::size_t i = 0; i < folded.size(); ++i) { + const auto& name = folded[i]; + if (std::find(gone.begin(), gone.end(), name) != gone.end() || + std::find(candidates.begin(), candidates.end(), name) != candidates.end()) + continue; + for (const auto& dependency : listed(name)) + if (const auto at = std::find(candidates.begin(), candidates.end(), dependency); at != candidates.end()) { + candidates.erase(at); + changed = true; + } + } + } + std::vector result; + for (std::size_t i = 0; i < folded.size(); ++i) + if (std::find(candidates.begin(), candidates.end(), folded[i]) != candidates.end()) result.push_back(installed[i]); + return result; +} + void remove(const fs::path& mods, const std::string& name) { if (!mods::valid_mod_name(name)) fail("\"" + name + "\" is not a mod folder."); const auto target = mods / from_utf8(name); diff --git a/Launcher/mod_manager.h b/Launcher/mod_manager.h index 5bfabddb..d11aa1d0 100644 --- a/Launcher/mod_manager.h +++ b/Launcher/mod_manager.h @@ -53,5 +53,13 @@ std::string install(const std::filesystem::path& mods_root, const std::filesyste // Moves Mods/ to the Recycle Bin. Throws a user-facing message. void remove(const std::filesystem::path& mods_root, const std::string& name); +// The installed mods a pack brought in that nothing else needs, to offer with its uninstall: +// the folders the manifest.json of a mod in `removing` lists as dependencies ("Namespace-Name-1.2.3" +// is the folder Namespace-Name), and theirs in turn, without the ones `removing` already has and +// without any that a mod staying installed (the rest of `installed`) still lists. In `installed`'s +// order; an unreadable manifest lists nothing. +std::vector orphaned_dependencies(const std::filesystem::path& mods_root, + const std::vector& removing, const std::vector& installed); + } // namespace dingosdk::launcher_mods