diff --git a/Launcher/Test/mod_profiles_tests.cpp b/Launcher/Test/mod_profiles_tests.cpp new file mode 100644 index 0000000..9d8f6dd --- /dev/null +++ b/Launcher/Test/mod_profiles_tests.cpp @@ -0,0 +1,158 @@ +// Mod profiles: storage, applying one to the installed mods, and r2modman's export formats. +#include "Launcher/mod_profiles.h" + +#include +#include +#include +#include +#include +#include + +using namespace dingosdk; +namespace profiles = launcher_profiles; + +namespace { + +int failures = 0; + +void check(bool condition, const char* what) { + if (condition) return; + std::cerr << "FAIL: " << what << '\n'; + ++failures; +} + +template std::string error_of(F&& run) { + try { run(); } catch (const std::exception& failure) { return failure.what(); } + return {}; +} + +mods::ModList installed(std::initializer_list> rows) { + mods::ModList list; + for (const auto& [name, version, enabled] : rows) { + mods::ModEntry entry; + entry.mod.name = name; + entry.mod.version = version; + entry.enabled = enabled; + list.entries.push_back(std::move(entry)); + } + return list; +} + +std::vector order(const std::vector& entries) { + std::vector names; + for (const auto& entry : entries) names.push_back(entry.mod.name + (entry.enabled ? "+" : "-")); + return names; +} + +void names() { + check(profiles::valid_profile_name("Street maps"), "An ordinary name was refused"); + for (const auto* name : {"", " lead", "trail ", "dot.", "a/b", "a\\b", "what?", "a:b", "tab\there"}) + check(!profiles::valid_profile_name(name), "A name that cannot be a file name was accepted"); + check(!profiles::valid_profile_name(std::string(49, 'a')), "An overlong name was accepted"); + check(profiles::clean_profile_name(" My: pack?. ") == "My best pack", "A name was not cleaned"); + check(profiles::clean_profile_name("???") == "Profile", "A name with nothing usable had no fallback"); + check(profiles::valid_profile_name(profiles::clean_profile_name(std::string(80, 'x'))), "A long name was not cut to fit"); + profiles::Profiles set; + set.list = {{"Pack", {}}, {"Pack (2)", {}}}; + check(set.free_name("Other") == "Other" && set.free_name("pack") == "pack (3)", "A free name was not found"); + check(set.find("PACK") == &set.list[0], "Profiles are not found whatever the case"); +} + +void applying() { + const auto list = installed({{"Team-A", "1.0.0", true}, {"Team-B", "2.1.0", false}, {"Local Mod", "wip", true}}); + const auto captured = profiles::capture("Now", list); + check(captured.mods.size() == 3 && captured.mods[1] == profiles::ProfileMod{"Team-B", "2.1.0", false} && + captured.mods[2].version == "0.0.0", "The installed mods were not captured as they are"); + check(profiles::in_use(captured, list), "A profile just captured is not in use"); + + const profiles::Profile profile{"Pack", {{"team-b", "2.1.0", true}, {"Team-C", "1.2.3", true}, {"Team-D", "1.0.0", false}, + {"Team-A", "1.0.0", false}, {"Team-B", "2.1.0", false}}}; + const auto applied = profiles::apply(profile, list); + check(order(applied.entries) == std::vector{"Team-B+", "Team-A-", "Local Mod-"}, + "A profile's order and switches were not applied, or a mod outside it stayed on"); + check(applied.missing.size() == 1 && applied.missing[0].name == "Team-C" && applied.missing[0].version == "1.2.3", + "The mods to download are not the missing ones that are switched on"); + check(!profiles::in_use(profile, list), "A profile with a mod to download counts as in use"); + auto after = list; + after.entries = applied.entries; + const profiles::Profile have{"Have", {{"Team-B", "2.1.0", true}, {"Team-A", "1.0.0", false}}}; + check(profiles::in_use(have, after) && !profiles::in_use(have, list), "In use does not follow which mods are on"); +} + +void storage(const std::filesystem::path& root) { + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + check(profiles::load(root).list.empty(), "A Mods folder with no profiles file has profiles"); + profiles::Profiles set; + set.list = {{"Street", {{"Team-A", "1.0.0", true}, {"Team-B", "2.0.0", false}}}, {"Empty", {}}}; + set.active = "Street"; + profiles::save(root, set); + const auto read = profiles::load(root); + check(read.active == "Street" && read.list == set.list, "Profiles did not survive saving and loading"); + std::ofstream(root / profiles::profiles_file) << R"({"active":"Gone","profiles":[{"name":"ok","mods":[{"name":"../x"},{"name":"Fine"}]},)" + R"({"name":"bad/name"},{"name":"ok"},7]})"; + const auto loose = profiles::load(root); + check(loose.list.size() == 1 && loose.list[0].mods.size() == 1 && loose.list[0].mods[0].name == "Fine" && + loose.list[0].mods[0].enabled && loose.active.empty(), "A damaged profiles file was not read loosely"); + std::ofstream(root / profiles::profiles_file) << "not json"; + check(profiles::load(root).list.empty(), "An unreadable profiles file was not treated as none"); +} + +void exporting(const std::filesystem::path& root) { + const profiles::Profile profile{"My \"best\" pack", {{"Team-Map_One", "1.10.3", true}, {"Local Mod", "0.0.0", false}}}; + const auto text = profiles::export_text(profile); + check(text.find("profileName: \"My \\\"best\\\" pack\"") == 0 && text.find(" minor: 10\n") != std::string::npos && + text.find(" enabled: false\n") != std::string::npos, "The export is not r2modman's layout"); + // clean_profile_name drops the quotes, which a file name cannot hold. + auto expected = profile; + expected.name = "My best pack"; + check(profiles::parse_export(text) == expected, "An export did not read back"); + check(profiles::parse_archive(profiles::export_archive(profile)) == expected, "A .r2z did not read back"); + // Left in the scratch folder, for opening with another zip reader. + const auto archive = profiles::export_archive(profile); + std::ofstream(root / "exported.r2z", std::ios::binary).write(archive.data(), static_cast(archive.size())); + check(profiles::parse_code_payload(profiles::code_payload(profile)) == expected, "A code payload did not read back"); + check(profiles::code_payload(profile).starts_with("#r2modman\n"), "A code payload lacks r2modman's first line"); + check(profiles::parse_export(profiles::export_text({"Empty", {}})) == profiles::Profile{"Empty", {}}, + "A profile with no mods did not read back"); + + // As r2modman's own YAML writer lays one out: unquoted, list items not indented, CRLF. + const auto theirs = profiles::parse_export("profileName: Default\r\nmods:\r\n- name: BepInEx-BepInExPack\r\n version:\r\n" + " major: 5\r\n minor: 4\r\n patch: 2100\r\n enabled: true\r\n- name: 'Team-Other'\r\n version:\r\n" + " major: 1\r\n minor: 0\r\n patch: 0\r\n enabled: false\r\n- name: bad/name\r\n enabled: true\r\n"); + check(theirs.name == "Default" && theirs.mods.size() == 2 && + theirs.mods[0] == profiles::ProfileMod{"BepInEx-BepInExPack", "5.4.2100", true} && + theirs.mods[1] == profiles::ProfileMod{"Team-Other", "1.0.0", false}, "r2modman's own export was misread"); + + check(!error_of([] { profiles::parse_export("just: text\n"); }).empty(), "Text that is not a profile was accepted"); + check(!error_of([] { profiles::parse_archive("PK\x03\x04 not really a zip"); }).empty(), "A broken archive was accepted"); + check(!error_of([] { profiles::parse_code_payload("not found"); }).empty(), "A page that is not a profile was accepted"); + check(!error_of([] { profiles::parse_code_payload("#r2modman\n!!!!"); }).empty(), "A payload that is not base64 was accepted"); +} + +void codes() { + check(profiles::valid_code("0193a2b4-7c1d-4e5f-8a9b-0c1d2e3f4a5b") && !profiles::valid_code("short") && + !profiles::valid_code("0193a2b4/../../etc") && !profiles::valid_code("0193a2b4 7c1d 4e5f"), "Profile codes are not checked"); + check(profiles::parse_created_code(R"({"key":"0193a2b4-7c1d-4e5f-8a9b-0c1d2e3f4a5b"})") == "0193a2b4-7c1d-4e5f-8a9b-0c1d2e3f4a5b", + "The code in a create answer was not read"); + check(!error_of([] { profiles::parse_created_code(R"({"detail":"throttled"})"); }).empty(), "An answer with no code was accepted"); + check(profiles::code_get_url("abc-123") == L"https://thunderstore.io/api/experimental/legacyprofile/get/abc-123/", + "The address of a code is wrong"); +} + +} // namespace + +int wmain(int argc, wchar_t** argv) { + if (argc != 2) { + std::cerr << "Pass a scratch folder\n"; + return 2; + } + names(); + applying(); + storage(std::filesystem::path(argv[1]) / L"profiles"); + exporting(std::filesystem::path(argv[1]) / L"profiles"); + codes(); + if (failures) return 1; + std::cout << "Mod profile names, applying, storage, exports and codes passed.\n"; + return 0; +} diff --git a/Launcher/Test/thunderstore_tests.cpp b/Launcher/Test/thunderstore_tests.cpp index 8a85e97..b124df2 100644 --- a/Launcher/Test/thunderstore_tests.cpp +++ b/Launcher/Test/thunderstore_tests.cpp @@ -173,6 +173,41 @@ int main() { check(wide.lines.size() == 1 && wide.lines[0].text == std::string(1999, 'a'), "An overlong line is cut between characters"); } + // ------------------------------------------------ dependencies and mod packs + { + const auto version = [](const char* owner, const char* name, const char* number, const char* needs) { + return std::string(R"({"name":")") + name + R"(","full_name":")" + owner + "-" + name + R"(","owner":")" + owner + + R"(","versions":[{"version_number":")" + number + R"(","download_url":"https://thunderstore.io/d/)" + name + + R"(/","dependencies":[)" + needs + R"(]},{"version_number":"0.9.0","download_url":"https://thunderstore.io/d/old/)" + + name + R"(/"}]})"; + }; + const auto shop = thunderstore::parse_listing("[" + + version("Zee", "Pack", "1.0.0", R"("Zee-Map-1.2.0","Kit-Rails-2.0.0","Gone-Mod-1.0.0")") + "," + + version("Zee", "Map", "1.2.0", R"("Kit-Rails-1.0.0","Kit-Core-1.0.0")") + "," + + version("Kit", "Rails", "2.0.0", R"("Kit-Core-1.0.0","Zee-Map-1.0.0")") + "," + + version("Kit", "Core", "1.0.0", "") + "]"); + check(shop.size() == 4 && shop[0].latest().dependencies.size() == 3, "A version's dependencies are read"); + check(thunderstore::split_dependency("Zee-Desert_Springs-1.10.0") == std::pair{"Zee-Desert_Springs", "1.10.0"} && + thunderstore::split_dependency("Zee-Map") == std::pair{"Zee-Map", ""}, + "A dependency splits into its package and version"); + const auto names = [](const std::vector& packages) { + std::string text; + for (const auto& package : packages) text += package.full_name + " "; + return text; + }; + std::vector unknown; + check(names(thunderstore::with_dependencies(shop[0], shop, {}, &unknown)) == "Kit-Core Kit-Rails Zee-Map Zee-Pack ", + "A pack's mods are gathered once each, needed ones first, through a cycle"); + check(unknown == std::vector{"Gone-Mod"}, "A dependency the listing lacks is reported"); + check(names(thunderstore::with_dependencies(shop[0], shop, {{"Kit-Core", "1.0.0"}, {"Kit-Rails", "1.5.0"}, {"Zee-Map", "1.2.0"}})) == + "Kit-Rails Zee-Pack ", "Dependencies installed at a new enough version are left out, and an old one is updated"); + check(names(thunderstore::with_dependencies(shop[3], shop, {})) == "Kit-Core ", "A package that needs nothing comes alone"); + check(shop[1].at_version("0.9.0").latest().number == "0.9.0" && shop[1].at_version("7.7.7").latest().number == "1.2.0" && + shop[1].at_version("0.9.0").versions.size() == 2, "A package is taken at an older version when it has it"); + check(thunderstore::find_package(shop, "Kit-Rails") == &shop[2] && !thunderstore::find_package(shop, "kit-rails-2.0.0"), + "A package is found by its full name"); + } + // ------------------------------------------------ community check(thunderstore::listing_index_url("reskate") == L"https://thunderstore.io/c/reskate/api/v1/package-listing-index/", "The listing index URL"); diff --git a/Launcher/gui_internal.h b/Launcher/gui_internal.h index 596dc6c..56b754d 100644 --- a/Launcher/gui_internal.h +++ b/Launcher/gui_internal.h @@ -4,6 +4,7 @@ #include "game_settings.h" #include "launch.h" #include "text_encoding.h" +#include "mod_profiles.h" #include "thunderstore.h" #include "Engine/Vfs/mod_list.h" @@ -488,9 +489,30 @@ struct ModsPanel { Icons icons; Readmes readmes; + // PROFILES (gui_mods_profiles.cpp). + launcher_profiles::Profiles profiles; + bool profiles_loaded{}; + std::array profile_name{}; // typed for a new profile + std::array profile_code{}; // typed to import + std::array profile_renamed{}; // typed in the rename popup + std::string profile_rename; // the profile being renamed + std::string profile_delete; // the profile awaiting "Delete" confirmation + // Loaded while its missing mods download: put in its order again once they are in. + std::string profile_settle; + // Talking to Thunderstore's profile service, off the UI thread. + struct ProfileResult { + bool ready{}, error{}; + std::string message, clipboard; + std::optional imported; + }; + std::thread profile_worker; + std::atomic profile_busy{}; + ProfileResult profile_result; // worker -> UI, under mutex + ~ModsPanel() { cancel = true; if (worker.joinable()) worker.join(); + if (profile_worker.joinable()) profile_worker.join(); } }; @@ -498,6 +520,19 @@ void scan(ModsPanel& panel, const launcher_app::Session& session); // Re-reads the Mods folder and the Thunderstore listing. void refresh_mods(Launcher& launcher, ModsPanel& panel); void start_install(ModsPanel& panel, const fs::path& source, bool replace); +// The Windows file (or folder) picker; empty when cancelled. A file picker shows +// `filter_pattern` ("*.zip") under `filter_name`. +fs::path pick_path(HWND owner, bool folder, const wchar_t* title, const wchar_t* filter_name, const wchar_t* filter_pattern); + +// ---------------------------------------------------------------- Profiles (gui_mods_profiles.cpp) + +// The PROFILES page body, under the page header. +void profiles_page(const Fonts& fonts, ModsPanel& panel, HWND window, float height, bool installing); +// The rename and delete confirmations; call once a frame with the page's other popups. +void profile_popups(ModsPanel& panel); +// A profile loaded while its mods downloaded is put in order once they are in; call once +// a frame, after collect_install. +void settle_profile(ModsPanel& panel); // ---------------------------------------------------------------- Thunderstore (gui_mods_browse.cpp) diff --git a/Launcher/gui_mods.cpp b/Launcher/gui_mods.cpp index 1ebf301..487d0e9 100644 --- a/Launcher/gui_mods.cpp +++ b/Launcher/gui_mods.cpp @@ -60,25 +60,10 @@ void collect_install(ModsPanel& panel, const launcher_app::Session& session) { logging::write(logging::Level::info, logging::Channel::launcher, "Mod installed: " + panel.finished_name); } -// The Windows file (or folder) picker; empty when cancelled. +// The picker for a mod to install: a .zip, or a folder. fs::path pick(HWND owner, bool folder) { - ComPtr dialog; - if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) return {}; - FILEOPENDIALOGOPTIONS options{}; - dialog->GetOptions(&options); - dialog->SetOptions(options | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | (folder ? FOS_PICKFOLDERS : FOS_FILEMUSTEXIST)); - if (!folder) { - const COMDLG_FILTERSPEC filter[]{{L"Mod archive (*.zip)", L"*.zip"}}; - dialog->SetFileTypes(1, filter); - } - dialog->SetTitle(folder ? L"Choose a mod folder to install" : L"Choose a mod .zip to install"); - if (FAILED(dialog->Show(owner))) return {}; - ComPtr item; - PWSTR path{}; - if (FAILED(dialog->GetResult(&item)) || FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) return {}; - fs::path result(path); - CoTaskMemFree(path); - return result; + return folder ? pick_path(owner, true, L"Choose a mod folder to install", nullptr, nullptr) + : pick_path(owner, false, L"Choose a mod .zip to install", L"Mod archive (*.zip)", L"*.zip"); } // Everything in the mod's folder, as Explorer would count it. The game's merged @@ -178,6 +163,10 @@ bool rail(const Fonts& fonts, ModsPanel& panel, HWND window, float width, store.loaded ? std::string() : std::string("Loading the Thunderstore listing"))) panel.tab = 1; if (panel.tab == 1) default_focus(); + if (nav_tile(fonts, width, "PROFILES", panel.tab == 2, + panel.profiles_loaded ? std::to_string(panel.profiles.list.size()) : std::string(), false, std::string())) + panel.tab = 2; + if (panel.tab == 2) default_focus(); ImGui::Dummy(ImVec2(0, S(8))); if (!pending.empty()) { @@ -854,6 +843,26 @@ void install_modal(const Fonts& fonts, ModsPanel& panel, ImVec2 size) { } // namespace +fs::path pick_path(HWND owner, bool folder, const wchar_t* title, const wchar_t* filter_name, const wchar_t* filter_pattern) { + ComPtr dialog; + if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog)))) return {}; + FILEOPENDIALOGOPTIONS options{}; + dialog->GetOptions(&options); + dialog->SetOptions(options | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | (folder ? FOS_PICKFOLDERS : FOS_FILEMUSTEXIST)); + if (!folder && filter_name && filter_pattern) { + const COMDLG_FILTERSPEC filter[]{{filter_name, filter_pattern}}; + dialog->SetFileTypes(1, filter); + } + dialog->SetTitle(title); + if (FAILED(dialog->Show(owner))) return {}; + ComPtr item; + PWSTR path{}; + if (FAILED(dialog->GetResult(&item)) || FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) return {}; + fs::path result(path); + CoTaskMemFree(path); + return result; +} + void refresh_mods(Launcher& launcher, ModsPanel& panel) { scan(panel, launcher.session()); refresh_listing(panel, ImGui::GetTime(), true); @@ -1062,6 +1071,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo const auto& session = launcher.session(); if (!panel.scanned) scan(panel, session); collect_install(panel, session); + settle_profile(panel); refresh_listing(panel, ImGui::GetTime()); // A page, not a panel: the mod list and the Thunderstore browser both want // the whole window. It sits at (0, 0), so window and screen space agree. @@ -1103,6 +1113,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo const float status = panel.message.empty() ? S(4) : ImGui::GetTextLineHeight() * 2 + S(12); const float body = ImGui::GetWindowHeight() - status; if (panel.tab == 1) browse_page(launcher, fonts, panel, body, installing); + else if (panel.tab == 2) profiles_page(fonts, panel, window, body, installing); else installed_page(launcher, fonts, panel, installed, body, installing); if (!panel.message.empty()) { ImGui::SetCursorPosY(ImGui::GetWindowHeight() - status + S(8)); @@ -1116,7 +1127,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo // Read before the overviews: one closing on Escape must not also leave the page. const bool popup_open = ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId); if (panel.tab == 1) package_overview(fonts, panel, frame, installing); - else mod_overview(fonts, panel, installed, frame, installing); + else if (panel.tab == 0) mod_overview(fonts, panel, installed, frame, installing); if (leave) close(); if (!installing && !popup_open && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) && !ImGui::IsAnyItemActive() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { @@ -1218,6 +1229,7 @@ void mods_window(Launcher& launcher, const Fonts& fonts, ImVec2 size, Ui& ui, Mo pop_primary_button(); ImGui::EndPopup(); } + profile_popups(panel); g_drag_allowed = !ImGui::IsAnyItemHovered() && !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId); ImGui::End(); if (installing) install_modal(fonts, panel, frame); diff --git a/Launcher/gui_mods_browse.cpp b/Launcher/gui_mods_browse.cpp index d28d000..1ed18c8 100644 --- a/Launcher/gui_mods_browse.cpp +++ b/Launcher/gui_mods_browse.cpp @@ -327,6 +327,17 @@ std::vector updates(const Store& store, const thun void start_store_install(ModsPanel& panel, std::vector packages) { if (panel.installing || packages.empty()) return; + { + // A package brings what it needs with it (a mod pack is nothing else), needed mods first. + const auto have = installed_versions(panel.list); + std::vector expanded; + for (const auto& package : packages) + for (auto& needed : thunderstore::with_dependencies(package, panel.store.packages, have)) + if (std::none_of(expanded.begin(), expanded.end(), + [&](const auto& queued) { return queued.full_name == needed.full_name; })) + expanded.push_back(std::move(needed)); + packages = std::move(expanded); + } if (panel.worker.joinable()) panel.worker.join(); panel.installing = true; panel.cancel = false; @@ -349,7 +360,7 @@ void start_store_install(ModsPanel& panel, std::vector pa log(logging::Level::info, std::format("Mod {} from Thunderstore: {} v{} into Mods\\{}", update ? "updated" : "installed", package.full_name, package.latest().number, name)); note = packages.size() > 1 - ? std::format("Updated {} mods. Changes apply the next time Skate starts.", done) + ? std::format("Installed or updated {} mods. Changes apply the next time Skate starts.", done) : std::format("{} {} v{}. It loads the next time Skate starts.", update ? "Updated" : "Installed", package.title(), package.latest().number); } catch (const std::exception& failure) { diff --git a/Launcher/gui_mods_profiles.cpp b/Launcher/gui_mods_profiles.cpp new file mode 100644 index 0000000..fb4da34 --- /dev/null +++ b/Launcher/gui_mods_profiles.cpp @@ -0,0 +1,479 @@ +#include "gui_internal.h" + +#include "mod_profiles.h" +#include "updater.h" + +#include "Engine/Core/Log/logging.h" + +#include +#include + +// The Mods panel's PROFILES page: named sets of mods to switch between, and +// sharing them as r2modman does, by code or by file (mod_profiles.h). Every mod +// stays installed once; loading a profile rewrites mods.json and downloads what +// it names that is missing. +namespace dingosdk::launcher_gui::detail { +namespace { + +namespace profiles = launcher_profiles; +namespace ts = thunderstore; + +constexpr int code_timeout_ms = 20000; +constexpr std::uint64_t max_code_answer = 8 * 1024 * 1024, max_profile_file = 64ull * 1024 * 1024; + +void log(logging::Level level, const std::string& message) { + logging::write(level, logging::Channel::launcher, message); +} + +void say(ModsPanel& panel, std::string message, bool error = false) { + panel.message = std::move(message); + panel.message_error = error; +} + +bool store(ModsPanel& panel) { + try { + profiles::save(panel.root, panel.profiles); + return true; + } catch (const std::exception& failure) { + say(panel, failure.what(), true); + return false; + } +} + +std::string trimmed(const char* text) { + std::string value(text); + while (!value.empty() && value.front() == ' ') value.erase(value.begin()); + while (!value.empty() && value.back() == ' ') value.pop_back(); + return value; +} + +// "a, b, c and 4 more". +std::string named(const std::vector& names) { + constexpr std::size_t shown = 3; + std::string text; + for (std::size_t i = 0; i < names.size() && i < shown; ++i) text += (i ? ", " : "") + names[i]; + if (names.size() > shown) text += std::format(" and {} more", names.size() - shown); + return text; +} + +// The profile's mods that are on, missing, and not in the Thunderstore listing either. +std::vector unobtainable(const ModsPanel& panel, const std::vector& missing) { + std::vector names; + for (const auto& mod : missing) + if (!package_for(panel.store, mod.name)) names.push_back(mod.name); + return names; +} + +// Writes mods.json as the profile has it. False, with the reason shown, when that failed. +bool write_order(ModsPanel& panel, const profiles::Profile& profile, std::vector& missing) { + auto applied = profiles::apply(profile, panel.list); + try { + mods::save_mod_order(panel.root, applied.entries); + } catch (const std::exception& failure) { + say(panel, failure.what(), true); + return false; + } + panel.list.entries = std::move(applied.entries); + panel.list.issue.clear(); + panel.marked.clear(); + panel.selected = -1; + missing = std::move(applied.missing); + return true; +} + +void load_profile(ModsPanel& panel, const profiles::Profile& profile) { + std::vector missing; + if (!write_order(panel, profile, missing)) return; + panel.profiles.active = profile.name; + if (!store(panel)) return; + log(logging::Level::info, std::format("Mod profile loaded: {} ({} mods, {} to download)", profile.name, + profile.mods.size(), missing.size())); + if (missing.empty()) { + say(panel, "Loaded \"" + profile.name + "\". Changes apply the next time Skate starts."); + return; + } + if (!panel.store.loaded) { + say(panel, std::format("Loaded \"{}\", but {} of its mods are not installed and the Thunderstore listing has not " + "arrived yet. Load it again in a moment to download them.", profile.name, missing.size()), true); + return; + } + std::vector packages; + for (const auto& mod : missing) + if (const auto* package = package_for(panel.store, mod.name)) + packages.push_back(package->at_version(mod.version)); + if (packages.empty()) { + say(panel, std::format("Loaded \"{}\". {} of its mods are not installed and are not on Thunderstore: {}.", + profile.name, missing.size(), named(unobtainable(panel, missing))), true); + return; + } + // The downloaded mods arrive switched on at the end of the list; settle_profile puts + // them where the profile has them once the install is done. + panel.profile_settle = profile.name; + start_store_install(panel, std::move(packages)); +} + +void add_profile(ModsPanel& panel, profiles::Profile profile, std::string_view verb) { + if (panel.profiles.list.size() >= profiles::max_profiles) { + say(panel, std::format("There is room for {} profiles. Delete one first.", profiles::max_profiles), true); + return; + } + profile.name = panel.profiles.free_name(profiles::clean_profile_name(profile.name)); + const auto name = profile.name; + const auto count = profile.mods.size(); + const auto missing = profiles::apply(profile, panel.list).missing.size(); + panel.profiles.list.push_back(std::move(profile)); + if (!store(panel)) { + panel.profiles.list.pop_back(); + return; + } + log(logging::Level::info, std::format("Mod profile {}: {} ({} mods)", verb, name, count)); + say(panel, missing ? std::format("{} \"{}\" ({} mods). LOAD it to switch to it; {} of its mods will be downloaded.", + verb, name, count, missing) + : std::format("{} \"{}\" ({} mods). LOAD it to switch to it.", verb, name, count)); +} + +std::string read_file(const fs::path& path) { + std::error_code error; + const auto size = fs::file_size(path, error); + if (error || size > max_profile_file) throw std::runtime_error("That file is too large to be a mod profile."); + std::ifstream input(path, std::ios::binary); + std::string bytes(static_cast(size), '\0'); + if (!input.read(bytes.data(), static_cast(bytes.size()))) + throw std::runtime_error("Could not read " + utf8(path.wstring())); + return bytes; +} + +// Runs `task` off the UI thread; what it returns is shown by collect_profile. +template void in_background(ModsPanel& panel, Task task) { + if (panel.profile_busy) return; + if (panel.profile_worker.joinable()) panel.profile_worker.join(); + panel.profile_busy = true; + panel.message.clear(); + panel.profile_worker = std::thread([&panel, task = std::move(task)] { + ModsPanel::ProfileResult result; + try { + result = task(); + } catch (const std::exception& failure) { + result.message = failure.what(); + result.error = true; + } + result.ready = true; + std::lock_guard lock(panel.mutex); + panel.profile_result = std::move(result); + panel.profile_busy = false; + }); +} + +void export_code(ModsPanel& panel, const profiles::Profile& profile) { + const auto missing_from_store = [&] { + std::size_t count{}; + for (const auto& mod : profile.mods) + if (mod.enabled && !package_for(panel.store, mod.name)) ++count; + return panel.store.loaded ? count : 0; + }(); + in_background(panel, [profile, missing_from_store] { + const auto answer = launcher_update::http_post(profiles::code_create_url(), L"application/octet-stream", + profiles::code_payload(profile), 64 * 1024, code_timeout_ms); + ModsPanel::ProfileResult result; + result.clipboard = profiles::parse_created_code(answer); + result.message = "The code for \"" + profile.name + "\" is on the clipboard: " + result.clipboard + + ". Thunderstore keeps codes for a limited time."; + if (missing_from_store) + result.message += std::format(" {} of its mods are not on Thunderstore, so whoever imports it will not get those.", + missing_from_store); + log(logging::Level::info, "Mod profile exported as a code: " + profile.name); + return result; + }); +} + +void import_code(ModsPanel& panel, std::string code) { + in_background(panel, [code = std::move(code)] { + std::string payload; + launcher_update::http_stream(profiles::code_get_url(code), max_code_answer, code_timeout_ms, + [&](const char* data, DWORD size) { payload.append(data, size); }); + ModsPanel::ProfileResult result; + result.imported = profiles::parse_code_payload(payload); + return result; + }); +} + +void export_file(ModsPanel& panel, const profiles::Profile& profile, HWND window) { + const auto folder = pick_path(window, true, L"Choose where to save the profile file", nullptr, nullptr); + if (folder.empty()) return; + try { + auto target = folder / (wide(profile.name) + L".r2z"); + for (int number = 2; fs::exists(target); ++number) + target = folder / (wide(profile.name) + std::format(L" ({}).r2z", number)); + const auto bytes = profiles::export_archive(profile); + std::ofstream output(target, std::ios::binary); + output.write(bytes.data(), static_cast(bytes.size())); + if (!output.flush()) throw std::runtime_error("Could not write " + utf8(target.wstring())); + log(logging::Level::info, "Mod profile exported as a file: " + profile.name); + say(panel, "Saved " + utf8(target.wstring()) + ". Anyone can import it here or in r2modman."); + } catch (const std::exception& failure) { + say(panel, failure.what(), true); + } +} + +void import_file(ModsPanel& panel, HWND window) { + const auto path = pick_path(window, false, L"Choose a profile file to import", L"Mod profile (*.r2z; *.r2x)", L"*.r2z;*.r2x"); + if (path.empty()) return; + try { + add_profile(panel, profiles::parse_archive(read_file(path)), "Imported"); + } catch (const std::exception& failure) { + say(panel, failure.what(), true); + } +} + +// Takes over what the background task finished. +void collect_profile(ModsPanel& panel) { + ModsPanel::ProfileResult result; + { + std::lock_guard lock(panel.mutex); + if (!panel.profile_result.ready) return; + result = std::exchange(panel.profile_result, {}); + } + if (!result.clipboard.empty()) ImGui::SetClipboardText(result.clipboard.c_str()); + if (result.imported) add_profile(panel, std::move(*result.imported), "Imported"); + else say(panel, std::move(result.message), result.error); +} + +} // namespace + +void settle_profile(ModsPanel& panel) { + // Read with the page, so the rail can count them before PROFILES is opened. + if (!panel.profiles_loaded && !panel.root.empty()) { + panel.profiles = profiles::load(panel.root); + panel.profiles_loaded = true; + } + if (panel.profile_settle.empty() || panel.installing) return; + { + std::lock_guard lock(panel.mutex); + if (panel.finished) return; // collect_install has not rescanned yet + } + const auto name = std::exchange(panel.profile_settle, {}); + const auto* profile = panel.profiles.find(name); + if (!profile) return; + // A failed download has already said why; keep that on screen. + const auto failure = panel.message_error ? panel.message : std::string(); + std::vector missing; + if (!write_order(panel, *profile, missing)) return; + if (!failure.empty()) say(panel, "Loaded \"" + name + "\", but not all of it: " + failure, true); + else if (!missing.empty()) + say(panel, std::format("Loaded \"{}\". {} of its mods are not installed and are not on Thunderstore: {}.", name, + missing.size(), named(unobtainable(panel, missing))), true); + else say(panel, "Loaded \"" + name + "\" and downloaded what it needed. Changes apply the next time Skate starts."); +} + +void profiles_page(const Fonts& fonts, ModsPanel& panel, HWND window, float height, bool installing) { + collect_profile(panel); + const bool busy = installing || panel.profile_busy; + const float top = ImGui::GetCursorPosY(); + + ImGui::PushTextWrapPos(0); + ImGui::TextDisabled("A profile remembers which of your mods are on and in what order. Every mod stays installed " + "once; loading a profile switches the rest off. Share one as a code or a file, with ReSkate " + "or r2modman."); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + + ImGui::BeginDisabled(busy); + // ------------------------------------------------ new, and the two ways in + const float button = S(280), gap = S(10); + ImGui::SetNextItemWidth(std::max(S(160), ImGui::GetContentRegionAvail().x - button - gap)); + const bool entered_name = ImGui::InputTextWithHint("##profile_name", "Name for a new profile", panel.profile_name.data(), + panel.profile_name.size(), ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(0, gap); + push_primary_button(); + const bool create = ImGui::Button("SAVE CURRENT MODS", ImVec2(button, 0)) || entered_name; + pop_primary_button(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Save what MY MODS shows now as a new profile"); + if (create) { + const auto name = trimmed(panel.profile_name.data()); + if (!profiles::valid_profile_name(name)) + say(panel, "Give the profile a name first (up to 48 characters, none of < > : \" / \\ | ? *).", true); + else if (panel.profiles.find(name)) say(panel, "A profile named \"" + name + "\" already exists.", true); + else { + const auto before = panel.profiles.list.size(); + add_profile(panel, profiles::capture(name, panel.list), "Saved"); + if (panel.profiles.list.size() > before) { + panel.profiles.active = panel.profiles.list.back().name; + store(panel); + panel.profile_name.fill(0); + } + } + } + const float half = (button - gap) * 0.5f; + ImGui::SetNextItemWidth(std::max(S(160), ImGui::GetContentRegionAvail().x - button - gap)); + const bool entered_code = ImGui::InputTextWithHint("##profile_code", "Paste a profile code", panel.profile_code.data(), + panel.profile_code.size(), ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(0, gap); + if (ImGui::Button("IMPORT CODE", ImVec2(half, 0)) || entered_code) { + const auto code = trimmed(panel.profile_code.data()); + if (!profiles::valid_code(code)) say(panel, "That is not a profile code. Paste the whole code you were given.", true); + else { + import_code(panel, code); + panel.profile_code.fill(0); + } + } + ImGui::SameLine(0, gap); + if (ImGui::Button("IMPORT FILE", ImVec2(half, 0))) import_file(panel, window); + ImGui::EndDisabled(); + if (panel.profile_busy) { + ImGui::SameLine(0, gap); + ImGui::TextDisabled("Talking to Thunderstore..."); + } + + // ------------------------------------------------ the profiles + ImGui::Spacing(); + ImGui::BeginChild("##profiles", ImVec2(0, height - (ImGui::GetCursorPosY() - top)), ImGuiChildFlags_NavFlattened); + if (panel.profiles.list.empty()) { + ImGui::Dummy(ImVec2(0, S(24))); + ImGui::TextDisabled("No profiles yet. Set up MY MODS the way you like, name it above and save it."); + } + // What a row asked for, carried out after the list is drawn: most of it changes the list. + enum class Ask { none, load, update, code, file, rename, remove } ask{Ask::none}; + std::size_t asked{}; + for (std::size_t index = 0; index < panel.profiles.list.size(); ++index) { + const auto& profile = panel.profiles.list[index]; + ImGui::PushID(static_cast(index)); + const auto on = std::count_if(profile.mods.begin(), profile.mods.end(), [](const auto& mod) { return mod.enabled; }); + const auto missing = profiles::apply(profile, panel.list).missing.size(); + const bool current = profiles::in_use(profile, panel.list); + const bool last = !current && profile.name == panel.profiles.active; + + ImGui::Dummy(ImVec2(0, S(6))); + ImGui::PushFont(fonts.bold); + ImGui::TextUnformatted(profile.name.c_str()); + ImGui::PopFont(); + if (current || last) { + ImGui::SameLine(0, S(12)); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(current ? color::good : color::warning), "%s", + current ? "IN USE" : "CHANGED SINCE LOADED"); + } + ImGui::TextDisabled("%s", (std::format("{} mods, {} on", profile.mods.size(), on) + + (missing ? std::format(" | {} to download", missing) : std::string())).c_str()); + ImGui::BeginDisabled(busy); + const auto act = [&](const char* label, Ask what, const char* tip, bool enabled = true, bool primary = false) { + ImGui::BeginDisabled(!enabled); + if (primary) push_primary_button(); + if (ImGui::Button(label, ImVec2(S(122), 0))) { + ask = what; + asked = index; + } + if (primary) pop_primary_button(); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", tip); + ImGui::SameLine(0, S(8)); + }; + act("LOAD", Ask::load, current ? "MY MODS already matches this profile" + : "Switch MY MODS to this profile, downloading what is missing", !current, true); + act("UPDATE", Ask::update, "Overwrite this profile with what MY MODS shows now", !current); + act("COPY CODE", Ask::code, "Upload this profile to Thunderstore and copy its code"); + act("SAVE FILE", Ask::file, "Save this profile as a .r2z file to send to someone"); + act("RENAME", Ask::rename, "Give this profile another name"); + act("DELETE", Ask::remove, "Delete this profile. No mod is uninstalled."); + ImGui::NewLine(); + ImGui::EndDisabled(); + ImGui::Dummy(ImVec2(0, S(6))); + ImGui::Separator(); + ImGui::PopID(); + } + ImGui::EndChild(); + + if (ask != Ask::none && asked < panel.profiles.list.size()) { + const auto profile = panel.profiles.list[asked]; // a copy: the list may change under it + switch (ask) { + case Ask::load: load_profile(panel, profile); break; + case Ask::update: { + panel.profiles.list[asked] = profiles::capture(profile.name, panel.list); + panel.profiles.active = profile.name; + if (store(panel)) say(panel, "Updated \"" + profile.name + "\" to what MY MODS shows now."); + else panel.profiles.list[asked] = profile; + break; + } + case Ask::code: export_code(panel, profile); break; + case Ask::file: export_file(panel, profile, window); break; + case Ask::rename: + panel.profile_rename = profile.name; + panel.profile_renamed.fill(0); + std::copy_n(profile.name.begin(), std::min(profile.name.size(), panel.profile_renamed.size() - 1), + panel.profile_renamed.begin()); + break; + case Ask::remove: panel.profile_delete = profile.name; break; + case Ask::none: break; + } + } +} + +void profile_popups(ModsPanel& panel) { + if (!panel.profile_rename.empty() && !ImGui::IsPopupOpen("Rename profile")) ImGui::OpenPopup("Rename profile"); + if (!panel.profile_delete.empty() && !ImGui::IsPopupOpen("Delete profile")) ImGui::OpenPopup("Delete profile"); + ImGui::SetNextWindowSize(ImVec2(S(440), 0)); + if (ImGui::BeginPopupModal("Rename profile", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) { + ImGui::Text("New name for \"%s\":", panel.profile_rename.c_str()); + ImGui::SetNextItemWidth(-1); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + const bool entered = ImGui::InputText("##renamed", panel.profile_renamed.data(), panel.profile_renamed.size(), + ImGuiInputTextFlags_EnterReturnsTrue); + const auto name = trimmed(panel.profile_renamed.data()); + auto* profile = panel.profiles.find(panel.profile_rename); + const auto* other = panel.profiles.find(name); + const bool usable = profile && profiles::valid_profile_name(name) && (!other || other == profile); + if (!usable && !name.empty()) + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(color::danger), "%s", + other && other != profile ? "Another profile has that name." : "That name cannot be used."); + ImGui::Spacing(); + if (ImGui::Button("Cancel", ImVec2(S(110), 0)) || ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + panel.profile_rename.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + ImGui::BeginDisabled(!usable); + push_primary_button(); + if (ImGui::Button("RENAME", ImVec2(S(110), 0)) || (entered && usable)) { + const auto old = profile->name; + profile->name = name; + if (panel.profiles.active == old) panel.profiles.active = name; + if (store(panel)) say(panel, "Renamed \"" + old + "\" to \"" + name + "\"."); + else { + profile->name = old; + if (panel.profiles.active == name) panel.profiles.active = old; + } + panel.profile_rename.clear(); + ImGui::CloseCurrentPopup(); + } + pop_primary_button(); + ImGui::EndDisabled(); + ImGui::EndPopup(); + } + ImGui::SetNextWindowSize(ImVec2(S(440), 0)); + if (ImGui::BeginPopupModal("Delete profile", nullptr, ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) { + ImGui::PushTextWrapPos(0); + ImGui::Text("Delete the profile \"%s\"? Your mods stay installed and stay as they are now.", + panel.profile_delete.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + // 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.profile_delete.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + push_primary_button(); + if (ImGui::Button("DELETE", ImVec2(S(110), 0))) { + const auto before = panel.profiles; + const auto name = panel.profile_delete; + std::erase_if(panel.profiles.list, [&](const auto& profile) { return profile.name == name; }); + if (panel.profiles.active == name) panel.profiles.active.clear(); + if (store(panel)) say(panel, "Deleted the profile \"" + name + "\". No mod was uninstalled."); + else panel.profiles = before; + panel.profile_delete.clear(); + ImGui::CloseCurrentPopup(); + } + pop_primary_button(); + ImGui::EndPopup(); + } +} + +} // namespace dingosdk::launcher_gui::detail diff --git a/Launcher/mod_profiles.cpp b/Launcher/mod_profiles.cpp new file mode 100644 index 0000000..81dec32 --- /dev/null +++ b/Launcher/mod_profiles.cpp @@ -0,0 +1,441 @@ +#include "Launcher/mod_profiles.h" + +#include "Engine/Core/Json/json.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace dingosdk::launcher_profiles { +namespace { + +[[noreturn]] void fail(const std::string& message) { throw std::runtime_error(message); } + +constexpr char export_name[] = "export.r2x"; +constexpr char payload_prefix[] = "#r2modman"; +constexpr std::size_t max_export_bytes = 1024 * 1024, max_payload_bytes = 8 * 1024 * 1024; +constexpr std::string_view base64_digits = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +// Folder names are compared as Windows compares them. +std::string folded(std::string_view text) { + std::string result(text); + for (auto& ch : result) + if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch + ('a' - 'A')); + return result; +} + +std::string_view trimmed(std::string_view text) { + while (!text.empty() && (text.front() == ' ' || text.front() == '\t')) text.remove_prefix(1); + while (!text.empty() && (text.back() == ' ' || text.back() == '\t' || text.back() == '\r')) text.remove_suffix(1); + return text; +} + +bool semantic(std::string_view version) { + int parts = 1; + bool digit = false; + for (const char ch : version) { + if (ch == '.') { + if (!digit) return false; + ++parts; + digit = false; + } else if (ch >= '0' && ch <= '9') digit = true; + else return false; + } + return digit && parts == 3; +} + +// A YAML scalar as plain text: quotes taken off, and a double-quoted one's escapes undone. +std::string scalar(std::string_view value) { + value = trimmed(value); + if (value.size() >= 2 && value.front() == '\'' && value.back() == '\'') { + std::string out; + const auto inner = value.substr(1, value.size() - 2); + for (std::size_t i = 0; i < inner.size(); ++i) { + out += inner[i]; + if (inner[i] == '\'' && i + 1 < inner.size() && inner[i + 1] == '\'') ++i; + } + return out; + } + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + std::string out; + const auto inner = value.substr(1, value.size() - 2); + for (std::size_t i = 0; i < inner.size(); ++i) { + if (inner[i] == '\\' && i + 1 < inner.size()) ++i; + out += inner[i]; + } + return out; + } + // An unquoted value ends at a comment. + if (const auto comment = value.find(" #"); comment != std::string_view::npos) value = trimmed(value.substr(0, comment)); + return std::string(value); +} + +std::string yaml_quoted(std::string_view text) { + std::string out = "\""; + for (const char ch : text) { + if (ch == '"' || ch == '\\') out += '\\'; + out += ch; + } + return out + '"'; +} + +std::wstring widen(std::string_view text) { return std::wstring(text.begin(), text.end()); } + +struct Zip { + mz_zip_archive value{}; + bool reader{}; + ~Zip() { + if (reader) mz_zip_reader_end(&value); + } +}; + +// A zip holding one file, stored as it is. miniz is built here without its writer, and a +// profile is a few kilobytes of text. +std::string stored_zip(std::string_view name, std::string_view data) { + std::string out; + const auto put = [&](std::uint32_t value, int bytes) { + for (int i = 0; i < bytes; ++i) out += static_cast((value >> (8 * i)) & 0xff); + }; + const auto crc = static_cast( + mz_crc32(MZ_CRC32_INIT, reinterpret_cast(data.data()), data.size())); + const auto size = static_cast(data.size()), length = static_cast(name.size()); + constexpr std::uint32_t dos_date = 0x21; // 1 January 1980: the format's zero + // Local header, then the file. + put(0x04034b50, 4); put(20, 2); put(0, 2); put(0, 2); put(0, 2); put(dos_date, 2); + put(crc, 4); put(size, 4); put(size, 4); put(length, 2); put(0, 2); + out += name; + out += data; + // Central directory with its one entry. + const auto central = static_cast(out.size()); + put(0x02014b50, 4); put(20, 2); put(20, 2); put(0, 2); put(0, 2); put(0, 2); put(dos_date, 2); + put(crc, 4); put(size, 4); put(size, 4); put(length, 2); put(0, 2); put(0, 2); put(0, 2); put(0, 2); put(0, 4); put(0, 4); + out += name; + const auto central_size = static_cast(out.size()) - central; + put(0x06054b50, 4); put(0, 2); put(0, 2); put(1, 2); put(1, 2); put(central_size, 4); put(central, 4); put(0, 2); + return out; +} + +std::string base64(std::string_view bytes) { + std::string out; + out.reserve((bytes.size() + 2) / 3 * 4); + for (std::size_t i = 0; i < bytes.size(); i += 3) { + const auto at = [&](std::size_t offset) { + return i + offset < bytes.size() ? static_cast(bytes[i + offset]) : 0u; + }; + const unsigned block = (at(0) << 16) | (at(1) << 8) | at(2); + out += base64_digits[(block >> 18) & 63]; + out += base64_digits[(block >> 12) & 63]; + out += i + 1 < bytes.size() ? base64_digits[(block >> 6) & 63] : '='; + out += i + 2 < bytes.size() ? base64_digits[block & 63] : '='; + } + return out; +} + +std::string unbase64(std::string_view text) { + std::string out; + unsigned block{}; + int bits{}; + for (const char ch : text) { + if (ch == '=' || ch == '\r' || ch == '\n' || ch == ' ' || ch == '\t') continue; + const auto digit = base64_digits.find(ch); + if (digit == std::string_view::npos) fail("The profile code holds text that is not a profile."); + block = (block << 6) | static_cast(digit); + bits += 6; + if (bits >= 8) { + bits -= 8; + out += static_cast((block >> bits) & 0xff); + } + } + return out; +} + +} // namespace + +Profile* Profiles::find(std::string_view name) { + for (auto& profile : list) + if (folded(profile.name) == folded(name)) return &profile; + return nullptr; +} + +std::string Profiles::free_name(std::string_view wanted) const { + const auto taken = [&](std::string_view name) { + return std::any_of(list.begin(), list.end(), [&](const Profile& p) { return folded(p.name) == folded(name); }); + }; + std::string name(wanted); + for (int number = 2; taken(name); ++number) { + const auto suffix = std::format(" ({})", number); + name = std::string(wanted.substr(0, max_name_bytes - std::min(max_name_bytes, suffix.size()))) + suffix; + } + return name; +} + +bool valid_profile_name(std::string_view name) { + if (name.empty() || name.size() > max_name_bytes || name.front() == ' ' || name.back() == ' ' || name.back() == '.') + return false; + return std::all_of(name.begin(), name.end(), [](char ch) { + const auto byte = static_cast(ch); + return byte >= 0x20 && byte != 0x7f && std::string_view("<>:\"/\\|?*").find(ch) == std::string_view::npos; + }); +} + +std::string clean_profile_name(std::string_view name) { + std::string out; + for (const char ch : name) { + const auto byte = static_cast(ch); + if (byte < 0x20 || byte == 0x7f || std::string_view("<>:\"/\\|?*").find(ch) != std::string_view::npos) continue; + out += ch; + } + while (out.size() > max_name_bytes) { + // Never cut a UTF-8 character in half. + do out.pop_back(); + while (!out.empty() && (static_cast(out.back()) & 0xC0) == 0x80); + } + while (!out.empty() && out.front() == ' ') out.erase(out.begin()); + while (!out.empty() && (out.back() == ' ' || out.back() == '.')) out.pop_back(); + return out.empty() ? std::string("Profile") : out; +} + +Profiles load(const fs::path& mods_root) noexcept { + Profiles profiles; + try { + std::ifstream input(mods_root / profiles_file, std::ios::binary); + if (!input) return profiles; + const std::string text{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + const auto root = Json::parse(text, JsonLimits{8 * 1024 * 1024, 8, 1024 * 1024}); + if (!root.is_object() || !root.contains("profiles") || !root.at("profiles").is_array()) return profiles; + for (const auto& row : root.at("profiles")) { + if (!row.is_object() || profiles.list.size() >= max_profiles) continue; + Profile profile; + profile.name = row.value("name", ""); + if (!valid_profile_name(profile.name) || profiles.find(profile.name)) continue; + if (row.contains("mods") && row.at("mods").is_array()) + for (const auto& item : row.at("mods")) { + if (!item.is_object() || profile.mods.size() >= max_profile_mods) continue; + ProfileMod mod{item.value("name", ""), item.value("version", ""), item.value("enabled", true)}; + if (mods::valid_mod_name(mod.name)) profile.mods.push_back(std::move(mod)); + } + profiles.list.push_back(std::move(profile)); + } + profiles.active = root.value("active", ""); + if (!profiles.find(profiles.active)) profiles.active.clear(); + } catch (...) { + profiles = {}; + } + return profiles; +} + +void save(const fs::path& mods_root, const Profiles& profiles) { + auto list = Json::array(); + for (const auto& profile : profiles.list) { + auto mods = Json::array(); + for (const auto& mod : profile.mods) { + Json row = Json::object(); + row["name"] = mod.name; + row["version"] = mod.version; + row["enabled"] = mod.enabled; + mods.push_back(std::move(row)); + } + Json row = Json::object(); + row["name"] = profile.name; + row["mods"] = std::move(mods); + list.push_back(std::move(row)); + } + Json root = Json::object(); + root["active"] = profiles.active; + root["profiles"] = std::move(list); + const auto target = mods_root / profiles_file; + auto temporary = target; + temporary += L".tmp"; + { + std::ofstream output(temporary, std::ios::binary | std::ios::trunc); + output << root.dump(2) << '\n'; + if (!output.flush()) fail("Could not write the profiles file in the Mods folder."); + } + std::error_code error; + fs::rename(temporary, target, error); + if (error) { + fs::remove(temporary, error); + fail("Could not replace the profiles file in the Mods folder."); + } +} + +Profile capture(std::string name, const mods::ModList& list) { + Profile profile; + profile.name = std::move(name); + for (const auto& entry : list.entries) { + if (profile.mods.size() >= max_profile_mods) break; + profile.mods.push_back({entry.mod.name, semantic(entry.mod.version) ? entry.mod.version : std::string("0.0.0"), + entry.enabled}); + } + return profile; +} + +Applied apply(const Profile& profile, const mods::ModList& list) { + Applied result; + std::map installed; + for (const auto& entry : list.entries) installed.emplace(folded(entry.mod.name), &entry); + std::set used; + for (const auto& mod : profile.mods) { + const auto key = folded(mod.name); + const auto found = installed.find(key); + if (found == installed.end()) { + if (mod.enabled) result.missing.push_back(mod); + continue; + } + if (!used.insert(key).second) continue; + result.entries.push_back(*found->second); + result.entries.back().enabled = mod.enabled; + } + for (const auto& entry : list.entries) + if (!used.contains(folded(entry.mod.name))) { + result.entries.push_back(entry); + result.entries.back().enabled = false; + } + return result; +} + +bool in_use(const Profile& profile, const mods::ModList& list) { + const auto applied = apply(profile, list); + if (!applied.missing.empty()) return false; + const auto enabled = [](const std::vector& entries) { + std::vector names; + for (const auto& entry : entries) + if (entry.enabled) names.push_back(folded(entry.mod.name)); + return names; + }; + return enabled(applied.entries) == enabled(list.entries); +} + +std::string export_text(const Profile& profile) { + std::string out = "profileName: " + yaml_quoted(profile.name) + "\nmods:\n"; + for (const auto& mod : profile.mods) { + std::array parts{}; + if (semantic(mod.version)) { + std::size_t at{}; + for (auto& part : parts) { + const auto end = std::min(mod.version.find('.', at), mod.version.size()); + std::from_chars(mod.version.data() + at, mod.version.data() + end, part); + at = end + 1; + } + } + out += std::format(" - name: {}\n version:\n major: {}\n minor: {}\n patch: {}\n enabled: {}\n", + yaml_quoted(mod.name), parts[0], parts[1], parts[2], mod.enabled ? "true" : "false"); + } + if (profile.mods.empty()) out.replace(out.size() - 1, 1, " []\n"); + return out; +} + +Profile parse_export(std::string_view text) { + if (text.size() > max_export_bytes) fail("The profile is too large to read."); + Profile profile; + bool named{}, listed{}; + std::array version{}; + const auto finish = [&] { + if (!profile.mods.empty() && profile.mods.back().version.empty()) + profile.mods.back().version = std::format("{}.{}.{}", version[0], version[1], version[2]); + }; + for (std::size_t at = 0; at < text.size();) { + const auto end = std::min(text.find('\n', at), text.size()); + auto line = text.substr(at, end - at); + at = end + 1; + const bool top = !line.empty() && line.front() != ' ' && line.front() != '\t' && line.front() != '-'; + line = trimmed(line); + if (line.empty() || line.front() == '#') continue; + if (line.front() == '-') { + // The next mod. What follows the dash is its first field. + finish(); + if (profile.mods.size() >= max_profile_mods) fail("The profile lists more mods than can be read."); + profile.mods.emplace_back(); + version = {}; + line = trimmed(line.substr(1)); + if (line.empty()) continue; + } + const auto colon = line.find(':'); + if (colon == std::string_view::npos) continue; + const auto key = trimmed(line.substr(0, colon)); + const auto value = scalar(line.substr(colon + 1)); + if (top && key == "profileName") { + profile.name = value; + named = true; + } else if (top && key == "mods") { + listed = true; + } else if (!profile.mods.empty()) { + auto& mod = profile.mods.back(); + const auto number = [&](int& part) { std::from_chars(value.data(), value.data() + value.size(), part); }; + if (key == "name") mod.name = value; + else if (key == "major") number(version[0]); + else if (key == "minor") number(version[1]); + else if (key == "patch") number(version[2]); + else if (key == "enabled") mod.enabled = value != "false"; + } + } + finish(); + if (!named || !listed) fail("That is not a mod profile (it has no profileName and mods)."); + profile.name = clean_profile_name(profile.name); + // A name that could not be a mod folder here is not a mod this launcher can hold. + std::erase_if(profile.mods, [](const ProfileMod& mod) { return !mods::valid_mod_name(mod.name); }); + return profile; +} + +std::string export_archive(const Profile& profile) { + return stored_zip(export_name, export_text(profile)); +} + +Profile parse_archive(std::string_view zip_bytes) { + // r2modman also accepts the bare export.r2x where a .r2z is asked for. + if (!zip_bytes.starts_with("PK")) return parse_export(zip_bytes); + Zip zip; + if (!mz_zip_reader_init_mem(&zip.value, zip_bytes.data(), zip_bytes.size(), 0)) fail("The profile file is not a readable archive."); + zip.reader = true; + const auto index = mz_zip_reader_locate_file(&zip.value, export_name, nullptr, 0); + mz_zip_archive_file_stat entry{}; + if (index < 0 || !mz_zip_reader_file_stat(&zip.value, static_cast(index), &entry) || entry.m_is_directory || + entry.m_is_encrypted) + fail("The profile file has no export.r2x in it."); + if (entry.m_uncomp_size > max_export_bytes) fail("The profile is too large to read."); + std::string text(static_cast(entry.m_uncomp_size), '\0'); + if (!mz_zip_reader_extract_to_mem(&zip.value, static_cast(index), text.data(), text.size(), 0)) + fail("The profile file is damaged."); + return parse_export(text); +} + +std::wstring code_create_url() { return L"https://thunderstore.io/api/experimental/legacyprofile/create/"; } + +std::wstring code_get_url(std::string_view code) { + return L"https://thunderstore.io/api/experimental/legacyprofile/get/" + widen(code) + L"/"; +} + +std::string code_payload(const Profile& profile) { + return std::string(payload_prefix) + "\n" + base64(export_archive(profile)); +} + +Profile parse_code_payload(std::string_view payload) { + if (payload.size() > max_payload_bytes) fail("The profile is too large to read."); + if (!payload.starts_with(payload_prefix)) fail("That code does not lead to a mod profile."); + return parse_archive(unbase64(payload.substr(sizeof(payload_prefix) - 1))); +} + +std::string parse_created_code(std::string_view json) { + const auto root = Json::parse(json, JsonLimits{64 * 1024, 4, 1024}); + const auto code = root.is_object() ? root.value("key", "") : std::string(); + if (!valid_code(code)) fail("Thunderstore answered without a profile code."); + return code; +} + +bool valid_code(std::string_view code) { + return code.size() >= 8 && code.size() <= 64 && std::all_of(code.begin(), code.end(), [](char ch) { + return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') || ch == '-'; + }); +} + +} // namespace dingosdk::launcher_profiles diff --git a/Launcher/mod_profiles.h b/Launcher/mod_profiles.h new file mode 100644 index 0000000..dea21e9 --- /dev/null +++ b/Launcher/mod_profiles.h @@ -0,0 +1,90 @@ +#pragma once + +#include "Engine/Vfs/mod_list.h" + +#include +#include +#include +#include + +// Mod profiles: named sets of mods ("Street maps", "Server X") a player switches +// between. Every mod is installed once, in the one Mods folder; a profile is a +// saved list of which mods are on, in what order, and at what version. Loading +// one rewrites mods.json. +// +// A profile travels as r2modman does it, so the two managers read each other's: +// export.r2x (YAML) inside a zip (.r2z), and a code that names that zip on +// Thunderstore's profile service. No network here; gui_mods_profiles.cpp sends +// and fetches. +namespace dingosdk::launcher_profiles { + +struct ProfileMod { + std::string name; // the mod's folder: Namespace-Name for a Thunderstore package + std::string version; // Major.Minor.Patch + bool enabled{true}; + bool operator==(const ProfileMod&) const = default; +}; + +struct Profile { + std::string name; + std::vector mods; // highest priority first, as mods.json lists them + bool operator==(const Profile&) const = default; +}; + +struct Profiles { + std::string active; // the profile last loaded or saved; empty for none + std::vector list; + + Profile* find(std::string_view name); + // `wanted`, or `wanted (2)`, `wanted (3)`... when a profile already has that name. + std::string free_name(std::string_view wanted) const; +}; + +inline constexpr char profiles_file[] = "profiles.json"; +inline constexpr std::size_t max_profiles = 64, max_profile_mods = 2000, max_name_bytes = 48; + +// 1 to 48 bytes of printable text, with nothing a file name refuses (a profile +// exports as .r2z) and no leading or trailing space. +bool valid_profile_name(std::string_view name); +// `name` made valid: refused characters dropped, cut to length, "Profile" when nothing is left. +std::string clean_profile_name(std::string_view name); + +// /profiles.json. A missing or unreadable file is no profiles; never throws. +Profiles load(const std::filesystem::path& mods_root) noexcept; +// Replaces the file atomically. Throws a readable message on failure. +void save(const std::filesystem::path& mods_root, const Profiles& profiles); + +// The installed mods as they are now, as a profile. +Profile capture(std::string name, const mods::ModList& list); + +struct Applied { + // What mods.json should hold: the profile's installed mods in its order with its + // switches, then every installed mod it does not name, switched off. + std::vector entries; + // The mods it switches on that are not installed, to be downloaded. + std::vector missing; +}; +Applied apply(const Profile& profile, const mods::ModList& list); +// True when loading `profile` would change nothing: the same mods on, in the same order. +bool in_use(const Profile& profile, const mods::ModList& list); + +// export.r2x. parse_export throws a readable message for text that is not one. +std::string export_text(const Profile& profile); +Profile parse_export(std::string_view text); +// A .r2z: a zip holding export.r2x. r2modman's own also carry config files, which are ignored. +std::string export_archive(const Profile& profile); +Profile parse_archive(std::string_view zip); + +// Thunderstore's profile service: POST the payload to create_url() as +// application/octet-stream and it answers {"key":""}; GET get_url(code) +// answers the payload back. A payload is "#r2modman\n" and the .r2z in base64. +std::wstring code_create_url(); +std::wstring code_get_url(std::string_view code); +std::string code_payload(const Profile& profile); +Profile parse_code_payload(std::string_view payload); +// The code in a create answer; throws when the answer has none. +std::string parse_created_code(std::string_view json); +// A code as the service gives them: hex digits and dashes. Surrounding space is not part of it. +bool valid_code(std::string_view code); + +} // namespace dingosdk::launcher_profiles diff --git a/Launcher/thunderstore.cpp b/Launcher/thunderstore.cpp index 58c77dc..25aefea 100644 --- a/Launcher/thunderstore.cpp +++ b/Launcher/thunderstore.cpp @@ -68,6 +68,7 @@ std::optional parse_version(const Json& row) { version.date_created = text(row, "date_created"); version.downloads = count(row, "downloads"); version.file_size = count(row, "file_size"); + version.dependencies = strings(row, "dependencies"); if (version.number.empty() || !https(version.download_url)) return std::nullopt; if (!https(version.icon)) version.icon.clear(); return version; @@ -135,6 +136,63 @@ std::wstring community_page(std::string_view community) { return widen(std::string(site) + "/c/" + std::string(community) + "/"); } +Package Package::at_version(std::string_view number) const { + auto result = *this; + const auto found = std::find_if(result.versions.begin(), result.versions.end(), + [&](const Version& version) { return version.number == number; }); + if (found != result.versions.end()) std::rotate(result.versions.begin(), found, found + 1); + return result; +} + +std::pair split_dependency(std::string_view text) { + const auto dash = text.rfind('-'); + if (dash == std::string_view::npos || dash == 0) return {std::string(text), {}}; + const auto version = text.substr(dash + 1); + const bool numbered = !version.empty() && std::all_of(version.begin(), version.end(), + [](char ch) { return (ch >= '0' && ch <= '9') || ch == '.'; }) && version.find('.') != std::string_view::npos; + if (!numbered) return {std::string(text), {}}; + return {std::string(text.substr(0, dash)), std::string(version)}; +} + +const Package* find_package(std::span listing, std::string_view full_name) { + for (const auto& package : listing) + if (package.full_name == full_name) return &package; + return nullptr; +} + +std::vector with_dependencies(const Package& package, std::span listing, + const std::map>& have, + std::vector* unknown) { + std::vector order; + std::vector visiting; + const auto seen = [&](std::string_view name) { + return std::find(visiting.begin(), visiting.end(), name) != visiting.end() || + std::any_of(order.begin(), order.end(), [&](const Package& done) { return done.full_name == name; }); + }; + // Depth first, so a package comes after what it needs. `visiting` breaks a cycle. + const auto visit = [&](const auto& self, const Package& current) -> void { + visiting.push_back(current.full_name); + for (const auto& text : current.latest().dependencies) { + const auto [name, version] = split_dependency(text); + if (seen(name)) continue; + const auto installed = have.find(folder_for(name)); + if (installed != have.end() && (version.empty() || compare_versions(installed->second, version) >= 0)) continue; + const auto* needed = find_package(listing, name); + if (!needed) { + if (unknown && std::find(unknown->begin(), unknown->end(), name) == unknown->end()) unknown->push_back(name); + continue; + } + // The newest is installed unless only an older one will do, which no listing says: + // a dependency names the oldest version that works. + self(self, *needed); + } + visiting.pop_back(); + order.push_back(current); + }; + visit(visit, package); + return order; +} + std::string Package::title() const { auto result = name; std::replace(result.begin(), result.end(), '_', ' '); diff --git a/Launcher/thunderstore.h b/Launcher/thunderstore.h index d9fc9c7..481db0c 100644 --- a/Launcher/thunderstore.h +++ b/Launcher/thunderstore.h @@ -2,13 +2,15 @@ #include #include +#include #include #include #include // Thunderstore (thunderstore.io/c/reskate): the package listing the launcher's // Mods browser shows. No network here; gui_mods_browse.cpp fetches with -// launcher_update::http_get. ReSkate packages have no dependencies. +// launcher_update::http_get. A package may list others it needs (a mod pack is +// one that is nothing else); with_dependencies gathers them. // // A ReSkate package is a mod folder zipped with Thunderstore's manifest.json, // icon.png and README.md at the top, and installs as Mods/. @@ -37,6 +39,7 @@ struct Version { std::string date_created; std::uint64_t downloads{}; std::uint64_t file_size{}; + std::vector dependencies; // Namespace-Name-1.2.3 each }; struct Package { @@ -55,8 +58,24 @@ struct Package { // The name as the site shows it: underscores as spaces. std::string title() const; bool in_category(std::string_view category) const; + // This package with `number` as its latest version, for installing that one; unchanged + // when it has no such version. + Package at_version(std::string_view number) const; }; +// "Team-Mod-1.2.3" as {"Team-Mod", "1.2.3"}: how a dependency is named. The version is +// empty when the text does not end in one. +std::pair split_dependency(std::string_view text); +// The package `full_name` (Namespace-Name) in `listing`, or null. +const Package* find_package(std::span listing, std::string_view full_name); +// `package` and everything it needs from `listing`, each once and what is needed first, +// so installing in order never leaves a mod without what it depends on. A dependency that +// `have` (installed folders and their versions) holds at the version asked for or newer is +// left out; any other is taken at its latest. Names the listing lacks go to `unknown`. +std::vector with_dependencies(const Package& package, std::span listing, + const std::map>& have, + std::vector* unknown = nullptr); + // A package's README, as its page on the site shows it: // /api/experimental/package////readme/, a JSON object with "markdown". std::wstring readme_url(const Package& package); diff --git a/Launcher/updater.cpp b/Launcher/updater.cpp index 8e69f76..f917203 100644 --- a/Launcher/updater.cpp +++ b/Launcher/updater.cpp @@ -250,6 +250,52 @@ void http_stream(const std::wstring& url, std::uint64_t limit, int timeout_ms, http_get(url, limit, timeout_ms, sink); } +std::string http_post(const std::wstring& url, const wchar_t* content_type, std::string_view body, + std::uint64_t limit, int timeout_ms) { + const auto win32 = [](std::string_view action) { + fail(std::format("{} failed (Windows error {})", action, GetLastError())); + }; + URL_COMPONENTS parts{}; parts.dwStructSize = sizeof(parts); + parts.dwHostNameLength = parts.dwUrlPathLength = parts.dwExtraInfoLength = + parts.dwUserNameLength = parts.dwPasswordLength = static_cast(-1); + if (url.size() >= 4096 || !WinHttpCrackUrl(url.c_str(), static_cast(url.size()), 0, &parts) || + parts.nScheme != INTERNET_SCHEME_HTTPS || !parts.dwHostNameLength || parts.dwUserNameLength || parts.dwPasswordLength) + fail("Upload URL must be HTTPS: " + utf8(url)); + if (body.size() > 16 * 1024 * 1024) fail("Upload is larger than expected"); + const std::wstring host(parts.lpszHostName, parts.dwHostNameLength); + std::wstring resource = parts.dwUrlPathLength ? std::wstring(parts.lpszUrlPath, parts.dwUrlPathLength) : L"/"; + if (parts.dwExtraInfoLength) resource.append(parts.lpszExtraInfo, parts.dwExtraInfoLength); + Internet session{WinHttpOpen(L"ReSkate-Launcher/1", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, nullptr, nullptr, 0)}; + if (!session.value) win32("WinHttpOpen"); + if (!WinHttpSetTimeouts(session.value, timeout_ms, timeout_ms, timeout_ms, timeout_ms)) win32("WinHttpSetTimeouts"); + Internet connection{WinHttpConnect(session.value, host.c_str(), parts.nPort, 0)}; + if (!connection.value) win32("WinHttpConnect"); + Internet request{WinHttpOpenRequest(connection.value, L"POST", resource.c_str(), nullptr, nullptr, + WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)}; + if (!request.value) win32("WinHttpOpenRequest"); + DWORD redirects = WINHTTP_DISABLE_REDIRECTS; + if (!WinHttpSetOption(request.value, WINHTTP_OPTION_DISABLE_FEATURE, &redirects, sizeof(redirects))) win32("WinHttpSetOption"); + const auto headers = std::wstring(L"Content-Type: ") + content_type + L"\r\n"; + if (!WinHttpSendRequest(request.value, headers.c_str(), static_cast(-1L), const_cast(body.data()), + static_cast(body.size()), static_cast(body.size()), 0) || + !WinHttpReceiveResponse(request.value, nullptr)) win32("HTTPS upload"); + DWORD status{}, size = sizeof(status); + if (!WinHttpQueryHeaders(request.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + nullptr, &status, &size, nullptr)) win32("WinHttpQueryHeaders"); + if (status == 429) fail("Thunderstore is limiting requests from this network; try again in a little while"); + if (status != 200 && status != 201) fail(std::format("{} returned HTTP {}", utf8(url), status)); + std::string answer; + std::array buffer{}; + for (;;) { + DWORD read{}; + if (!WinHttpReadData(request.value, buffer.data(), static_cast(buffer.size()), &read)) win32("WinHttpReadData"); + if (!read) break; + if (answer.size() + read > limit) fail("The answer is larger than expected: " + utf8(url)); + answer.append(buffer.data(), read); + } + return answer; +} + bool binary_updates_enabled() noexcept { return launcher_binary_updates; } Config parse_config(std::string_view text) { diff --git a/Launcher/updater.h b/Launcher/updater.h index 56fd43b..f54257a 100644 --- a/Launcher/updater.h +++ b/Launcher/updater.h @@ -63,6 +63,10 @@ std::string releases_page(); // `limit` bytes, on a non-200 answer, or whatever `sink` throws (to cancel). void http_stream(const std::wstring& url, std::uint64_t limit, int timeout_ms, const std::function& sink); +// An HTTPS POST of `body`; the answer's body, which must come with a 200 or 201 and +// fit in `limit` bytes. No redirect is followed. Throws a readable message otherwise. +std::string http_post(const std::wstring& url, const wchar_t* content_type, std::string_view body, + std::uint64_t limit, int timeout_ms); // Reports bytes received and the expected total while a download runs. using Progress = std::function; diff --git a/README.md b/README.md index 0ccc1e7..83041f6 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ the launcher, the runtime that loads into the game, and the dedicated server. - Drop a mod in `Mods/` and it is merged into the game at launch. Mods can add custom maps, loading screens, cosmetics and scripts. - Browse and install mods from the [Thunderstore community](https://thunderstore.io/c/reskate/) in the - launcher. + launcher. A mod that needs others (a mod pack) brings them with it. + - Save sets of mods as profiles, switch between them, and share one as a code or a file. - Most mod changes apply in game without a restart. - **Launcher.** - Checks that you have the supported game build, and can download exactly that build with your Steam @@ -114,6 +115,10 @@ accepts `park random` from its console or an admin. - Install mods from the launcher's **MODS** page: browse Thunderstore, or drag a `.zip` or folder onto the window. +- **PROFILES** on that page saves which mods are on, and in what order, under a name. Loading a profile + switches to it and downloads any of its mods you do not have; every mod stays installed once. **COPY CODE** + and **SAVE FILE** share a profile, and **IMPORT CODE** and **IMPORT FILE** take one in. The codes and + `.r2z` files are r2modman's, so the two managers read each other's. - In game, the **MODS** tab of the ReSkate menu (**Insert**) turns mods on and off and applies the changes. - Mods are checked against the game build they were made for. Outdated mods, or mods that can't be merged cleanly, are left out with a message naming them, and the rest still load. diff --git a/Server/README-roles.txt b/Server/README-roles.txt index a58d1b1..a6377b2 100644 --- a/Server/README-roles.txt +++ b/Server/README-roles.txt @@ -115,7 +115,9 @@ Any role with at least one command can also use help and roles (the list). Players players Connected players and their SteamID64s. - kick kick : off the server until it restarts. + kick kick : off the server until it restarts, or for + as long as kick-time says. + kick-time kick-time |restart: how long a kick lasts. ban ban [name]: for good. unban unban . bans The ban list. (A role with ban, unban or bans also sees diff --git a/Server/README.txt b/Server/README.txt index 548d7d8..1dfdc0f 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -207,6 +207,12 @@ afk_kick_minutes Remove a player who has been away this many minutes, 1-1440 in chat or changing their objects. They are warned in chat a minute before and can join again at once. Admins are never removed for it. Console: afk-kick |off. +kick_minutes How long a kicked player is kept out, by an admin's kick or + by a vote: 1-1440 minutes (default 0: until the server + restarts). They are told how long when they are kicked and + when they try to join. A kick keeps the time it was given; + changing this is for later kicks. A ban is still for good. + Console: kick-time |restart. word_warnings The ReSkate team keeps a list of words that are not allowed in chat at all (read from api.reskate.dev with the global bans). A message with one is never passed on, whatever @@ -549,7 +555,10 @@ or Travel, and voice, distances, placement and kicks from the Multiplayer menu. msg-party Message everyone in that player's party ("[DM from to party]"). msg-admins Message every admin who is online ("[DM from to admins]"). Players can whisper each other with /w in chat. - kick Until the server restarts. Name start or SteamID64. + kick Until the server restarts, or for kick-time's minutes. + Name start or SteamID64. + kick-time |restart How long a kicked player (by an admin or a vote) is kept + out: 1-1440 minutes, or until the server restarts. Nobody kicks or bans a player of their rank or above; the console can. ban [name] For good. unban bans map e.g. map San Vansterdam, map grom, map bbcity diff --git a/Server/ROLES.md b/Server/ROLES.md index a58be82..d952cde 100644 --- a/Server/ROLES.md +++ b/Server/ROLES.md @@ -88,7 +88,7 @@ A player whose role has a command types it in chat with `/` in front (`/kick Nam | Messages | `msg`, `msg-party`, `msg-admins`, `announce`, `announce-to`, `announcements` | | Map and world | `map`, `maps`, `map-pool`, `rotation`, `park`, `park-mod`, `layer-sync`, `layer`, `layers`, `tod` | | Server settings | `name`, `password`, `welcome`, `listed`, `chat-color`, `status`, `net`, `activity-log` | -| What players may do | `voice`, `voice-range`, `placement`, `objects`, `object-scaling`, `clear-objects`, `effects`, `noclip`, `nobail`, `boosts`, `tuning`, `parties`, `party-size`, `afk-kick`, `announce-throwdowns` | +| What players may do | `voice`, `voice-range`, `placement`, `objects`, `object-scaling`, `clear-objects`, `effects`, `noclip`, `nobail`, `boosts`, `tuning`, `parties`, `party-size`, `afk-kick`, `kick-time`, `announce-throwdowns` | | Votes and polls | `votes`, `vote`, `vote-cancel`, `poll` | | Network | `distances`, `crowd`, `rate`, `tps` | | Anti-cheat | `speed-check`, `board-check`, `score-check`, `score-allow`, `bone-scale`, `bone-reach` | diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 35a6ccf..62bb6e2 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -217,6 +217,15 @@ int run() { check(load_config(away.file).afk_kick == 15, "The away timer was not kept"); away.afk_kick = 1441; check(config_error(away).find("afk_kick_minutes") != std::string::npos, "An away timer over a day was accepted"); + // How long a kick lasts: until the restart unless set, and no more than a day. + ServerConfig kicks; + check(kicks.kick_minutes == 0 && config_error(kicks).find("kick_minutes") == std::string::npos, "A kick does not last until the restart by default"); + kicks.file = folder / "kicks.json"; + kicks.kick_minutes = 10; + save_config(kicks); + check(load_config(kicks.file).kick_minutes == 10, "The kick time was not kept"); + kicks.kick_minutes = 1441; + check(config_error(kicks).find("kick_minutes") != std::string::npos, "A kick time over a day was accepted"); ServerConfig words; check(words.word_warnings == 3 && config.word_warnings == 3, "word_warnings is not 3 unless set"); words.word_warnings = 11; diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index f671e73..e047b61 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -16,7 +16,7 @@ namespace dingosdk::server { namespace { constexpr std::string_view help_text = - "status | net [player] | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" + "status | net [player] | players | say | msg | msg-party | msg-admins | kick | kick-time |restart | ban [name] | unban | bans\n" "map | maps [reload] | name | password | welcome | listed on|off\n" "voice on|off | voice-range <50-1000> | distances | crowd |off | rate | bone-scale <1-8>|off | bone-reach <0.5-20>|off\n" "placement everyone|admins|nobody | objects |off | object-scaling on|off | effects on|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" @@ -132,9 +132,20 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { if (!guest || !guest->handshaken) return "No single connected player matches \"" + std::string(argument) + "\"."; if (!outranks(guest->member.id)) return "You cannot kick a player of your rank or above."; const auto label = guest_name(*guest); - kicked_.insert(guest->member.id); - drop(guest->member.id, "You were kicked from this server."); - return changed(label + " was kicked until the server restarts."); + const auto how_long = keep_out(guest->member.id); + drop(guest->member.id, "You were kicked from this server" + how_long + "."); + return changed(label + " was kicked" + how_long + "."); + } + if (name == "kick-time") { + const auto word = lower(argument); + const auto value = word == "restart" || word == "off" ? std::optional(0) : number(argument); + if (!value || *value > 1440) + return "kick-time |restart (now " + + (config_.kick_minutes ? std::to_string(config_.kick_minutes) + " min" : std::string("until the server restarts")) + ")"; + config_.kick_minutes = static_cast(*value); + return changed(config_.kick_minutes + ? "Kicked players can join again after " + std::to_string(config_.kick_minutes) + " min. Players already kicked keep the time they were given." + : std::string("Kicked players stay out until the server restarts. Players already kicked keep the time they were given.")); } if (name == "ban") { auto [who, reason] = split(argument); @@ -950,6 +961,11 @@ void Host::check_scoring(Guest &guest) { // time. Flagged players are taken out of linked throwdowns and coop challenges (the roster's // speeding flag: every client drops them from those), or kicked; the flag clears after a minute // of normal speed. +std::string Host::keep_out(std::uint64_t id) { + const auto minutes = config_.kick_minutes; + kicked_[id] = minutes ? now_ + minutes * 60000000ULL : 0; + return minutes ? " for " + std::to_string(minutes) + (minutes == 1 ? " minute" : " minutes") : std::string(" until the server restarts"); +} bool Host::check_speed(Guest &guest, std::uint64_t sent) { if (config_.speed_check == "off" || !guest.handshaken || !guest.world_ready || !sent) return true; if (!guest.speed.sample(sent, now_)) return true; diff --git a/Server/server_config.cpp b/Server/server_config.cpp index 1564696..3905e4c 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -121,6 +121,7 @@ Layout layout(const ServerConfig &c) { players.set("allow_parties", c.parties); players.set("party_size", c.party_size); players.set("afk_kick_minutes", c.afk_kick); + players.set("kick_minutes", c.kick_minutes); players.set("word_warnings", c.word_warnings); players.set("use_global_word_filter", c.global_words); players.set("allow_voice_chat", c.voice_chat); @@ -365,6 +366,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector. for (const std::string_view taken : {"help", "party", "p", "w", "whisper", "tell", "poll", "vote", "yes", "y", "no", "n", "tp", - "activity-log", "admin", "admins", "afk-kick", "role", "roles", "announce", "announce-throwdowns", "announce-to", "announcements", + "activity-log", "admin", "admins", "afk-kick", "kick-time", "role", "roles", "announce", "announce-throwdowns", "announce-to", "announcements", "ban", "bans", "board-check", "bone-scale", "boosts", "boosts-allow", "chat-color", "chat-colour", "clear-objects", "crowd", "distances", "effects", "kick", "layer", "layer-sync", "layers", "listed", "map", "map-pool", "maps", "msg", "msg-admins", "msg-party", "name", "net", "nobail", "nobail-allow", "noclip", "noclip-allow", "object-limit", @@ -826,6 +828,7 @@ std::string config_error(const ServerConfig &c) { using namespace multiplayer; if (!parse_colour(c.chat_color)) return "chat_color must be a colour like #8E5CFF."; if (c.afk_kick > 1440) return "afk_kick_minutes must be 0 (never) to 1440."; + if (c.kick_minutes > 1440) return "kick_minutes must be 0 (until the server restarts) to 1440."; if (c.word_warnings > 10) return "word_warnings must be 0 (words are not checked) to 10."; if (!parse_colour(c.chat_text_color)) return "chat_text_color must be a colour like #D9C8FF."; if (!valid_server_name(c.name)) return std::string("name must be ") + server_name_rule + "."; diff --git a/Server/server_config.h b/Server/server_config.h index f572602..3d3c2b4 100644 --- a/Server/server_config.h +++ b/Server/server_config.h @@ -158,6 +158,9 @@ struct ServerConfig { // Minutes a player may be away (not moving, talking, typing or building) before the server // removes them, 1 to 1440; 0: never. Admins are never removed for it. unsigned afk_kick = 0; + // Minutes a kicked player (by an admin or by a vote) is kept out, 1 to 1440; 0: until the + // server restarts. A kick keeps the time it was given: changing this is for later kicks. + unsigned kick_minutes = 0; // A chat message with a word the ReSkate team does not allow at all (word_lists.h) is never // passed on. Its player is warned; after this many warnings, 1 to 10, the next gets them // kicked. 0: nobody is warned or kicked, and the message is still not passed on. diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 6290d3b..76b974f 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -1742,9 +1742,16 @@ void Host::tick(std::uint64_t now) { drop(id, "Disconnected.", how); } for (const auto &link : links) { - if (kicked_.contains(link.id)) { - transport_.disconnect(link.id, "You were kicked from this server."); - continue; + if (const auto kicked = kicked_.find(link.id); kicked != kicked_.end()) { + if (!kicked->second || now_ < kicked->second) { + const auto reason = kicked->second + ? "You were kicked from this server. You can join again in " + + std::to_string((kicked->second - now_) / 60000000 + 1) + " min." + : std::string("You were kicked from this server."); + transport_.disconnect(link.id, reason.c_str()); + continue; + } + kicked_.erase(kicked); // their time is up } if (is_banned(link.id)) { transport_.disconnect(link.id, "You are banned from this server."); diff --git a/Server/server_host.h b/Server/server_host.h index 09af046..cf6243f 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -287,7 +287,11 @@ class Host { PartyBook parties_; std::uint64_t party_revision_{}; std::map> guests_; - std::set kicked_; + // Kicked players and when each may join again (0: not before the server restarts). + std::map kicked_; + // Keeps a player out for as long as kick_minutes says, and says how long for their notice + // and the log: " for 10 minutes", or " until the server restarts". + std::string keep_out(std::uint64_t id); // The name each player last joined under, for banning one who has left by their SteamID. std::map seen_names_; // Warnings each player has had for words that are not allowed at all ("word_warnings"), diff --git a/Server/server_votes.cpp b/Server/server_votes.cpp index f60ef93..a569acd 100644 --- a/Server/server_votes.cpp +++ b/Server/server_votes.cpp @@ -542,8 +542,8 @@ void Host::check_vote(bool expired) { } case VoteKind::kick: if (find(done.target)) { - kicked_.insert(done.target); - drop(done.target, "You were kicked from this server by a vote."); + const auto how_long = keep_out(done.target); + drop(done.target, "You were kicked from this server by a vote" + how_long + "."); } break; case VoteKind::poll: break; diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 84b447b..8467408 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -1,9 +1,9 @@ if(WIN32) add_executable(dingosdk_launcher WIN32 Launcher/main.cpp Launcher/launch.cpp Launcher/gui.cpp Launcher/gui_launcher.cpp Launcher/gui_renderer.cpp Launcher/gui_home.cpp - Launcher/gui_settings.cpp Launcher/gui_sign_in.cpp Launcher/gui_mods.cpp Launcher/gui_mods_browse.cpp + Launcher/gui_settings.cpp Launcher/gui_sign_in.cpp Launcher/gui_mods.cpp Launcher/gui_mods_browse.cpp Launcher/gui_mods_profiles.cpp Launcher/gui_gamepad.cpp Launcher/gamepad_input.cpp Launcher/gui_changelog.cpp Launcher/game_settings.cpp - Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/thunderstore.cpp Launcher/problem.h) + Launcher/updater.cpp Launcher/mod_manager.cpp Launcher/mod_profiles.cpp Launcher/thunderstore.cpp Launcher/problem.h) target_link_libraries(dingosdk_launcher PRIVATE dingosdk_logging dingosdk_content_cache_install dingosdk_world_layer_scan dingosdk_launcher_support dingosdk_initfs dingosdk_mod_list dingosdk_mods dingosdk_json dingosdk_miniz dingosdk_imgui dingosdk_playstation_input winhttp shell32 dwmapi windowscodecs ole32) set_target_properties(dingosdk_launcher PROPERTIES OUTPUT_NAME "ReSkateLauncher") @@ -29,6 +29,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_link_libraries(dingosdk_thunderstore_tests PRIVATE dingosdk_json dingosdk_miniz) target_include_directories(dingosdk_thunderstore_tests PRIVATE "${PROJECT_SOURCE_DIR}") add_test(NAME launcher_thunderstore COMMAND dingosdk_thunderstore_tests) + add_executable(dingosdk_mod_profiles_tests Launcher/Test/mod_profiles_tests.cpp Launcher/mod_profiles.cpp) + target_link_libraries(dingosdk_mod_profiles_tests PRIVATE dingosdk_mod_list dingosdk_json dingosdk_miniz) + target_include_directories(dingosdk_mod_profiles_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME launcher_mod_profiles COMMAND dingosdk_mod_profiles_tests "${CMAKE_CURRENT_BINARY_DIR}/mod-profiles-fixture") add_executable(dingosdk_thunderstore_package_tests Engine/Vfs/Test/thunderstore_package_tests.cpp Engine/Vfs/thunderstore_package.cpp) target_link_libraries(dingosdk_thunderstore_package_tests PRIVATE dingosdk_json) target_include_directories(dingosdk_thunderstore_package_tests PRIVATE "${PROJECT_SOURCE_DIR}")