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