mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 10:57:01 +02:00
Launcher: mod profiles, sharing them by code or file, and Thunderstore dependencies
The Mods page has a third tab, PROFILES. A profile is a saved list of which mods are on, in what order and at what version; every mod stays installed once, in the one Mods folder. Loading a profile rewrites mods.json, switches off what it does not name, and downloads from Thunderstore what it names that is missing. Profiles live in Mods/profiles.json. A profile travels the way r2modman's do, so the two managers read each other's: export.r2x inside a .r2z file, and a code on Thunderstore's profile service. Config files in an r2modman profile are ignored. Installing or updating a Thunderstore package now brings the packages it depends on, needed ones first, leaving out any already installed at a new enough version. A mod pack is a package that is nothing else. A kick, by an admin or by a passed vote, used to keep the player out until the server restarted. "kick_minutes" in the players section of the config (1-1440; 0, the default, is still until the restart) and the console command "kick-time <minutes>|restart" now set how long it lasts. The player is told how long when they are kicked, and how many minutes are left if they try to join early. A kick keeps the time it was given, so changing the setting is for later kicks. Bans are unchanged.
This commit is contained in:
1 parent
242ac11814
commit
2e186b9839
24 files changed
+1488
-38
No files matched your search
@@ -0,0 +1,158 @@
|
||||
// Mod profiles: storage, applying one to the installed mods, and r2modman's export formats.
|
||||
#include "Launcher/mod_profiles.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
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<class F> std::string error_of(F&& run) {
|
||||
try { run(); } catch (const std::exception& failure) { return failure.what(); }
|
||||
return {};
|
||||
}
|
||||
|
||||
mods::ModList installed(std::initializer_list<std::tuple<const char*, const char*, bool>> 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<std::string> order(const std::vector<mods::ModEntry>& entries) {
|
||||
std::vector<std::string> 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: <best> 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<std::string>{"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<std::streamsize>(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("<html>not found</html>"); }).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;
|
||||
}
|
||||
@@ -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<std::string, std::string>{"Zee-Desert_Springs", "1.10.0"} &&
|
||||
thunderstore::split_dependency("Zee-Map") == std::pair<std::string, std::string>{"Zee-Map", ""},
|
||||
"A dependency splits into its package and version");
|
||||
const auto names = [](const std::vector<thunderstore::Package>& packages) {
|
||||
std::string text;
|
||||
for (const auto& package : packages) text += package.full_name + " ";
|
||||
return text;
|
||||
};
|
||||
std::vector<std::string> 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<std::string>{"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");
|
||||
|
||||
@@ -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<char, 64> profile_name{}; // typed for a new profile
|
||||
std::array<char, 96> profile_code{}; // typed to import
|
||||
std::array<char, 64> 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<launcher_profiles::Profile> imported;
|
||||
};
|
||||
std::thread profile_worker;
|
||||
std::atomic<bool> 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)
|
||||
|
||||
|
||||
+31
-19
@@ -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<IFileOpenDialog> 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<IShellItem> 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<IFileOpenDialog> 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<IShellItem> 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);
|
||||
|
||||
@@ -327,6 +327,17 @@ std::vector<const thunderstore::Package*> updates(const Store& store, const thun
|
||||
|
||||
void start_store_install(ModsPanel& panel, std::vector<thunderstore::Package> 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<thunderstore::Package> 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<thunderstore::Package> 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) {
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
#include "gui_internal.h"
|
||||
|
||||
#include "mod_profiles.h"
|
||||
#include "updater.h"
|
||||
|
||||
#include "Engine/Core/Log/logging.h"
|
||||
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
|
||||
// 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<std::string>& 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<std::string> unobtainable(const ModsPanel& panel, const std::vector<profiles::ProfileMod>& missing) {
|
||||
std::vector<std::string> 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<profiles::ProfileMod>& 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<profiles::ProfileMod> 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<ts::Package> 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<std::size_t>(size), '\0');
|
||||
if (!input.read(bytes.data(), static_cast<std::streamsize>(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<class Task> 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<std::streamsize>(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<profiles::ProfileMod> 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<int>(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
|
||||
@@ -0,0 +1,441 @@
|
||||
#include "Launcher/mod_profiles.h"
|
||||
|
||||
#include "Engine/Core/Json/json.h"
|
||||
|
||||
#include <miniz.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <format>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
|
||||
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<char>(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<char>((value >> (8 * i)) & 0xff);
|
||||
};
|
||||
const auto crc = static_cast<std::uint32_t>(
|
||||
mz_crc32(MZ_CRC32_INIT, reinterpret_cast<const unsigned char*>(data.data()), data.size()));
|
||||
const auto size = static_cast<std::uint32_t>(data.size()), length = static_cast<std::uint32_t>(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<std::uint32_t>(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<std::uint32_t>(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<unsigned char>(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<unsigned>(digit);
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out += static_cast<char>((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<unsigned char>(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<unsigned char>(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<unsigned char>(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<char>(input), std::istreambuf_iterator<char>()};
|
||||
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<std::string, const mods::ModEntry*> installed;
|
||||
for (const auto& entry : list.entries) installed.emplace(folded(entry.mod.name), &entry);
|
||||
std::set<std::string> 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<mods::ModEntry>& entries) {
|
||||
std::vector<std::string> 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<int, 3> 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<int, 3> 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<mz_uint>(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<std::size_t>(entry.m_uncomp_size), '\0');
|
||||
if (!mz_zip_reader_extract_to_mem(&zip.value, static_cast<mz_uint>(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
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include "Engine/Vfs/mod_list.h"
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// 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<ProfileMod> 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<Profile> 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 <name>.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);
|
||||
|
||||
// <Mods>/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<mods::ModEntry> entries;
|
||||
// The mods it switches on that are not installed, to be downloaded.
|
||||
std::vector<ProfileMod> 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":"<code>"}; 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
|
||||
@@ -68,6 +68,7 @@ std::optional<Version> 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<std::string, std::string> 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<const Package> listing, std::string_view full_name) {
|
||||
for (const auto& package : listing)
|
||||
if (package.full_name == full_name) return &package;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<Package> with_dependencies(const Package& package, std::span<const Package> listing,
|
||||
const std::map<std::string, std::string, std::less<>>& have,
|
||||
std::vector<std::string>* unknown) {
|
||||
std::vector<Package> order;
|
||||
std::vector<std::string> 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(), '_', ' ');
|
||||
|
||||
+20
-1
@@ -2,13 +2,15 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
// 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/<Namespace-Name>.
|
||||
@@ -37,6 +39,7 @@ struct Version {
|
||||
std::string date_created;
|
||||
std::uint64_t downloads{};
|
||||
std::uint64_t file_size{};
|
||||
std::vector<std::string> 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<std::string, std::string> split_dependency(std::string_view text);
|
||||
// The package `full_name` (Namespace-Name) in `listing`, or null.
|
||||
const Package* find_package(std::span<const Package> 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<Package> with_dependencies(const Package& package, std::span<const Package> listing,
|
||||
const std::map<std::string, std::string, std::less<>>& have,
|
||||
std::vector<std::string>* unknown = nullptr);
|
||||
|
||||
// A package's README, as its page on the site shows it:
|
||||
// /api/experimental/package/<owner>/<name>/<version>/readme/, a JSON object with "markdown".
|
||||
std::wstring readme_url(const Package& package);
|
||||
|
||||
@@ -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<DWORD>(-1);
|
||||
if (url.size() >= 4096 || !WinHttpCrackUrl(url.c_str(), static_cast<DWORD>(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<DWORD>(-1L), const_cast<char*>(body.data()),
|
||||
static_cast<DWORD>(body.size()), static_cast<DWORD>(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<char, 16384> buffer{};
|
||||
for (;;) {
|
||||
DWORD read{};
|
||||
if (!WinHttpReadData(request.value, buffer.data(), static_cast<DWORD>(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) {
|
||||
|
||||
@@ -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<void(const char*, DWORD)>& 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<void(std::uint64_t received, std::uint64_t total)>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <player>: off the server until it restarts.
|
||||
kick kick <player>: off the server until it restarts, or for
|
||||
as long as kick-time says.
|
||||
kick-time kick-time <minutes>|restart: how long a kick lasts.
|
||||
ban ban <player or SteamID64> [name]: for good.
|
||||
unban unban <SteamID64>.
|
||||
bans The ban list. (A role with ban, unban or bans also sees
|
||||
|
||||
+10
-1
@@ -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 <minutes>|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 <minutes>|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 <player> <text> Message everyone in that player's party ("[DM from <you> to party]").
|
||||
msg-admins <text> Message every admin who is online ("[DM from <you> to admins]").
|
||||
Players can whisper each other with /w <player> <text> in chat.
|
||||
kick <player> Until the server restarts. Name start or SteamID64.
|
||||
kick <player> Until the server restarts, or for kick-time's minutes.
|
||||
Name start or SteamID64.
|
||||
kick-time <minutes>|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 <player or id> [name] For good. unban <id> bans
|
||||
map <name> e.g. map San Vansterdam, map grom, map bbcity
|
||||
|
||||
+1
-1
@@ -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` |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
namespace dingosdk::server {
|
||||
namespace {
|
||||
constexpr std::string_view help_text =
|
||||
"status | net [player] | players | say <text> | msg <player> <text> | msg-party <player> <text> | msg-admins <text> | kick <player> | ban <player or SteamID64> [name] | unban <SteamID64> | bans\n"
|
||||
"status | net [player] | players | say <text> | msg <player> <text> | msg-party <player> <text> | msg-admins <text> | kick <player> | kick-time <minutes>|restart | ban <player or SteamID64> [name] | unban <SteamID64> | bans\n"
|
||||
"map <name, e.g. San Vansterdam> | maps [reload] | name <text> | password <text|off> | welcome <text|off> | listed on|off\n"
|
||||
"voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low> | crowd <n>|off | rate <KB/s> | bone-scale <1-8>|off | bone-reach <0.5-20>|off\n"
|
||||
"placement everyone|admins|nobody | objects <number>|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<std::uint64_t>(0) : number(argument);
|
||||
if (!value || *value > 1440)
|
||||
return "kick-time <minutes 1-1440>|restart (now " +
|
||||
(config_.kick_minutes ? std::to_string(config_.kick_minutes) + " min" : std::string("until the server restarts")) + ")";
|
||||
config_.kick_minutes = static_cast<unsigned>(*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;
|
||||
|
||||
@@ -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<std::str
|
||||
c.parties = get("players", "allow_parties", c.parties, {"parties"});
|
||||
c.party_size = std::clamp(get("players", "party_size", c.party_size), 2U, 8U);
|
||||
c.afk_kick = get("players", "afk_kick_minutes", c.afk_kick);
|
||||
c.kick_minutes = get("players", "kick_minutes", c.kick_minutes);
|
||||
c.word_warnings = get("players", "word_warnings", c.word_warnings);
|
||||
c.global_words = get("players", "use_global_word_filter", c.global_words);
|
||||
c.voice_chat = get("players", "allow_voice_chat", c.voice_chat, {"voice_chat"});
|
||||
@@ -756,7 +758,7 @@ bool custom_command_name_free(std::string_view name) noexcept {
|
||||
// The chat's own commands, then the server commands admins type as /<command>.
|
||||
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 + ".";
|
||||
|
||||
@@ -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.
|
||||
|
||||
+10
-3
@@ -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.");
|
||||
|
||||
@@ -287,7 +287,11 @@ class Host {
|
||||
PartyBook parties_;
|
||||
std::uint64_t party_revision_{};
|
||||
std::map<std::uint64_t, std::unique_ptr<Guest>> guests_;
|
||||
std::set<std::uint64_t> kicked_;
|
||||
// Kicked players and when each may join again (0: not before the server restarts).
|
||||
std::map<std::uint64_t, std::uint64_t> 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<std::uint64_t, std::string> seen_names_;
|
||||
// Warnings each player has had for words that are not allowed at all ("word_warnings"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
+6
-2
@@ -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}")
|
||||
|
||||
Reference in new issue
Block a user