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_discovery.h b/recompui/src/composites/ui_mod_discovery.h new file mode 100644 index 0000000..62d49c0 --- /dev/null +++ b/recompui/src/composites/ui_mod_discovery.h @@ -0,0 +1,141 @@ +#ifndef RECOMPUI_MOD_DISCOVERY_H +#define RECOMPUI_MOD_DISCOVERY_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 "elements/ui_text_input.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace recompui +{ + + std::string get_discovery_url(); + void curl_global_initialize(); + std::string http_fetch_string(const std::string &url); + std::vector http_fetch_bytes(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 DiscoveryMod + { + std::string name; + std::string short_description; + std::string file_url; + std::string thumbnail_image; + std::string thumbnail_url; + std::string version; + std::string id; + std::string game_id; + std::vector dependencies; + }; + + class ModDiscoveryEntry : public Element + { + public: + DiscoveryMod mod_data; + ModDiscoveryEntry(ResourceId rid, Element *parent, const DiscoveryMod &mod_data); + virtual ~ModDiscoveryEntry(); + 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 "ModDiscoveryEntry"; } + + private: + void init_thumbnail_image(); + + Container *entry_container = nullptr; + Image *thumbnail_image = nullptr; + Label *name_label = nullptr; + Label *description_label = nullptr; + Button *download_button = nullptr; + std::string thumbnail_src; + std::function download_callback; + }; + + class ModDownloadsPanel : public Element + { + public: + ModDownloadsPanel(ResourceId rid, Element *parent); + virtual ~ModDownloadsPanel(); + void show(); + void hide(); + void fetch_discovery_data(); + + protected: + std::string_view get_type_name() override { return "ModDownloadsPanel"; } + + private: + void load_discovery_mods(const std::vector &mods); + void refresh_discovery_mods(); + void download_mod(const DiscoveryMod &mod); + std::string fetch_json_from_url(const std::string &url); + std::vector + parse_discovery_json(const std::string &json_data); + void download_file_from_url(const std::string &url, + const std::string &output_path); + ModInstallStatus get_mod_install_status(const DiscoveryMod &mod); + bool install_single_mod_file(const DiscoveryMod &mod, + std::vector &out_errors); + void resolve_and_install_dependencies( + const DiscoveryMod &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; + TextInput *search_input = nullptr; + Button *sort_name_button = nullptr; + ScrollContainer *mod_list_container = nullptr; + Button *refresh_button = nullptr; + Button *close_button = nullptr; + std::vector mod_entries; + std::vector fetched_mods; + std::string name_search_query; + bool sort_name_ascending = true; + std::string fetch_error; + bool thumbnail_viewport_dirty = false; + int thumbnail_viewport_retry_frames = 0; + 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_discovery_downloader.cpp b/recompui/src/composites/ui_mod_discovery_downloader.cpp new file mode 100644 index 0000000..1088a99 --- /dev/null +++ b/recompui/src/composites/ui_mod_discovery_downloader.cpp @@ -0,0 +1,326 @@ +#include "librecomp/mods.hpp" +#include "recompui/recompui.h" +#include "ui_mod_installer.h" +#include "ui_mod_discovery.h" +#include +#include + +namespace recompui +{ + + // Download and install a mod from the discovery + bool ModDownloadsPanel::install_single_mod_file( + const DiscoveryMod &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 DiscoveryMod &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; + + // 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. + auto installed_dep_details = recomp::mods::get_details_for_mod(dep_id); + if (installed_dep_details.has_value() && !required_version.empty()) + { + std::string installed_ver = installed_dep_details->version.to_string(); + 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 discovery just warn the user we can't install it. + auto dep_it = std::find_if(fetched_mods.begin(), fetched_mods.end(), + [&](const DiscoveryMod &m) { return m.id == dep_id; }); + const DiscoveryMod *dep_mod = (dep_it != fetched_mods.end()) ? &*dep_it : nullptr; + if (!dep_mod) + { + out_warnings.push_back( + "Dependency '" + dep_id + "' required by '" + mod.name + + "' was not found in the discovery and must be installed manually."); + continue; + } + + // If its on the discovery 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 discovery 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 discovery 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 (ModDiscoveryEntry *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 DiscoveryMod &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 + "..."); + + ModDiscoveryEntry *downloading_entry = nullptr; + for (ModDiscoveryEntry *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; + 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 (ModDiscoveryEntry *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); + entry->queue_update(); + } + } + + 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())); + } + } + + // Wrapper that checks all our installed mod status' + ModInstallStatus + ModDownloadsPanel::get_mod_install_status(const DiscoveryMod &mod) + { + auto details = recomp::mods::get_details_for_mod(mod.id); + if (!details.has_value()) + { + // Check whether all dependencies are satisfiable before declaring NotInstalled + bool satisfiable = true; + for (const std::string &dep_str : mod.dependencies) + { + auto [dep_id, required_version] = parse_dep_string(dep_str); + if (dep_id.empty()) + continue; + + auto 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; + } + + auto dep_it = std::find_if(fetched_mods.begin(), fetched_mods.end(), + [&](const DiscoveryMod &m) { return m.id == dep_id; }); + const DiscoveryMod *dep_mod = (dep_it != fetched_mods.end()) ? &*dep_it : nullptr; + if (!dep_mod || (!required_version.empty() && !dep_mod->version.empty() && + compare_versions(dep_mod->version, required_version) < 0)) + { + satisfiable = false; + break; + } + } + return satisfiable ? ModInstallStatus::NotInstalled : ModInstallStatus::MissingDependencies; + } + + std::string installed_version = details->version.to_string(); + 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; + } + +} \ No newline at end of file diff --git a/recompui/src/composites/ui_mod_discovery_http.cpp b/recompui/src/composites/ui_mod_discovery_http.cpp new file mode 100644 index 0000000..c312734 --- /dev/null +++ b/recompui/src/composites/ui_mod_discovery_http.cpp @@ -0,0 +1,209 @@ +#include "ui_mod_discovery.h" + +#include +#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); + } + + static size_t write_bytes_callback(void *contents, size_t size, size_t nmemb, + std::vector *out) + { + const size_t byte_count = size * nmemb; + const char *bytes = static_cast(contents); + out->insert(out->end(), bytes, bytes + byte_count); + return byte_count; + } + + static std::string append_cache_busting_query(const std::string &url) + { + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + return url + (url.find('?') == std::string::npos ? "?" : "&") + + "t=" + std::to_string(now); + } + + static curl_slist *make_no_cache_headers() + { + 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"); + return headers; + } + + 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; + + std::string busted_url = append_cache_busting_query(url); + + 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 = make_no_cache_headers(); + 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; + } + + std::vector http_fetch_bytes(const std::string &url) + { + CurlSession session; + std::vector response; + + std::string busted_url = append_cache_busting_query(url); + + curl_easy_setopt(session.handle, CURLOPT_URL, busted_url.c_str()); + curl_easy_setopt(session.handle, CURLOPT_WRITEFUNCTION, + write_bytes_callback); + curl_easy_setopt(session.handle, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(session.handle, CURLOPT_TIMEOUT, 30L); + + struct curl_slist *headers = make_no_cache_headers(); + 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_discovery_ui.cpp b/recompui/src/composites/ui_mod_discovery_ui.cpp new file mode 100644 index 0000000..57f0abd --- /dev/null +++ b/recompui/src/composites/ui_mod_discovery_ui.cpp @@ -0,0 +1,532 @@ +#include "../../../lib/N64ModernRuntime/N64Recomp/lib/tomlplusplus/vendor/json.hpp" +#include "./ui_mod_discovery.h" +#include "librecomp/game.hpp" +#include "recompui/recompui.h" +#include "ui_utils.h" + +#include +#include +#include + +extern std::vector supported_games; + +namespace recompui +{ + // Gets the URL for the discovery so we can determine if we have to render the + // button for the UI + std::string get_discovery_url() + { + return supported_games.empty() ? "" : supported_games[0].discovery_url; + } + + // Each Mod Entry on the UI + ModDiscoveryEntry::ModDiscoveryEntry(ResourceId rid, Element *parent, + const DiscoveryMod &mod_data) + : Element(rid, 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_min_width(80.0f); + thumbnail_image->set_min_height(80.0f); + thumbnail_image->set_max_width(80.0f); + thumbnail_image->set_max_height(80.0f); + thumbnail_image->set_background_color(Color{190, 184, 219, 100}); + thumbnail_image->set_border_radius(4.0f); + thumbnail_image->set_margin_right(16.0f); + + init_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