mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-27 19:47:08 +01:00
Installs, updates and uninstalls are jobs run one after another in the order asked for: asking for one while another runs queues it instead of being refused. A job waits while the mods are applied or a session's map downloads, and works out what its mod needs when it starts, from the Mods folder as it is then. Buttons say ADD TO QUEUE while a job runs and QUEUED once in line; the foot of the page says how many wait, and CANCEL ALL empties the queue. The carousel's middle card: the mod's picture as its backdrop, darkened, and big as a tipped sticker beside the text; a FEATURED and INSTALLED / UPDATE AVAILABLE tag, the facts as chips, as much of the description as fits above the buttons, and the buttons under the text with the pad's prompts beside them.
757 lines
34 KiB
C++
757 lines
34 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 <deque>
|
|
#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;
|
|
}
|
|
// A job ended: what it did, for the page. The queue may go on with the next.
|
|
void job_done(std::string result, bool failed) {
|
|
log(failed ? logging::Level::warning : logging::Level::info, result);
|
|
{
|
|
std::lock_guard lock(progress_mutex);
|
|
current_progress.activity.clear();
|
|
current_progress.fraction = -1;
|
|
current_progress.step = Progress::Step::starting;
|
|
current_progress.package.clear();
|
|
current_progress.title.clear();
|
|
current_progress.version.clear();
|
|
current_progress.icon.clear();
|
|
current_progress.queued.clear();
|
|
current_progress.received = current_progress.total = 0;
|
|
current_progress.result = std::move(result);
|
|
current_progress.failed = failed;
|
|
++current_progress.serial;
|
|
}
|
|
rescan();
|
|
}
|
|
|
|
// The jobs waiting their turn, and whether the worker runs. A job is an install of some packages or an
|
|
// uninstall of a folder.
|
|
struct Job {
|
|
std::vector<std::string> install;
|
|
std::string uninstall;
|
|
};
|
|
std::mutex jobs_mutex;
|
|
std::deque<Job> jobs; // under jobs_mutex
|
|
bool working{}; // under jobs_mutex: the worker thread runs
|
|
|
|
// 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() {
|
|
Progress result;
|
|
{
|
|
std::lock_guard lock(progress_mutex);
|
|
result = current_progress;
|
|
}
|
|
// And what waits after the job in hand.
|
|
std::lock_guard lock(jobs_mutex);
|
|
result.waiting = jobs.size();
|
|
for (const auto& job : jobs) {
|
|
result.queued.insert(result.queued.end(), job.install.begin(), job.install.end());
|
|
if (!job.uninstall.empty()) result.queued.push_back(job.uninstall);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
namespace {
|
|
// An install job: the packages asked for at their newest, and what they need that is not here at the
|
|
// version asked for (read from the Mods folder now: a job before this one may have installed it).
|
|
std::pair<std::string, bool> install_job(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 any more.", true};
|
|
ts::Installed have;
|
|
for (const auto& entry : mods::scan_mods(mods::engine_data_root()).entries) have[entry.mod.name] = entry.mod.version;
|
|
for (const auto& package : packages) have.erase(ts::folder_for(package.full_name));
|
|
const auto plan = mod_store::plan(packages, shown->packages, have);
|
|
if (plan.packages.empty()) return {"Nothing to install.", false};
|
|
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> rest;
|
|
for (std::size_t j = i + 1; j < plan.packages.size(); ++j) rest.push_back(plan.packages[j].full_name);
|
|
const auto how = install_one(package, i, plan.packages.size(), std::move(rest));
|
|
(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 (" + title + " and what it needs).";
|
|
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." : ".");
|
|
return {result, stop.load() || !failed.empty() || !plan.unknown.empty()};
|
|
}
|
|
std::pair<std::string, bool> uninstall_job(const std::string& 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);
|
|
return {folder + " is uninstalled (it is in the Recycle Bin).", false};
|
|
} 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);
|
|
return {folder + " is switched off, and uninstalled the next time the game starts.", false};
|
|
} catch (const std::exception& failure) {
|
|
return {std::string("Could not uninstall it: ") + failure.what(), true};
|
|
}
|
|
}
|
|
// The worker: the jobs one after another, each once nothing else is changing the Mods folder (a merge,
|
|
// a session's map download); then it ends, until a job is queued again.
|
|
void work() {
|
|
for (;;) {
|
|
Job job;
|
|
{
|
|
std::lock_guard lock(jobs_mutex);
|
|
if (jobs.empty()) {
|
|
working = false;
|
|
busy.store(false);
|
|
std::lock_guard progress_lock(progress_mutex);
|
|
current_progress.busy = false;
|
|
return;
|
|
}
|
|
job = std::move(jobs.front());
|
|
jobs.pop_front();
|
|
}
|
|
stop.store(false);
|
|
watch.cancel.store(false);
|
|
set_package(Progress::Step::starting, nullptr, 0, 0);
|
|
while (!stop.load() && (live_mods::busy() || map_download::view().stage != map_download::Stage::idle)) {
|
|
set_activity(live_mods::busy() ? "Waiting for the mods being applied..." : "Waiting for the session's map download...");
|
|
Sleep(250);
|
|
}
|
|
set_activity("Starting...");
|
|
std::pair<std::string, bool> outcome;
|
|
try {
|
|
outcome = stop.load() ? std::pair<std::string, bool>{"Cancelled.", true}
|
|
: job.uninstall.empty() ? install_job(job.install) : uninstall_job(job.uninstall);
|
|
} catch (const std::exception& failure) {
|
|
outcome = {std::string("It could not be done: ") + failure.what(), true};
|
|
} catch (...) {
|
|
outcome = {"It could not be done.", true};
|
|
}
|
|
job_done(std::move(outcome.first), outcome.second);
|
|
}
|
|
}
|
|
// Adds a job, starting the worker when it is not running.
|
|
std::string enqueue(Job job) {
|
|
std::lock_guard lock(jobs_mutex);
|
|
jobs.push_back(std::move(job));
|
|
{
|
|
std::lock_guard progress_lock(progress_mutex);
|
|
if (!current_progress.busy) {
|
|
current_progress.busy = true;
|
|
current_progress.result.clear();
|
|
current_progress.failed = false;
|
|
current_progress.activity = "Starting...";
|
|
current_progress.fraction = -1;
|
|
}
|
|
}
|
|
if (!working) {
|
|
try {
|
|
std::thread(work).detach();
|
|
working = true;
|
|
busy.store(true);
|
|
} catch (...) {
|
|
jobs.pop_back();
|
|
std::lock_guard progress_lock(progress_mutex);
|
|
current_progress.busy = false;
|
|
return "The install could not start.";
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
} // namespace
|
|
|
|
bool queued(std::string_view package) {
|
|
{
|
|
std::lock_guard lock(progress_mutex);
|
|
if (current_progress.package == package ||
|
|
std::find(current_progress.queued.begin(), current_progress.queued.end(), package) != current_progress.queued.end())
|
|
return true;
|
|
}
|
|
std::lock_guard lock(jobs_mutex);
|
|
return std::any_of(jobs.begin(), jobs.end(), [&](const Job& job) {
|
|
return std::find(job.install.begin(), job.install.end(), package) != job.install.end();
|
|
});
|
|
}
|
|
|
|
std::string install(const std::vector<std::string>& wanted) {
|
|
const auto shown = listing();
|
|
std::vector<std::string> names;
|
|
for (const auto& name : wanted)
|
|
if (ts::find_package(shown->packages, name) && !queued(name) &&
|
|
std::find(names.begin(), names.end(), name) == names.end())
|
|
names.push_back(name);
|
|
if (names.empty()) {
|
|
const bool listed = std::any_of(wanted.begin(), wanted.end(), [&](const std::string& name) { return ts::find_package(shown->packages, name); });
|
|
return listed ? "Already in the download queue." : "That mod is not in Thunderstore's listing.";
|
|
}
|
|
return enqueue({std::move(names), {}});
|
|
}
|
|
|
|
std::string uninstall(const std::string& folder) {
|
|
if (!mods::valid_mod_name(folder)) return "That is not a mod folder.";
|
|
{
|
|
std::lock_guard lock(jobs_mutex);
|
|
if (std::any_of(jobs.begin(), jobs.end(), [&](const Job& job) { return same(job.uninstall, folder); }))
|
|
return "Already in the queue.";
|
|
}
|
|
return enqueue({{}, folder});
|
|
}
|
|
|
|
void cancel() {
|
|
{
|
|
std::lock_guard lock(jobs_mutex);
|
|
jobs.clear();
|
|
}
|
|
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;
|
|
}
|
|
}
|