mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-20 08:16:27 +02:00
UNINSTALL, under OPEN FOLDER, takes the mod's stamp away first (a folder without it is no mod to the game), then its folder, and clears whether it was turned off so an install brings it back on. The card says UNINSTALLING meanwhile, and UNINSTALL FAILED when Windows keeps the files (Skate still running). Cards are as tall as their buttons.
442 lines
19 KiB
C++
442 lines
19 KiB
C++
#include "official_mods.h"
|
|
#include "mod_manager.h"
|
|
#include "text_encoding.h"
|
|
#include "thunderstore.h"
|
|
#include "updater.h"
|
|
|
|
#include "Engine/Core/Json/json.h"
|
|
#include "Engine/Core/Log/logging.h"
|
|
#include "Engine/Vfs/builtin_packs.h"
|
|
#include "Engine/Vfs/mod_list.h"
|
|
|
|
#include <Windows.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <condition_variable>
|
|
#include <format>
|
|
#include <fstream>
|
|
#include <functional>
|
|
#include <mutex>
|
|
#include <set>
|
|
#include <thread>
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace dingosdk::official_mods {
|
|
namespace {
|
|
using launcher_text::utf8;
|
|
using launcher_text::wide;
|
|
|
|
constexpr char list_url[] = "https://api.reskate.dev/api/v1/official-mods";
|
|
constexpr char list_file[] = "official-mods.json"; // the last list read, in the official folder
|
|
constexpr int list_timeout_ms = 15000;
|
|
constexpr int download_timeout_ms = 30000;
|
|
|
|
struct Shared {
|
|
std::mutex mutex;
|
|
std::condition_variable done;
|
|
State state;
|
|
bool started{};
|
|
int installs{}; // install() downloads running; Play waits for them
|
|
std::atomic<bool> cancel{};
|
|
};
|
|
Shared& shared() {
|
|
static auto* value = new Shared;
|
|
return *value;
|
|
}
|
|
void log(logging::Level level, const std::string& text) { logging::write(level, logging::Channel::launcher, text); }
|
|
|
|
std::string get(const std::string& url, std::uint64_t limit, int timeout_ms) {
|
|
std::string body;
|
|
launcher_update::http_stream(wide(url), limit, timeout_ms, [&](const char* data, DWORD size) { body.append(data, size); });
|
|
return body;
|
|
}
|
|
std::string read_text(const fs::path& path) {
|
|
std::ifstream input(path, std::ios::binary);
|
|
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
|
|
}
|
|
std::string trimmed(std::string text) {
|
|
while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == ' ')) text.pop_back();
|
|
return text;
|
|
}
|
|
// "Namespace-Name" with a name of letters, digits and underscores on each side: nothing else may
|
|
// become a folder name or a URL path here.
|
|
bool plain_full_name(std::string_view name) {
|
|
const auto dash = name.find('-');
|
|
if (dash == std::string_view::npos || !dash || dash + 1 >= name.size() || name.find('-', dash + 1) != std::string_view::npos)
|
|
return false;
|
|
return std::all_of(name.begin(), name.end(), [](char c) {
|
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-';
|
|
});
|
|
}
|
|
// An https URL on thunderstore.io or one of its hosts: nothing else is downloaded.
|
|
bool thunderstore_url(std::string_view url) {
|
|
if (!url.starts_with("https://")) return false;
|
|
const auto host = url.substr(8, url.find('/', 8) == std::string_view::npos ? std::string_view::npos : url.find('/', 8) - 8);
|
|
return host == "thunderstore.io" || host.ends_with(".thunderstore.io");
|
|
}
|
|
std::string title_of(std::string_view full_name) {
|
|
std::string title(full_name.substr(full_name.find('-') + 1));
|
|
std::replace(title.begin(), title.end(), '_', ' ');
|
|
return title;
|
|
}
|
|
fs::path official_root(const fs::path& mods_root) { return mods_root / std::string(mods::official_folder); }
|
|
std::string stamp_of(const fs::path& folder) { return trimmed(read_text(folder / std::string(mods::official_stamp))); }
|
|
void write_stamp(const fs::path& folder, const std::string& version) {
|
|
std::ofstream(folder / std::string(mods::official_stamp), std::ios::binary | std::ios::trunc) << (version.empty() ? "0" : version) << '\n';
|
|
}
|
|
// The version a mod's own manifest.json gives, or empty.
|
|
std::string manifest_version(const fs::path& folder) {
|
|
try {
|
|
const auto manifest = Json::parse(read_text(folder / L"manifest.json"));
|
|
return manifest.is_object() ? manifest.value("version_number", "") : std::string{};
|
|
} catch (...) {
|
|
return {};
|
|
}
|
|
}
|
|
void update(const std::string& full_name, const std::function<void(Mod&)>& change) {
|
|
auto& s = shared();
|
|
std::lock_guard lock(s.mutex);
|
|
for (auto& mod : s.state.mods)
|
|
if (mod.full_name == full_name) change(mod);
|
|
}
|
|
// The latest version Thunderstore lists for a mod the list named without one.
|
|
void ask_thunderstore(Mod& mod) {
|
|
const auto dash = mod.full_name.find('-');
|
|
const auto url = std::format("{}/api/experimental/package/{}/{}/", thunderstore::site, mod.full_name.substr(0, dash),
|
|
mod.full_name.substr(dash + 1));
|
|
const auto package = Json::parse(get(url, 4ull << 20, list_timeout_ms));
|
|
const auto& latest = package.at("latest");
|
|
const auto download = latest.value("download_url", "");
|
|
if (!thunderstore_url(download)) throw std::runtime_error("Thunderstore gave no download for it");
|
|
mod.version = latest.value("version_number", "");
|
|
mod.download_url = download;
|
|
if (mod.description.empty()) mod.description = latest.value("description", "");
|
|
if (mod.icon.empty()) mod.icon = latest.value("icon", "");
|
|
}
|
|
// Downloads `mod` and puts it in place of the version there, whole or not at all.
|
|
void download(const fs::path& root, const Mod& mod, const std::atomic<bool>& cancel) {
|
|
const auto cache = root / L".reskate-download";
|
|
std::error_code error;
|
|
fs::create_directories(cache, error);
|
|
const auto archive = cache / wide(mod.full_name + ".zip");
|
|
struct Cleanup {
|
|
fs::path archive;
|
|
~Cleanup() { std::error_code ignored; fs::remove(archive, ignored); }
|
|
} cleanup{archive};
|
|
{
|
|
std::ofstream file(archive, std::ios::binary | std::ios::trunc);
|
|
if (!file) throw std::runtime_error("the download could not be written");
|
|
launcher_update::http_stream(wide(mod.download_url), 4ull << 30, download_timeout_ms, [&](const char* data, DWORD size) {
|
|
if (cancel) throw std::runtime_error("stopped");
|
|
file.write(data, size);
|
|
if (!file) throw std::runtime_error("the download could not be written (is the disk full?)");
|
|
});
|
|
}
|
|
// Installed beside the one in place, then swapped in.
|
|
const auto staging = mod.full_name + ".new";
|
|
fs::remove_all(root / wide(staging), error);
|
|
launcher_mods::InstallOptions options;
|
|
options.folder = staging;
|
|
options.author = mod.full_name.substr(0, mod.full_name.find('-'));
|
|
options.require_content = true;
|
|
launcher_mods::install(root, archive, false, {}, cancel, options);
|
|
const auto target = root / wide(mod.full_name);
|
|
fs::remove_all(target, error);
|
|
fs::rename(root / wide(staging), target, error);
|
|
if (error) {
|
|
fs::remove_all(root / wide(staging), error);
|
|
throw std::runtime_error("the new version could not be put in place");
|
|
}
|
|
write_stamp(target, mod.version);
|
|
}
|
|
|
|
std::string status_of(const Mod& mod) {
|
|
if (mod.installed.empty()) return "Not installed";
|
|
if (!mod.version.empty() && mod.installed != mod.version) return "Update available";
|
|
return "Up to date";
|
|
}
|
|
|
|
void sync(const fs::path& mods_root) {
|
|
auto& s = shared();
|
|
const auto root = official_root(mods_root);
|
|
std::error_code error;
|
|
fs::create_directories(root, error);
|
|
|
|
// The list: ReSkate's, else the last one read, else ReSkate's own.
|
|
std::vector<Mod> list;
|
|
std::string source;
|
|
bool authoritative = true;
|
|
try {
|
|
const auto body = get(list_url, 4ull << 20, list_timeout_ms);
|
|
list = parse_list(body);
|
|
if (list.empty() && body.find("\"mods\"") == std::string::npos) throw std::runtime_error("not a list of official mods");
|
|
std::ofstream(root / list_file, std::ios::binary | std::ios::trunc) << body;
|
|
source = "ReSkate";
|
|
} catch (const std::exception& failure) {
|
|
log(logging::Level::warning, std::string("Official mods: the list could not be read from ReSkate (") + failure.what() + ")");
|
|
list = parse_list(read_text(root / list_file));
|
|
source = "the last list read";
|
|
if (list.empty() && !fs::exists(root / list_file, error)) {
|
|
list = fallback_list();
|
|
source = "ReSkate's own list";
|
|
authoritative = false;
|
|
}
|
|
}
|
|
for (auto& mod : list) mod.installed = stamp_of(root / wide(mod.full_name));
|
|
{
|
|
std::lock_guard lock(s.mutex);
|
|
s.state.mods = list;
|
|
s.state.source = source;
|
|
}
|
|
log(logging::Level::info, std::format("Official mods: {} from {}", list.size(), source));
|
|
|
|
for (const auto& line : adopt_and_retire(mods_root, list)) log(logging::Level::info, "Official mods: " + line);
|
|
// Nothing is downloaded here: the player installs and updates them on the OFFICIAL tab
|
|
// (install()). Only what the latest version is, for that tab.
|
|
for (auto& mod : list) {
|
|
if (s.cancel) break;
|
|
mod.installed = stamp_of(root / wide(mod.full_name));
|
|
try {
|
|
if (mod.version.empty() || mod.download_url.empty()) ask_thunderstore(mod);
|
|
} catch (const std::exception& failure) {
|
|
log(logging::Level::warning, std::format("Official mods: the latest version of {} is not known: {}", mod.full_name, failure.what()));
|
|
}
|
|
update(mod.full_name, [&](Mod& shown) {
|
|
shown = mod;
|
|
shown.status = status_of(mod);
|
|
});
|
|
}
|
|
// An official mod the list no longer names goes (it came from ReSkate, not the player).
|
|
if (authoritative && !s.cancel) {
|
|
std::set<std::string> named;
|
|
for (const auto& mod : list) named.insert(mod.full_name);
|
|
for (fs::directory_iterator at(root, error), end; !error && at != end; at.increment(error)) {
|
|
const auto name = utf8(at->path().filename().wstring());
|
|
if (!at->is_directory(error) || name.starts_with('.') || named.contains(name)) continue;
|
|
std::error_code ignored;
|
|
fs::remove_all(at->path(), ignored);
|
|
log(logging::Level::info, "Official mods: " + name + " is no longer official and was removed");
|
|
}
|
|
}
|
|
}
|
|
} // namespace
|
|
|
|
std::vector<Mod> parse_list(std::string_view json) {
|
|
std::vector<Mod> list;
|
|
try {
|
|
const auto answer = Json::parse(json);
|
|
if (!answer.is_object() || !answer.contains("mods") || !answer.at("mods").is_array()) return {};
|
|
const auto& mods = answer.at("mods");
|
|
std::set<std::string> seen;
|
|
for (std::size_t i = 0; i < mods.size(); ++i) {
|
|
const auto& row = mods.at(i);
|
|
if (!row.is_object()) continue;
|
|
Mod mod;
|
|
mod.full_name = row.value("full_name", "");
|
|
if (!plain_full_name(mod.full_name) || !seen.insert(mod.full_name).second) continue;
|
|
mod.title = row.value("title", "");
|
|
if (mod.title.empty()) mod.title = title_of(mod.full_name);
|
|
mod.description = row.value("description", "");
|
|
mod.icon = row.value("icon", "");
|
|
if (!thunderstore_url(mod.icon)) mod.icon.clear();
|
|
mod.version = row.value("version", "");
|
|
mod.download_url = row.value("download_url", "");
|
|
if (!thunderstore_url(mod.download_url) || mod.version.empty()) {
|
|
mod.version.clear();
|
|
mod.download_url.clear();
|
|
}
|
|
list.push_back(std::move(mod));
|
|
}
|
|
} catch (...) {
|
|
return {};
|
|
}
|
|
return list;
|
|
}
|
|
|
|
std::vector<Mod> fallback_list() {
|
|
std::vector<Mod> list;
|
|
for (const auto* name : {"Fourth_Studio-Board_Break_Pack", "Fourth_Studio-Road_Rash"}) {
|
|
Mod mod;
|
|
mod.full_name = name;
|
|
mod.title = title_of(name);
|
|
list.push_back(std::move(mod));
|
|
}
|
|
return list;
|
|
}
|
|
|
|
std::vector<std::string> adopt_and_retire(const fs::path& mods_root, const std::vector<Mod>& official) {
|
|
std::vector<std::string> done;
|
|
try {
|
|
const auto root = official_root(mods_root);
|
|
std::error_code error;
|
|
fs::create_directories(root, error);
|
|
std::vector<std::string> gone;
|
|
for (const auto& mod : official) {
|
|
const auto copy = mods_root / wide(mod.full_name);
|
|
if (!fs::is_directory(copy, error)) continue;
|
|
const auto target = root / wide(mod.full_name);
|
|
if (stamp_of(target).empty()) {
|
|
// Installed by the player already: moved over rather than downloaded again.
|
|
const auto version = manifest_version(copy);
|
|
fs::remove_all(target, error);
|
|
fs::rename(copy, target, error);
|
|
if (!error) {
|
|
write_stamp(target, version);
|
|
gone.push_back(mod.full_name);
|
|
done.push_back(std::format("{} (v{}) moved from the Mods folder", mod.full_name, version.empty() ? "?" : version));
|
|
continue;
|
|
}
|
|
error.clear();
|
|
}
|
|
try {
|
|
launcher_mods::remove(mods_root, mod.full_name);
|
|
gone.push_back(mod.full_name);
|
|
done.push_back(mod.full_name + ": the copy in the Mods folder went to the Recycle Bin");
|
|
} catch (...) {}
|
|
}
|
|
for (fs::directory_iterator at(mods_root, error), end; !error && at != end; at.increment(error)) {
|
|
const auto name = utf8(at->path().filename().wstring());
|
|
if (!at->is_directory(error) || !mods::retired_mod(name)) continue;
|
|
try {
|
|
launcher_mods::remove(mods_root, name);
|
|
gone.push_back(name);
|
|
done.push_back(name + " is retired and went to the Recycle Bin");
|
|
} catch (...) {}
|
|
}
|
|
// mods.json keeps the rest as it was; a malformed one is left for the player to see.
|
|
if (!gone.empty()) {
|
|
const auto list = mods::scan_mods(mods_root.parent_path());
|
|
if (list.issue.empty()) mods::save_mod_order(mods_root, list.entries);
|
|
}
|
|
} catch (...) {}
|
|
return done;
|
|
}
|
|
|
|
void start(const fs::path& mods_root) {
|
|
auto& s = shared();
|
|
{
|
|
std::lock_guard lock(s.mutex);
|
|
if (s.started) return;
|
|
s.started = true;
|
|
s.state.syncing = true;
|
|
}
|
|
std::thread([mods_root] {
|
|
try {
|
|
sync(mods_root);
|
|
} catch (const std::exception& failure) {
|
|
log(logging::Level::warning, std::string("Official mods could not be put in place: ") + failure.what());
|
|
} catch (...) {}
|
|
auto& s = shared();
|
|
{
|
|
std::lock_guard lock(s.mutex);
|
|
s.state.syncing = false;
|
|
s.state.synced = true;
|
|
}
|
|
s.done.notify_all();
|
|
}).detach();
|
|
}
|
|
|
|
bool wait(std::chrono::milliseconds timeout) {
|
|
auto& s = shared();
|
|
std::unique_lock lock(s.mutex);
|
|
return s.done.wait_for(lock, timeout, [&] { return s.state.synced && !s.installs; });
|
|
}
|
|
|
|
void install(const fs::path& mods_root, const std::string& full_name) {
|
|
auto& s = shared();
|
|
Mod mod;
|
|
{
|
|
std::lock_guard lock(s.mutex);
|
|
const auto found = std::find_if(s.state.mods.begin(), s.state.mods.end(), [&](const Mod& m) { return m.full_name == full_name; });
|
|
if (found == s.state.mods.end() || found->working || s.state.syncing) return;
|
|
found->working = true;
|
|
found->status = found->installed.empty() ? "Downloading" : "Updating";
|
|
mod = *found;
|
|
++s.installs;
|
|
}
|
|
std::thread([mods_root, mod]() mutable {
|
|
auto& s = shared();
|
|
std::string status;
|
|
try {
|
|
if (mod.version.empty() || mod.download_url.empty()) ask_thunderstore(mod);
|
|
const auto was = mod.installed;
|
|
download(official_root(mods_root), mod, s.cancel);
|
|
mod.installed = mod.version;
|
|
log(logging::Level::info, std::format("Official mods: {} v{} {}", mod.full_name, mod.version,
|
|
was.empty() ? "installed" : "updated from v" + was));
|
|
status = status_of(mod);
|
|
} catch (const std::exception& failure) {
|
|
log(logging::Level::warning, std::format("Official mods: {} could not be {}: {}", mod.full_name,
|
|
mod.installed.empty() ? "installed" : "updated", failure.what()));
|
|
status = std::string(mod.installed.empty() ? "Install failed: " : "Update failed: ") + failure.what();
|
|
}
|
|
update(mod.full_name, [&](Mod& shown) {
|
|
shown = mod;
|
|
shown.status = status;
|
|
shown.working = false;
|
|
});
|
|
{
|
|
std::lock_guard lock(s.mutex);
|
|
--s.installs;
|
|
}
|
|
s.done.notify_all();
|
|
}).detach();
|
|
}
|
|
|
|
void uninstall(const fs::path& mods_root, const std::string& full_name) {
|
|
auto& s = shared();
|
|
{
|
|
std::lock_guard lock(s.mutex);
|
|
const auto found = std::find_if(s.state.mods.begin(), s.state.mods.end(), [&](const Mod& m) { return m.full_name == full_name; });
|
|
if (found == s.state.mods.end() || found->working || found->installed.empty() || s.state.syncing) return;
|
|
found->working = true;
|
|
found->status = "Removing";
|
|
++s.installs;
|
|
}
|
|
std::thread([mods_root, full_name] {
|
|
auto& s = shared();
|
|
const auto folder = official_root(mods_root) / wide(full_name);
|
|
std::error_code error;
|
|
// The stamp first: a folder without it is no mod to the game, whatever is left of it.
|
|
fs::remove(folder / std::string(mods::official_stamp), error);
|
|
const bool gone = !error;
|
|
if (gone) {
|
|
fs::remove_all(folder, error);
|
|
if (error)
|
|
log(logging::Level::warning, std::format("Official mods: some of {} could not be deleted ({}); it is uninstalled all the same",
|
|
full_name, error.message()));
|
|
(void)mods::set_official_off(mods_root, full_name, false);
|
|
log(logging::Level::info, "Official mods: " + full_name + " uninstalled");
|
|
} else {
|
|
log(logging::Level::warning, std::format("Official mods: {} could not be uninstalled: {}", full_name, error.message()));
|
|
}
|
|
update(full_name, [&](Mod& shown) {
|
|
if (gone) shown.installed.clear();
|
|
shown.status = gone ? status_of(shown) : "Uninstall failed: " + error.message() + " (is Skate still running?)";
|
|
shown.working = false;
|
|
});
|
|
{
|
|
std::lock_guard lock(s.mutex);
|
|
--s.installs;
|
|
}
|
|
s.done.notify_all();
|
|
}).detach();
|
|
}
|
|
|
|
State snapshot() {
|
|
auto& s = shared();
|
|
std::lock_guard lock(s.mutex);
|
|
return s.state;
|
|
}
|
|
|
|
bool official(std::string_view full_name) {
|
|
if (mods::retired_mod(thunderstore::folder_for(full_name))) return true;
|
|
auto& s = shared();
|
|
std::lock_guard lock(s.mutex);
|
|
const auto& mods = s.state.mods.empty() ? fallback_list() : s.state.mods;
|
|
return std::any_of(mods.begin(), mods.end(), [&](const Mod& mod) { return mod.full_name == full_name; });
|
|
}
|
|
} // namespace dingosdk::official_mods
|