mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-27 23:07:08 +01:00
A third ReSkate tab in the game's pause menu (native_menu.cpp slot 2, the Store's tab art), drawn by the overlay as the other two are (mod_manager_page.inl, included into hub_page.cpp): - BROWSE: search, categories in a panel of their own (shown only, hidden or either, kept in the profile), sort, a featured carousel and rows (most popular, recently updated, new, the biggest categories), a grid for results, and a mod's own page with its README, what it brings with it, and INSTALL, switch on or off and uninstall. Works from a controller: the carousel's card is one stop that left and right turn, and B goes back from a mod's page (the page's Back is held meanwhile). - MY MODS: the Mods folder with the states the ReSkate menu shows; UPDATES: Update all. - The foot of the page shows the install's progress and the apply's, and Apply (View on a pad). Leaving the menu with changes not applied asks first (a two-answer card in message_card.cpp, answered by the runtime as the message is); LATER closes the menu. Behind it, Extension/Assets/mod_store: Thunderstore's listing (cached on disk), the installed mods, and a queue that downloads and installs packages with their dependencies, updates and uninstalls them. An update or uninstall of a mod the game holds open waits in Mods/.reskate-pending and the launcher makes it before the next start (launcher_mods::apply_pending). Package icons load into their own SRV slots (store_icons.cpp), sharing the launcher's icon cache. Tests: mod_store_view (search, categories, the front page's rows, install plans), the pending updates and uninstalls in mod_manager_tests, and the third tab in native_menu_view_tests.
664 lines
31 KiB
C++
664 lines
31 KiB
C++
#include "mod_store.h"
|
|
#include "map_download.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include "Engine/Vfs/https_download.h"
|
|
#include "Engine/Vfs/mod_catalog.h"
|
|
#include "Engine/Vfs/mod_conflicts.h"
|
|
#include "Engine/Vfs/mod_list.h"
|
|
#include "Engine/Vfs/thunderstore_package.h"
|
|
#include "Launcher/mod_manager.h"
|
|
|
|
#include <Windows.h>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <cctype>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <set>
|
|
#include <thread>
|
|
|
|
namespace dingosdk::mod_store {
|
|
namespace {
|
|
namespace fs = std::filesystem;
|
|
namespace package = thunderstore_package;
|
|
|
|
constexpr wchar_t agent[] = L"ReSkate";
|
|
constexpr std::uint64_t listing_most = 256ull << 20, index_most = 4ull << 20;
|
|
constexpr std::uint64_t stale_seconds = 10 * 60;
|
|
constexpr std::uint64_t largest_download = 8ull << 30;
|
|
constexpr std::uint32_t download_seconds = 4 * 60 * 60;
|
|
|
|
std::uint64_t unix_now() {
|
|
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(
|
|
std::chrono::system_clock::now().time_since_epoch()).count());
|
|
}
|
|
fs::path mods_root() { return mods::engine_data_root() / mods::mods_folder; }
|
|
fs::path local_folder() {
|
|
std::array<wchar_t, 32768> local{};
|
|
const auto size = GetEnvironmentVariableW(L"LOCALAPPDATA", local.data(), static_cast<DWORD>(local.size()));
|
|
if (!size || size >= local.size()) return {};
|
|
return fs::path(local.data()) / L"ReSkate" / L"thunderstore";
|
|
}
|
|
std::wstring wide(std::string_view ascii) { return std::wstring(ascii.begin(), ascii.end()); }
|
|
bool same(std::string_view a, std::string_view b) {
|
|
return a.size() == b.size() && _strnicmp(a.data(), b.data(), a.size()) == 0;
|
|
}
|
|
void log(logging::Level level, const std::string& text) { logging::write(level, logging::Channel::assets, "Mod Manager: " + text); }
|
|
|
|
// ---------------------------------------------------------------- the listing
|
|
|
|
std::mutex listing_mutex;
|
|
std::shared_ptr<const Listing> current_listing = std::make_shared<Listing>(); // under the mutex
|
|
std::atomic<bool> fetching{};
|
|
std::atomic<std::uint64_t> next_fetch{};
|
|
|
|
fs::path listing_file() {
|
|
const auto folder = local_folder();
|
|
return folder.empty() ? fs::path() : folder / wide("listing-" + thunderstore::community() + ".json");
|
|
}
|
|
// One listing chunk (a JSON array) without its brackets, for joining chunks into one array.
|
|
std::string_view inner(std::string_view json) {
|
|
while (!json.empty() && std::isspace(static_cast<unsigned char>(json.front()))) json.remove_prefix(1);
|
|
while (!json.empty() && std::isspace(static_cast<unsigned char>(json.back()))) json.remove_suffix(1);
|
|
if (json.size() < 2 || json.front() != '[' || json.back() != ']') return {};
|
|
json = json.substr(1, json.size() - 2);
|
|
while (!json.empty() && std::isspace(static_cast<unsigned char>(json.front()))) json.remove_prefix(1);
|
|
return json;
|
|
}
|
|
void publish(std::vector<ts::Package> packages, std::uint64_t fetched, std::string error) {
|
|
std::lock_guard lock(listing_mutex);
|
|
auto next = std::make_shared<Listing>();
|
|
if (packages.empty() && !current_listing->packages.empty()) {
|
|
// A failed fetch keeps what was there.
|
|
next->packages = current_listing->packages;
|
|
next->fetched = current_listing->fetched;
|
|
} else {
|
|
next->packages = std::move(packages);
|
|
next->fetched = fetched;
|
|
}
|
|
next->error = std::move(error);
|
|
next->fetching = fetching.load();
|
|
next->serial = current_listing->serial + 1;
|
|
current_listing = std::move(next);
|
|
}
|
|
// The whole listing as one JSON array: the index's chunks, else the plain listing. Empty on failure.
|
|
std::string download_listing(std::string& error) {
|
|
const auto community = thunderstore::community();
|
|
https::Download result;
|
|
if (const auto index = https::get_text(thunderstore::listing_index_url(community), index_most, 30, agent, &result)) {
|
|
try {
|
|
const auto urls = thunderstore::parse_index(thunderstore::gunzip(*index, index_most));
|
|
std::string joined = "[";
|
|
bool all = !urls.empty();
|
|
for (const auto& url : urls) {
|
|
const auto chunk = https::get_text(url, listing_most, 120, agent, &result);
|
|
if (!chunk) { all = false; break; }
|
|
const auto text = thunderstore::gunzip(*chunk, listing_most);
|
|
const auto body = inner(text);
|
|
if (body.empty()) continue;
|
|
if (joined.size() > 1) joined += ',';
|
|
joined += body;
|
|
}
|
|
if (all) return joined + "]";
|
|
} catch (const std::exception&) {}
|
|
}
|
|
const auto plain = https::get_text(thunderstore::listing_url(community), listing_most, 180, agent, &result);
|
|
if (!plain) {
|
|
error = result.http_status && result.http_status != 200
|
|
? "Thunderstore answered " + std::to_string(result.http_status) + "."
|
|
: "Thunderstore could not be reached (Windows error " + std::to_string(result.error) + ").";
|
|
return {};
|
|
}
|
|
try { return thunderstore::gunzip(*plain, listing_most); } catch (const std::exception& failure) { error = failure.what(); }
|
|
return {};
|
|
}
|
|
void fetch_listing(bool from_disk) {
|
|
try {
|
|
const auto file = listing_file();
|
|
if (from_disk && !file.empty()) {
|
|
std::error_code error;
|
|
if (fs::is_regular_file(file, error)) {
|
|
std::ifstream in(file, std::ios::binary);
|
|
std::string text((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
auto packages = thunderstore::parse_listing(text);
|
|
const auto written = fs::last_write_time(file, error);
|
|
const auto age = error ? stale_seconds : static_cast<std::uint64_t>(std::max<long long>(0,
|
|
std::chrono::duration_cast<std::chrono::seconds>(fs::file_time_type::clock::now() - written).count()));
|
|
const auto when = unix_now() - std::min(age, unix_now());
|
|
if (!packages.empty()) {
|
|
publish(std::move(packages), when, {});
|
|
if (age < stale_seconds) {
|
|
next_fetch.store(when + stale_seconds);
|
|
fetching.store(false);
|
|
publish({}, 0, {});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
std::string error;
|
|
const auto text = download_listing(error);
|
|
auto packages = text.empty() ? std::vector<ts::Package>() : thunderstore::parse_listing(text);
|
|
if (!text.empty() && packages.empty()) error = "Thunderstore's listing could not be read.";
|
|
if (!packages.empty() && !file.empty()) {
|
|
std::error_code ignored;
|
|
fs::create_directories(file.parent_path(), ignored);
|
|
const auto temporary = fs::path(file).concat(L".tmp");
|
|
{
|
|
std::ofstream out(temporary, std::ios::binary | std::ios::trunc);
|
|
out.write(text.data(), static_cast<std::streamsize>(text.size()));
|
|
}
|
|
MoveFileExW(temporary.c_str(), file.c_str(), MOVEFILE_REPLACE_EXISTING);
|
|
}
|
|
log(packages.empty() ? logging::Level::warning : logging::Level::info,
|
|
packages.empty() ? "the listing could not be fetched: " + error : std::to_string(packages.size()) + " packages listed.");
|
|
const auto now = unix_now();
|
|
// A failure is tried again in a minute.
|
|
next_fetch.store(now + (packages.empty() ? 60 : stale_seconds));
|
|
fetching.store(false);
|
|
publish(std::move(packages), now, std::move(error));
|
|
} catch (const std::exception& failure) {
|
|
next_fetch.store(unix_now() + 60);
|
|
fetching.store(false);
|
|
publish({}, 0, failure.what());
|
|
} catch (...) {
|
|
next_fetch.store(unix_now() + 60);
|
|
fetching.store(false);
|
|
publish({}, 0, "the listing could not be read");
|
|
}
|
|
}
|
|
void start_fetch(bool from_disk) {
|
|
if (fetching.exchange(true)) return;
|
|
publish({}, 0, {});
|
|
try { std::thread([from_disk] { fetch_listing(from_disk); }).detach(); }
|
|
catch (...) { fetching.store(false); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- the installed mods
|
|
|
|
std::mutex mods_mutex;
|
|
std::shared_ptr<const Mods> current_mods; // under the mutex
|
|
std::atomic<bool> scanning{};
|
|
std::atomic<std::uint64_t> next_scan{}; // GetTickCount64
|
|
std::atomic<unsigned> mods_serial{};
|
|
// mods.json is written by one change at a time.
|
|
std::mutex write_mutex;
|
|
// Mods in the game whose new version went in here since the mods were last applied: the game still
|
|
// has the old one merged until they are.
|
|
std::mutex updated_mutex;
|
|
std::set<std::string, std::less<>> updated_since_apply; // under the mutex
|
|
bool updated_since(const std::string& folder) {
|
|
std::lock_guard lock(updated_mutex);
|
|
return std::any_of(updated_since_apply.begin(), updated_since_apply.end(), [&](const std::string& name) { return same(name, folder); });
|
|
}
|
|
|
|
std::atomic<std::uint64_t> applied_at{}; // GetTickCount64 when apply() last started a merge
|
|
Mods read_mods() {
|
|
Mods result;
|
|
result.settled = !live_mods::busy() && GetTickCount64() > applied_at.load() + 100;
|
|
const auto list = mods::scan_mods(mods::engine_data_root());
|
|
result.present = list.present;
|
|
result.issue = list.issue;
|
|
const auto& launch = mods::catalog();
|
|
const auto applied = live_mods::applied_mods();
|
|
const auto waiting = launcher_mods::pending(list.root);
|
|
result.waiting = waiting.updates.size() + waiting.removals.size();
|
|
const auto named = [](const std::vector<std::string>& names, const std::string& name) {
|
|
return std::any_of(names.begin(), names.end(), [&](const std::string& other) { return same(other, name); });
|
|
};
|
|
// ReSkate's own packs live outside the Mods folder's list, always on.
|
|
const auto kept = [&](const std::string& name) {
|
|
const auto owner = mods::variant_owner(name);
|
|
return (owner.empty() ? name : owner).starts_with("reskate-builtin-");
|
|
};
|
|
// A mod with variants is in effect when one of its variants is (mod_catalog.h).
|
|
const auto in_effect = [&](const std::string& name) {
|
|
return std::any_of(applied.begin(), applied.end(), [&](const std::string& other) {
|
|
return same(other, name) || same(mods::variant_owner(other), name);
|
|
});
|
|
};
|
|
const auto excluded = [&](const std::string& name) -> const mods::Mod* {
|
|
for (const auto& mod : launch.excluded)
|
|
if (same(mod.name, name)) return &mod;
|
|
return nullptr;
|
|
};
|
|
const auto aside = mods::set_aside_for_variants();
|
|
const auto title_of = [&](const std::string& name) {
|
|
for (const auto& entry : list.entries)
|
|
if (same(entry.mod.name, name)) return entry.mod.title.empty() ? name : entry.mod.title;
|
|
return name;
|
|
};
|
|
bool reload_for_update{};
|
|
for (const auto& entry : list.entries) {
|
|
const auto& mod = entry.mod;
|
|
Mod row{mod.name, mod.title.empty() ? mod.name : mod.title, mod.author, mod.version, mod.description,
|
|
mods::mod_contents(mod), entry.enabled, in_effect(mod.name)};
|
|
result.versions[mod.name] = mod.version;
|
|
const auto* left_out = excluded(mod.name);
|
|
const bool set_aside = entry.enabled && !row.in_effect && named(aside, mod.name);
|
|
if (named(waiting.removals, mod.name)) {
|
|
row.state = State::removal_waiting;
|
|
row.why = "It is uninstalled the next time the game starts: the game has its files open now.";
|
|
} else if (named(waiting.updates, mod.name)) {
|
|
row.state = State::update_waiting;
|
|
row.why = "Its update goes in the next time the game starts: the game has its files open now.";
|
|
} else if (left_out && !mods::conflict_winner(*left_out).empty()) {
|
|
row.state = State::left_out;
|
|
row.why = "It changes the same parts of the game as " + title_of(mods::conflict_winner(*left_out)) +
|
|
", which is higher in the load order, so only that one loads. Change the order in the launcher.";
|
|
} else if (left_out) {
|
|
row.state = State::not_loaded;
|
|
row.why = !left_out->outdated.empty() ? left_out->outdated
|
|
: "It could not be merged cleanly, so none of it is used. Reinstall it, or ask its author for an update.";
|
|
} else if (set_aside) {
|
|
row.state = State::set_aside;
|
|
row.why = "It changes what another mod's chosen variant replaces, and only one of them can be in the game.";
|
|
} else if (entry.enabled && row.in_effect && updated_since(mod.name) && (mod.provides_layout || mod.provides_levels)) {
|
|
row.state = State::apply_to_load;
|
|
row.why = "Its new version is installed: it goes in the game when the mods are applied.";
|
|
reload_for_update |= !live_mods::only_maps(mod.directory);
|
|
} else if (entry.enabled && row.in_effect) row.state = State::loaded;
|
|
else if (entry.enabled) row.state = State::apply_to_load;
|
|
else if (row.in_effect) row.state = State::apply_to_unload;
|
|
else row.state = State::disabled;
|
|
if (row.state == State::apply_to_load || row.state == State::apply_to_unload) ++result.changes;
|
|
result.mods.push_back(std::move(row));
|
|
}
|
|
// In effect, but its folder has been deleted: applying unloads it.
|
|
for (const auto& name : applied)
|
|
if (mods::variant_owner(name).empty() && !kept(name) &&
|
|
std::none_of(list.entries.begin(), list.entries.end(), [&](const mods::ModEntry& entry) { return same(entry.mod.name, name); })) {
|
|
Mod row{name, name};
|
|
row.in_effect = row.gone = true;
|
|
row.state = State::apply_to_unload;
|
|
row.why = "Its folder is gone, but it stays in the game until the mods are applied.";
|
|
++result.changes;
|
|
result.mods.push_back(std::move(row));
|
|
}
|
|
if (result.changes) result.effect = live_mods::preview(list);
|
|
// (preview only sees mods going in or out; a new version of one in the game is neither.)
|
|
if (reload_for_update && result.effect == live_mods::ApplyEffect::maps_only) result.effect = live_mods::ApplyEffect::reload_level;
|
|
return result;
|
|
}
|
|
void start_scan() {
|
|
if (scanning.exchange(true)) return;
|
|
next_scan.store(GetTickCount64() + 1500);
|
|
try {
|
|
std::thread([] {
|
|
std::shared_ptr<const Mods> fresh;
|
|
try {
|
|
auto read = read_mods();
|
|
read.serial = ++mods_serial;
|
|
fresh = std::make_shared<const Mods>(std::move(read));
|
|
} catch (...) {}
|
|
if (fresh) {
|
|
std::lock_guard lock(mods_mutex);
|
|
current_mods = std::move(fresh);
|
|
}
|
|
scanning.store(false);
|
|
}).detach();
|
|
} catch (...) { scanning.store(false); }
|
|
}
|
|
// After a change made here: read again at once (or as soon as the read under way ends).
|
|
void rescan() { next_scan.store(0); }
|
|
|
|
// ---------------------------------------------------------------- the queue
|
|
|
|
std::mutex progress_mutex;
|
|
Progress current_progress; // under the mutex
|
|
std::atomic<bool> busy{}, stop{};
|
|
https::Watch watch;
|
|
|
|
void set_activity(std::string text, float fraction = -1) {
|
|
std::lock_guard lock(progress_mutex);
|
|
current_progress.activity = std::move(text);
|
|
current_progress.fraction = fraction;
|
|
}
|
|
// The package in hand: what it is, and where the job is with it.
|
|
void set_package(Progress::Step step, const ts::Package* wanted, std::size_t at, std::size_t of, std::vector<std::string> queued = {}) {
|
|
std::lock_guard lock(progress_mutex);
|
|
current_progress.step = step;
|
|
current_progress.at = at;
|
|
current_progress.of = of;
|
|
current_progress.received = current_progress.total = 0;
|
|
if (wanted) {
|
|
current_progress.package = wanted->full_name;
|
|
current_progress.title = wanted->title();
|
|
current_progress.version = wanted->latest().number;
|
|
current_progress.icon = wanted->latest().icon;
|
|
current_progress.queued = std::move(queued);
|
|
}
|
|
}
|
|
void set_bytes(std::uint64_t received, std::uint64_t total) {
|
|
std::lock_guard lock(progress_mutex);
|
|
current_progress.received = received;
|
|
current_progress.total = total;
|
|
}
|
|
void finish(std::string result, bool failed) {
|
|
log(failed ? logging::Level::warning : logging::Level::info, result);
|
|
{
|
|
std::lock_guard lock(progress_mutex);
|
|
current_progress.busy = false;
|
|
current_progress.activity.clear();
|
|
current_progress.fraction = -1;
|
|
current_progress.step = Progress::Step::starting;
|
|
current_progress.package.clear();
|
|
current_progress.queued.clear();
|
|
current_progress.result = std::move(result);
|
|
current_progress.failed = failed;
|
|
++current_progress.serial;
|
|
}
|
|
busy.store(false);
|
|
rescan();
|
|
}
|
|
std::string begin(const char* what) {
|
|
if (map_download::view().stage != map_download::Stage::idle) return "A map for a session is being downloaded. Wait for it to finish.";
|
|
if (live_mods::busy()) return "The mods are being applied. Wait for it to finish.";
|
|
if (busy.exchange(true)) return std::string("Wait for the ") + what + " under way to finish.";
|
|
stop.store(false);
|
|
watch.cancel.store(false);
|
|
std::lock_guard lock(progress_mutex);
|
|
current_progress.busy = true;
|
|
current_progress.result.clear();
|
|
current_progress.failed = false;
|
|
current_progress.activity = "Starting...";
|
|
current_progress.fraction = -1;
|
|
current_progress.step = Progress::Step::starting;
|
|
current_progress.package.clear();
|
|
current_progress.queued.clear();
|
|
return {};
|
|
}
|
|
|
|
// Downloads and installs one package (its newest version). Returns what happened ("" when it went
|
|
// in now, "later" when it waits for the next start) or throws why not.
|
|
std::string install_one(const ts::Package& wanted, std::size_t at, std::size_t of, std::vector<std::string> queued) {
|
|
const auto& version = wanted.latest();
|
|
const auto root = mods_root();
|
|
const auto counter = of > 1 ? " (" + std::to_string(at + 1) + " of " + std::to_string(of) + ")" : std::string();
|
|
const auto archive_folder = root / L".reskate-download";
|
|
std::error_code error;
|
|
fs::create_directories(archive_folder, error);
|
|
const auto archive = archive_folder / (wide(wanted.full_name) + L"-ingame.zip");
|
|
struct Cleanup {
|
|
fs::path archive, folder;
|
|
~Cleanup() { std::error_code ignored; fs::remove(archive, ignored); fs::remove(folder, ignored); }
|
|
} cleanup{archive, archive_folder};
|
|
fs::remove(archive, error);
|
|
// Always from thunderstore.io, whatever the listing names (as the map download does).
|
|
const package::Name name{wanted.owner, wanted.name, version.number};
|
|
set_package(Progress::Step::downloading, &wanted, at, of, std::move(queued));
|
|
set_activity("Downloading " + wanted.title() + " v" + version.number + counter, 0);
|
|
watch.received.store(0);
|
|
watch.total.store(version.file_size);
|
|
https::Download result;
|
|
{
|
|
// How far it is, said a few times a second while the download runs.
|
|
struct Meter {
|
|
std::atomic<bool> done{};
|
|
std::thread thread;
|
|
~Meter() { done.store(true); if (thread.joinable()) thread.join(); }
|
|
} meter;
|
|
meter.thread = std::thread([&] {
|
|
while (!meter.done.load()) {
|
|
const auto total = watch.total.load(), received = watch.received.load();
|
|
set_bytes(received, total);
|
|
if (total) set_activity("Downloading " + wanted.title() + " v" + version.number + counter + " " + size_text(received) + " of " + size_text(total),
|
|
static_cast<float>(static_cast<double>(received) / static_cast<double>(total)));
|
|
Sleep(100);
|
|
}
|
|
});
|
|
result = https::get(package::download_url(name, version.number), archive, version.file_size ? version.file_size + (1 << 20) : largest_download,
|
|
download_seconds, agent, &watch);
|
|
}
|
|
if (stop.load()) throw std::runtime_error("Cancelled.");
|
|
if (!result.ok)
|
|
throw std::runtime_error(result.http_status && result.http_status != 200
|
|
? "the download failed (Thunderstore answered " + std::to_string(result.http_status) + ")"
|
|
: "the download failed (Windows error " + std::to_string(result.error) + ")");
|
|
set_package(Progress::Step::installing, nullptr, at, of);
|
|
set_activity("Installing " + wanted.title() + " v" + version.number + counter, 0);
|
|
launcher_mods::InstallOptions options;
|
|
options.folder = ts::folder_for(wanted.full_name);
|
|
options.author = wanted.owner;
|
|
options.require_content = true;
|
|
const auto meter_install = [&](float fraction) { set_activity("Installing " + wanted.title() + " v" + version.number + counter, fraction); };
|
|
const bool update = fs::exists(root / wide(options.folder), error);
|
|
try {
|
|
launcher_mods::install(root, archive, true, meter_install, stop, options);
|
|
if (update) {
|
|
std::lock_guard lock(updated_mutex);
|
|
updated_since_apply.insert(options.folder);
|
|
}
|
|
return {};
|
|
} catch (const std::exception& failure) {
|
|
if (!update || stop.load()) throw;
|
|
// The game holds the files of the copy it replaces: it goes in at the next start.
|
|
log(logging::Level::info, wanted.full_name + " is in use (" + failure.what() + "); its update waits for the next start.");
|
|
}
|
|
launcher_mods::install_later(root, archive, meter_install, stop, options);
|
|
return "later";
|
|
}
|
|
|
|
// ---------------------------------------------------------------- READMEs
|
|
|
|
std::mutex readme_mutex;
|
|
std::map<std::string, std::shared_ptr<const Readme>, std::less<>> readmes; // under the mutex; null while fetching
|
|
} // namespace
|
|
|
|
const char* state_label(State state) {
|
|
switch (state) {
|
|
case State::loaded: return "LOADED";
|
|
case State::disabled: return "DISABLED";
|
|
case State::apply_to_load: return "APPLY TO LOAD";
|
|
case State::apply_to_unload: return "APPLY TO UNLOAD";
|
|
case State::left_out: return "LEFT OUT";
|
|
case State::not_loaded: return "NOT LOADED";
|
|
case State::set_aside: return "SET ASIDE";
|
|
case State::update_waiting: return "RESTART TO UPDATE";
|
|
case State::removal_waiting: return "RESTART TO REMOVE";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
std::shared_ptr<const Listing> listing() {
|
|
static std::atomic<bool> started{};
|
|
if (!started.exchange(true)) start_fetch(true);
|
|
else if (!fetching.load() && unix_now() >= next_fetch.load()) start_fetch(false);
|
|
std::lock_guard lock(listing_mutex);
|
|
return current_listing;
|
|
}
|
|
void refresh() {
|
|
if (!fetching.load()) start_fetch(false);
|
|
}
|
|
|
|
std::shared_ptr<const Mods> installed() {
|
|
if (!scanning.load() && GetTickCount64() >= next_scan.load()) start_scan();
|
|
std::lock_guard lock(mods_mutex);
|
|
return current_mods;
|
|
}
|
|
|
|
void set_enabled(const std::string& folder, bool on) {
|
|
try {
|
|
std::thread([folder, on] {
|
|
try {
|
|
std::lock_guard lock(write_mutex);
|
|
auto list = mods::scan_mods(mods::engine_data_root());
|
|
if (!list.issue.empty()) throw std::runtime_error("mods.json could not be read: " + list.issue);
|
|
for (auto& entry : list.entries)
|
|
if (same(entry.mod.name, folder)) entry.enabled = on;
|
|
mods::save_mod_order(list.root, list.entries);
|
|
log(logging::Level::info, folder + (on ? " switched on." : " switched off."));
|
|
} catch (const std::exception& failure) {
|
|
std::lock_guard lock(progress_mutex);
|
|
current_progress.result = std::string("Could not save mods.json: ") + failure.what();
|
|
current_progress.failed = true;
|
|
++current_progress.serial;
|
|
}
|
|
rescan();
|
|
}).detach();
|
|
} catch (...) {}
|
|
}
|
|
|
|
std::string apply() {
|
|
if (busy.load()) return "Wait for the install under way to finish.";
|
|
std::shared_ptr<const Mods> now;
|
|
{
|
|
std::lock_guard lock(mods_mutex);
|
|
now = current_mods;
|
|
}
|
|
const bool reload = now && now->effect == live_mods::ApplyEffect::reload_level;
|
|
const auto said = live_mods::apply(reload);
|
|
applied_at.store(GetTickCount64());
|
|
if (live_mods::busy()) {
|
|
std::lock_guard lock(updated_mutex);
|
|
updated_since_apply.clear();
|
|
}
|
|
rescan();
|
|
return said;
|
|
}
|
|
|
|
Progress progress() {
|
|
std::lock_guard lock(progress_mutex);
|
|
return current_progress;
|
|
}
|
|
|
|
std::string install(const std::vector<std::string>& wanted) {
|
|
const auto shown = listing();
|
|
std::vector<ts::Package> packages;
|
|
for (const auto& name : wanted)
|
|
if (const auto* found = ts::find_package(shown->packages, name)) packages.push_back(*found);
|
|
if (packages.empty()) return "That mod is not in Thunderstore's listing.";
|
|
std::shared_ptr<const Mods> mods_now;
|
|
{
|
|
std::lock_guard lock(mods_mutex);
|
|
mods_now = current_mods;
|
|
}
|
|
// The ones asked for are taken at their newest, installed or not; what they need only when it
|
|
// is not here at the version they ask for.
|
|
ts::Installed have = mods_now ? mods_now->versions : ts::Installed{};
|
|
for (const auto& package : packages) have.erase(ts::folder_for(package.full_name));
|
|
auto plan = mod_store::plan(packages, shown->packages, have);
|
|
if (plan.packages.empty()) return "Nothing to install.";
|
|
if (auto refused = begin("install"); !refused.empty()) return refused;
|
|
try {
|
|
std::thread([plan = std::move(plan)] {
|
|
std::size_t now_in{}, later{};
|
|
std::vector<std::string> failed;
|
|
for (std::size_t i = 0; i < plan.packages.size(); ++i) {
|
|
if (stop.load()) break;
|
|
const auto& package = plan.packages[i];
|
|
try {
|
|
std::vector<std::string> queued;
|
|
for (std::size_t j = i + 1; j < plan.packages.size(); ++j) queued.push_back(plan.packages[j].full_name);
|
|
const auto how = install_one(package, i, plan.packages.size(), std::move(queued));
|
|
(how.empty() ? now_in : later) += 1;
|
|
log(logging::Level::info, package.full_name + " v" + package.latest().number + (how.empty() ? " installed." : " waits for the next start."));
|
|
} catch (const std::exception& failure) {
|
|
if (stop.load()) break;
|
|
failed.push_back(package.title() + ": " + failure.what());
|
|
}
|
|
}
|
|
std::string result;
|
|
const auto title = plan.packages.back().title();
|
|
if (stop.load()) result = "Cancelled. " + std::to_string(now_in) + " of " + std::to_string(plan.packages.size()) + " installed.";
|
|
else if (failed.empty() && later == 0)
|
|
result = plan.packages.size() == 1 ? title + " is installed." : std::to_string(now_in) + " mods installed.";
|
|
else if (failed.empty())
|
|
result = later == plan.packages.size() ? "The game has the old files open: the update goes in the next time it starts."
|
|
: std::to_string(now_in) + " installed; " + std::to_string(later) + " go in the next time the game starts.";
|
|
else {
|
|
result = failed.size() == 1 ? failed.front() : std::to_string(failed.size()) + " could not be installed: " + failed.front();
|
|
if (now_in) result += " (" + std::to_string(now_in) + " installed)";
|
|
}
|
|
if (!plan.unknown.empty()) result += " Not on Thunderstore: " + plan.unknown.front() + (plan.unknown.size() > 1 ? " and more." : ".");
|
|
finish(std::move(result), stop.load() || !failed.empty() || !plan.unknown.empty());
|
|
}).detach();
|
|
} catch (...) {
|
|
finish("The install could not start.", true);
|
|
}
|
|
return {};
|
|
}
|
|
|
|
std::string uninstall(const std::string& folder) {
|
|
if (!mods::valid_mod_name(folder)) return "That is not a mod folder.";
|
|
if (auto refused = begin("install"); !refused.empty()) return refused;
|
|
try {
|
|
std::thread([folder] {
|
|
set_package(Progress::Step::uninstalling, nullptr, 0, 1);
|
|
{
|
|
std::lock_guard lock(progress_mutex);
|
|
current_progress.package = current_progress.title = folder;
|
|
}
|
|
set_activity("Uninstalling " + folder);
|
|
const auto root = mods_root();
|
|
try {
|
|
launcher_mods::remove(root, folder);
|
|
finish(folder + " is uninstalled (it is in the Recycle Bin).", false);
|
|
return;
|
|
} catch (const std::exception& failure) {
|
|
log(logging::Level::info, folder + " could not be removed now (" + failure.what() + "); it goes at the next start.");
|
|
}
|
|
try {
|
|
{
|
|
// Off now, so the next apply takes it out of the game; gone at the next start.
|
|
std::lock_guard lock(write_mutex);
|
|
auto list = mods::scan_mods(mods::engine_data_root());
|
|
if (list.issue.empty()) {
|
|
for (auto& entry : list.entries)
|
|
if (same(entry.mod.name, folder)) entry.enabled = false;
|
|
mods::save_mod_order(list.root, list.entries);
|
|
}
|
|
}
|
|
launcher_mods::remove_later(root, folder);
|
|
finish(folder + " is switched off, and uninstalled the next time the game starts.", false);
|
|
} catch (const std::exception& failure) {
|
|
finish(std::string("Could not uninstall it: ") + failure.what(), true);
|
|
}
|
|
}).detach();
|
|
} catch (...) {
|
|
finish("The uninstall could not start.", true);
|
|
}
|
|
return {};
|
|
}
|
|
|
|
void cancel() {
|
|
stop.store(true);
|
|
watch.cancel.store(true);
|
|
}
|
|
|
|
std::shared_ptr<const Readme> readme(const ts::Package& wanted) {
|
|
const auto key = wanted.full_name + '-' + wanted.latest().number;
|
|
{
|
|
std::lock_guard lock(readme_mutex);
|
|
if (const auto found = readmes.find(key); found != readmes.end()) return found->second;
|
|
if (readmes.size() >= 64) readmes.clear();
|
|
readmes.emplace(key, nullptr);
|
|
}
|
|
try {
|
|
std::thread([key, url = ts::readme_url(wanted)] {
|
|
auto result = std::make_shared<Readme>();
|
|
if (const auto json = https::get_text(url, 1 << 20, 20, agent)) {
|
|
try { result->text = ts::readme_lines(ts::parse_readme(*json), 300); }
|
|
catch (...) { result->failed = true; }
|
|
} else result->failed = true;
|
|
std::lock_guard lock(readme_mutex);
|
|
readmes[key] = std::move(result);
|
|
}).detach();
|
|
} catch (...) {}
|
|
return nullptr;
|
|
}
|
|
|
|
std::string today() {
|
|
SYSTEMTIME now{};
|
|
GetSystemTime(&now);
|
|
char text[16];
|
|
std::snprintf(text, sizeof(text), "%04u-%02u-%02u", now.wYear, now.wMonth, now.wDay);
|
|
return text;
|
|
}
|
|
}
|