From fef15c73b8e602b2c0100b3a4615dde4ea0c5782 Mon Sep 17 00:00:00 2001 From: Killklli <11064610+Killklli@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:20:18 -0400 Subject: [PATCH 1/7] Mod Marketplace UI --- CMakeLists.txt | 5 + recompui/CMakeLists.txt | 4 + recompui/src/composites/ui_mod_marketplace.h | 135 ++++++ .../ui_mod_marketplace_downloader.cpp | 369 ++++++++++++++++ .../composites/ui_mod_marketplace_http.cpp | 154 +++++++ .../src/composites/ui_mod_marketplace_ui.cpp | 414 ++++++++++++++++++ .../composites/ui_mod_marketplace_utils.cpp | 109 +++++ recompui/src/composites/ui_mod_menu.cpp | 6 + recompui/src/composites/ui_mod_menu.h | 4 + 9 files changed, 1200 insertions(+) create mode 100644 recompui/src/composites/ui_mod_marketplace.h create mode 100644 recompui/src/composites/ui_mod_marketplace_downloader.cpp create mode 100644 recompui/src/composites/ui_mod_marketplace_http.cpp create mode 100644 recompui/src/composites/ui_mod_marketplace_ui.cpp create mode 100644 recompui/src/composites/ui_mod_marketplace_utils.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a4981c0..3c85889 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,11 @@ cmake_minimum_required(VERSION 3.20) project(RecompFrontend) +# Include vcpkg toolchain file if it exists +if(DEFINED ENV{VCPKG_ROOT}) + set(CMAKE_TOOLCHAIN_FILE "$ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake" CACHE STRING "Vcpkg toolchain file") +endif() + set(CMAKE_C_STANDARD 17) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/recompui/CMakeLists.txt b/recompui/CMakeLists.txt index b9c5458..a714ce5 100644 --- a/recompui/CMakeLists.txt +++ b/recompui/CMakeLists.txt @@ -99,6 +99,9 @@ target_include_directories(recompui PUBLIC build_vertex_shader(recompui "shaders/InterfaceVS.hlsl" "shaders/InterfaceVS.hlsl") build_pixel_shader(recompui "shaders/InterfacePS.hlsl" "shaders/InterfacePS.hlsl") +# Find curl package from vcpkg +find_package(CURL REQUIRED) + target_include_directories(recompui PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include/recompui") if (WIN32) @@ -120,4 +123,5 @@ target_link_libraries(recompui PUBLIC RmlUi::Debugger lunasvg miniz + CURL::libcurl ) diff --git a/recompui/src/composites/ui_mod_marketplace.h b/recompui/src/composites/ui_mod_marketplace.h new file mode 100644 index 0000000..808a68e --- /dev/null +++ b/recompui/src/composites/ui_mod_marketplace.h @@ -0,0 +1,135 @@ +#ifndef RECOMPUI_MOD_MARKETPLACE_H +#define RECOMPUI_MOD_MARKETPLACE_H + +#include "elements/ui_button.h" +#include "elements/ui_container.h" +#include "elements/ui_element.h" +#include "elements/ui_image.h" +#include "elements/ui_label.h" +#include "elements/ui_scroll_container.h" + +#include +#include +#include +#include +#include +#include + +namespace recompui +{ + + std::string get_marketplace_url(); + void curl_global_initialize(); + std::string http_fetch_string(const std::string &url); + void http_download_to_file(const std::string &url, + const std::string &output_path); + std::vector decode_base64(const std::string &encoded); + std::pair + parse_dep_string(const std::string &dep_str); + int compare_versions(const std::string &version1, + const std::string &version2); + + enum class ModInstallStatus + { + NotInstalled, + Installed, + UpdateAvailable, + DowngradeAvailable, + MissingDependencies + }; + + struct MarketplaceMod + { + std::string name; + std::string short_description; + std::string file_url; + std::string thumbnail_image; + std::string version; + std::string id; + std::string game_id; + std::vector dependencies; + }; + + class ModMarketplaceEntry : public Element + { + public: + MarketplaceMod mod_data; + ModMarketplaceEntry(Element *parent, const MarketplaceMod &mod_data); + virtual ~ModMarketplaceEntry(); + void + set_download_callback(std::function callback); + void update_install_status(ModInstallStatus status); + Button *get_download_button() { return download_button; } + + protected: + std::string_view get_type_name() override { return "ModMarketplaceEntry"; } + void process_event(const Event &e) override; + + private: + Container *entry_container = nullptr; + Image *thumbnail_image = nullptr; + Label *name_label = nullptr; + Label *description_label = nullptr; + Button *download_button = nullptr; + std::function download_callback; + }; + + class ModDownloadsPanel : public Element + { + public: + ModDownloadsPanel(Element *parent); + virtual ~ModDownloadsPanel(); + void show(); + void hide(); + void fetch_marketplace_data(); + + protected: + std::string_view get_type_name() override { return "ModDownloadsPanel"; } + void process_event(const Event &e) override; + + private: + void load_marketplace_mods(const std::vector &mods); + void download_mod(const MarketplaceMod &mod); + std::string fetch_json_from_url(const std::string &url); + std::vector + parse_marketplace_json(const std::string &json_data); + void download_file_from_url(const std::string &url, + const std::string &output_path); + bool is_mod_installed(const MarketplaceMod &mod); + ModInstallStatus get_mod_install_status(const MarketplaceMod &mod); + std::string get_installed_mod_version(const MarketplaceMod &mod); + bool check_dependencies_satisfiable(const MarketplaceMod &mod) const; + const MarketplaceMod * + find_marketplace_mod_by_id(const std::string &mod_id) const; + bool install_single_mod_file(const MarketplaceMod &mod, + std::vector &out_errors); + void resolve_and_install_dependencies( + const MarketplaceMod &mod, std::unordered_set &visited_ids, + std::vector &out_warnings, + std::vector &out_errors, + std::vector &out_installed_deps); + + Container *main_container = nullptr; + Container *content_panel = nullptr; + Label *title_label = nullptr; + Label *status_label = nullptr; + ScrollContainer *mod_list_container = nullptr; + Button *refresh_button = nullptr; + Button *close_button = nullptr; + std::vector mod_entries; + std::vector fetched_mods; + std::string fetch_error; + bool is_visible = false; + bool is_loading = false; + bool fetch_completed = false; + }; + class ElementModDownloads : public Rml::Element + { + public: + ElementModDownloads(const Rml::String &tag); + virtual ~ElementModDownloads(); + }; + +} + +#endif \ No newline at end of file diff --git a/recompui/src/composites/ui_mod_marketplace_downloader.cpp b/recompui/src/composites/ui_mod_marketplace_downloader.cpp new file mode 100644 index 0000000..1504076 --- /dev/null +++ b/recompui/src/composites/ui_mod_marketplace_downloader.cpp @@ -0,0 +1,369 @@ +#include "librecomp/mods.hpp" +#include "recompui/recompui.h" +#include "ui_mod_installer.h" +#include "ui_mod_marketplace.h" +#include +#include + +namespace recompui +{ + + // Gets all marketplace mods where the mod id matches for the game + const MarketplaceMod * + ModDownloadsPanel::find_marketplace_mod_by_id(const std::string &mod_id) const + { + for (const auto &m : fetched_mods) + { + if (m.id == mod_id) + return &m; + } + return nullptr; + } + + // Download and install a mod from the marketplace + bool ModDownloadsPanel::install_single_mod_file( + const MarketplaceMod &mod, std::vector &out_errors) + { + // If theres no download URL just throw out a message, because something + // upstream went wrong + if (mod.file_url.empty()) + { + out_errors.push_back("No download URL available for " + mod.name); + return false; + } + + try + { + std::filesystem::path temp_dir = std::filesystem::temp_directory_path(); + + const std::string &url = mod.file_url; + size_t last_slash = url.find_last_of('/'); + std::string filename = (last_slash != std::string::npos) + ? url.substr(last_slash + 1) + : "download.zip"; + std::filesystem::path temp_file = temp_dir / filename; + + http_download_to_file(url, temp_file.string()); + + ModInstaller::Result install_result; + std::list file_paths = {temp_file}; + + // We're just using the normal built in mod installer, this dosen't support + // multiple zips split out currently, I'd love to add this, but dealing with + // each zip type eg winrar didn't really scale. + ModInstaller installer; + installer.start_mod_installation( + file_paths, [](std::filesystem::path, size_t, size_t) {}, + install_result); + + bool needs_close = std::any_of(install_result.pending_installations.begin(), + install_result.pending_installations.end(), + [](const ModInstaller::Installation &i) + { + return i.needs_overwrite_confirmation; + }); + if (needs_close) + recomp::mods::close_mods(); + + std::vector finish_errors; + installer.finish_mod_installation(install_result, finish_errors); + install_result.error_messages.insert(install_result.error_messages.end(), + finish_errors.begin(), + finish_errors.end()); + + std::filesystem::remove(temp_file); + + if (!install_result.error_messages.empty()) + { + out_errors.insert(out_errors.end(), install_result.error_messages.begin(), + install_result.error_messages.end()); + return false; + } + + return true; + } + catch (const std::exception &e) + { + out_errors.push_back("Download/install error for " + mod.name + ": " + + e.what()); + return false; + } + } + + // Resolves and installs dependencies recursively before target install. + void ModDownloadsPanel::resolve_and_install_dependencies( + const MarketplaceMod &mod, std::unordered_set &visited_ids, + std::vector &out_warnings, + std::vector &out_errors, + std::vector &out_installed_deps) + { + // For each dep that the mod has we need to actually look up if the sub mod is avaiable. + for (const std::string &dep_str : mod.dependencies) + { + auto [dep_id, required_version] = parse_dep_string(dep_str); + + if (dep_id.empty() || visited_ids.count(dep_id)) + continue; + + MarketplaceMod dummy_dep; + dummy_dep.id = dep_id; + dummy_dep.version = required_version; + // Check if we already have it installed, and that it satisfies the required version, if we do then we can skip trying to install it. + if (is_mod_installed(dummy_dep) && !required_version.empty()) + { + std::string installed_ver = get_installed_mod_version(dummy_dep); + if (!installed_ver.empty() && + compare_versions(installed_ver, required_version) >= 0) + { + visited_ids.insert(dep_id); + continue; + } + } + // If we don't have the mod or its too old on the marketplace just warn the user we can't install it. + const MarketplaceMod *dep_mod = find_marketplace_mod_by_id(dep_id); + if (!dep_mod) + { + out_warnings.push_back( + "Dependency '" + dep_id + "' required by '" + mod.name + + "' was not found in the marketplace and must be installed manually."); + continue; + } + + // If its on the marketplace but its the wrong version, just throw out some warnings based on if its new or not. + if (!required_version.empty() && !dep_mod->version.empty()) + { + int cmp = compare_versions(dep_mod->version, required_version); + if (cmp < 0) + { + out_warnings.push_back( + "Dependency '" + dep_mod->name + "' requires v" + required_version + + " but the marketplace only has v" + dep_mod->version + + ". Skipping auto-install; please install it manually."); + continue; + } + else if (cmp > 0) + { + out_warnings.push_back( + "Dependency '" + dep_mod->name + "' requires v" + required_version + + " but the marketplace has a newer version v" + dep_mod->version + + ". Installing the newer version."); + } + } + + visited_ids.insert(dep_id); + // Make sure we recursively resolve dependencies for the current mods dependency before we try to install it, just in case. + resolve_and_install_dependencies(*dep_mod, visited_ids, out_warnings, + out_errors, out_installed_deps); + + status_label->set_text("Installing dependency: " + dep_mod->name + "..."); + + std::vector dep_errors; + // Actually install the dependency + if (!install_single_mod_file(*dep_mod, dep_errors)) + { + for (const auto &err : dep_errors) + out_errors.push_back("Dependency '" + dep_mod->name + "': " + err); + } + else + { + out_installed_deps.push_back(dep_mod->name); + + for (ModMarketplaceEntry *entry : mod_entries) + { + if (entry->mod_data.id == dep_mod->id) + { + entry->update_install_status(ModInstallStatus::Installed); + entry->queue_update(); + break; + } + } + } + } + } + + // On mod download button click + void ModDownloadsPanel::download_mod(const MarketplaceMod &mod) + { + if (mod.file_url.empty()) + { + status_label->set_text("No download URL available for " + mod.name); + return; + } + + status_label->set_text("Downloading " + mod.name + "..."); + + ModMarketplaceEntry *downloading_entry = nullptr; + for (ModMarketplaceEntry *entry : mod_entries) + { + if (entry->mod_data.id == mod.id && entry->mod_data.name == mod.name) + { + downloading_entry = entry; + break; + } + } + + try + { + std::vector warnings; + std::vector dep_errors; + std::vector installed_deps; + // We're doing a bit of a double check once we reach the resolve and install step, but I'll be honest, I don't trust my own code sometimes. + if (!mod.dependencies.empty()) + { + status_label->set_text("Resolving dependencies for " + mod.name + "..."); + std::unordered_set visited_ids = {mod.id}; + resolve_and_install_dependencies(mod, visited_ids, warnings, dep_errors, + installed_deps); + + if (!dep_errors.empty()) + { + std::string error_msg = "Dependency installation failed:\n"; + for (const auto &err : dep_errors) + error_msg += " " + err + "\n"; + status_label->set_text(error_msg); + return; + } + } + + if (!warnings.empty()) + { + std::string warn_msg = "Warning(s):\n"; + for (const auto &w : warnings) + warn_msg += " " + w + "\n"; + status_label->set_text(warn_msg); + } + + status_label->set_text("Installing " + mod.name + "..."); + + std::vector install_errors; + if (!install_single_mod_file(mod, install_errors)) + { + std::string error_msg = "Installation failed: "; + for (const auto &err : install_errors) + error_msg += err + " "; + status_label->set_text(error_msg); + return; + } + + recompui::update_mod_list(true); + recomp::mods::scan_mods(); + + if (downloading_entry) + { + downloading_entry->update_install_status(ModInstallStatus::Installed); + downloading_entry->queue_update(); + } + + for (ModMarketplaceEntry *entry : mod_entries) + { + if (entry == downloading_entry) + continue; + ModInstallStatus entry_status = get_mod_install_status(entry->mod_data); + if (entry_status != ModInstallStatus::NotInstalled) + entry->update_install_status(entry_status); + } + + std::string final_msg = "Successfully installed " + mod.name + "!"; + if (!installed_deps.empty()) + { + final_msg += " Also installed dependencies: "; + for (size_t i = 0; i < installed_deps.size(); i++) + { + if (i > 0) + final_msg += ", "; + final_msg += installed_deps[i]; + } + final_msg += "."; + } + if (!warnings.empty()) + final_msg += " (with warnings — check dependency versions)"; + + status_label->set_text(final_msg); + queue_update(); + } + catch (const std::exception &e) + { + status_label->set_text("Download failed: " + std::string(e.what())); + } + } + + // Checks if a mod is currently installed locally. + bool ModDownloadsPanel::is_mod_installed(const MarketplaceMod &mod) + { + if (mod.id.empty()) + return false; + return recomp::mods::get_details_for_mod(mod.id).has_value(); + } + + // Gets the installed version string for a mod identifier. + std::string + ModDownloadsPanel::get_installed_mod_version(const MarketplaceMod &mod) + { + if (mod.id.empty()) + return {}; + std::optional details = + recomp::mods::get_details_for_mod(mod.id); + return details.has_value() ? details->version.to_string() : std::string{}; + } + + // Wrapper that checks all our installed mod status' + ModInstallStatus + ModDownloadsPanel::get_mod_install_status(const MarketplaceMod &mod) + { + if (!is_mod_installed(mod)) + return check_dependencies_satisfiable(mod) + ? ModInstallStatus::NotInstalled + : ModInstallStatus::MissingDependencies; + + std::string installed_version = get_installed_mod_version(mod); + if (installed_version.empty() || mod.version.empty()) + return ModInstallStatus::Installed; + + int cmp = compare_versions(mod.version, installed_version); + if (cmp > 0) + return ModInstallStatus::UpdateAvailable; + if (cmp < 0) + return ModInstallStatus::DowngradeAvailable; + return ModInstallStatus::Installed; + } + + // Determines whether all dependencies can be satisfied. + bool ModDownloadsPanel::check_dependencies_satisfiable( + const MarketplaceMod &mod) const + { + for (const std::string &dep_str : mod.dependencies) + { + // Validate the dependency string parses out. + auto [dep_id, required_version] = parse_dep_string(dep_str); + + if (dep_id.empty()) + continue; + + std::optional installed = + recomp::mods::get_details_for_mod(dep_id); + if (installed.has_value()) + { + if (required_version.empty()) + continue; + + std::string inst_ver = installed->version.to_string(); + if (!inst_ver.empty() && + compare_versions(inst_ver, required_version) >= 0) + continue; + } + + const MarketplaceMod *dep_mod = find_marketplace_mod_by_id(dep_id); + if (!dep_mod) + return false; + + if (!required_version.empty() && !dep_mod->version.empty() && + compare_versions(dep_mod->version, required_version) < 0) + { + return false; + } + } + + return true; + } + +} \ No newline at end of file diff --git a/recompui/src/composites/ui_mod_marketplace_http.cpp b/recompui/src/composites/ui_mod_marketplace_http.cpp new file mode 100644 index 0000000..6a4b504 --- /dev/null +++ b/recompui/src/composites/ui_mod_marketplace_http.cpp @@ -0,0 +1,154 @@ +#include "ui_mod_marketplace.h" + +#include +#include +#include +#include +#include + +namespace recompui +{ + // Simple Curl Wrapper for getting mod info + struct CurlSession + { + CURL *handle = nullptr; + + CurlSession() + { + handle = curl_easy_init(); + if (!handle) + throw std::runtime_error("Failed to initialize libcurl easy handle"); + + curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(handle, CURLOPT_USERAGENT, "ModDownloader/1.0"); + curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 2L); + } + + ~CurlSession() + { + if (handle) + curl_easy_cleanup(handle); + } + + CurlSession(const CurlSession &) = delete; + CurlSession &operator=(const CurlSession &) = delete; + + CURLcode perform() { return curl_easy_perform(handle); } + + long response_code() const + { + long code = 0; + curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &code); + return code; + } + }; + + static size_t write_string_callback(void *contents, size_t size, size_t nmemb, + std::string *out) + { + out->append(static_cast(contents), size * nmemb); + return size * nmemb; + } + + static size_t write_file_callback(void *contents, size_t size, size_t nmemb, + FILE *file) + { + return fwrite(contents, size, nmemb, file); + } + + void curl_global_initialize() + { + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) + throw std::runtime_error("Failed to initialize libcurl globally"); + } + + std::string http_fetch_string(const std::string &url) + { + CurlSession session; + std::string response; + + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + std::string busted_url = url + "?t=" + std::to_string(now); + + curl_easy_setopt(session.handle, CURLOPT_URL, busted_url.c_str()); + curl_easy_setopt(session.handle, CURLOPT_WRITEFUNCTION, + write_string_callback); + curl_easy_setopt(session.handle, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(session.handle, CURLOPT_TIMEOUT, 30L); + + struct curl_slist *headers = nullptr; + headers = curl_slist_append( + headers, "Cache-Control: no-cache, no-store, must-revalidate"); + headers = curl_slist_append(headers, "Pragma: no-cache"); + headers = curl_slist_append(headers, "Expires: 0"); + curl_easy_setopt(session.handle, CURLOPT_HTTPHEADER, headers); + + CURLcode res = session.perform(); + curl_slist_free_all(headers); + + if (res != CURLE_OK) + throw std::runtime_error(std::string("libcurl error: ") + + curl_easy_strerror(res)); + + long http_code = session.response_code(); + if (http_code != 200) + throw std::runtime_error("HTTP error: " + std::to_string(http_code)); + + if (response.empty()) + throw std::runtime_error("No data received from server"); + + return response; + } + + void http_download_to_file(const std::string &url, + const std::string &output_path) + { + FILE *file = fopen(output_path.c_str(), "wb"); + if (!file) + { + throw std::runtime_error("Failed to open output file: " + output_path); + } + + try + { + CurlSession session; + + curl_easy_setopt(session.handle, CURLOPT_URL, url.c_str()); + curl_easy_setopt(session.handle, CURLOPT_WRITEFUNCTION, + write_file_callback); + curl_easy_setopt(session.handle, CURLOPT_WRITEDATA, file); + curl_easy_setopt(session.handle, CURLOPT_TIMEOUT, 120L); + + CURLcode res = session.perform(); + + if (res != CURLE_OK) + { + fclose(file); + std::filesystem::remove(output_path); + throw std::runtime_error(std::string("libcurl download error: ") + + curl_easy_strerror(res)); + } + + long http_code = session.response_code(); + if (http_code != 200) + { + fclose(file); + std::filesystem::remove(output_path); + throw std::runtime_error("HTTP download error: " + + std::to_string(http_code)); + } + } + catch (...) + { + fclose(file); + std::filesystem::remove(output_path); + throw; + } + + fclose(file); + } + +} diff --git a/recompui/src/composites/ui_mod_marketplace_ui.cpp b/recompui/src/composites/ui_mod_marketplace_ui.cpp new file mode 100644 index 0000000..a671814 --- /dev/null +++ b/recompui/src/composites/ui_mod_marketplace_ui.cpp @@ -0,0 +1,414 @@ +#include "json/json.hpp" +#include "ui_mod_marketplace.h" +#include "librecomp/game.hpp" +#include "recompui/recompui.h" +#include "ui_utils.h" + +extern std::vector supported_games; + +namespace recompui +{ + // Gets the URL for the marketplace so we can determine if we have to render the + // button for the UI + std::string get_marketplace_url() + { + return supported_games.empty() ? "" : supported_games[0].marketplace_url; + } + + // Each Mod Entry on the UI + ModMarketplaceEntry::ModMarketplaceEntry(Element *parent, + const MarketplaceMod &mod_data) + : Element(parent, Events(EventType::Click)), mod_data(mod_data) + { + ContextId context = get_current_context(); + + entry_container = context.create_element( + this, FlexDirection::Row, JustifyContent::FlexStart); + entry_container->set_width(100.0f, Unit::Percent); + entry_container->set_padding(16.0f); + entry_container->set_margin_bottom(12.0f); + entry_container->set_background_color(Color{26, 24, 32, 255}); + entry_container->set_border_radius(8.0f); + entry_container->set_border_width(1.0f); + entry_container->set_border_color(Color{242, 242, 242, 64}); + + thumbnail_image = context.create_element(entry_container, ""); + thumbnail_image->set_width(80.0f, Unit::Px); + thumbnail_image->set_height(80.0f, Unit::Px); + thumbnail_image->set_background_color(Color{190, 184, 219, 100}); + thumbnail_image->set_border_radius(4.0f); + thumbnail_image->set_margin_right(16.0f); + + // if we actually have a thumbnail, check if its base64 and if we need to + // decode it or not. + if (!mod_data.thumbnail_image.empty()) + { + if (mod_data.thumbnail_image.starts_with("data:image/")) + { + std::string thumbnail_src = "marketplace_thumb_" + mod_data.id; + std::vector image_data = decode_base64(mod_data.thumbnail_image); + if (!image_data.empty()) + { + recompui::queue_image_from_bytes_file(thumbnail_src, image_data); + thumbnail_image->set_src(thumbnail_src); + } + } + else + { + thumbnail_image->set_src(mod_data.thumbnail_image); + } + } + + Container *content_container = context.create_element( + entry_container, FlexDirection::Column, JustifyContent::FlexStart); + content_container->set_flex(1.0f, 1.0f); + content_container->set_gap(8.0f); + + name_label = context.create_element