diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a32d38..9fc7629 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -175,6 +175,7 @@ set (SOURCES ${CMAKE_SOURCE_DIR}/src/ui/ui_rml_hacks.cpp ${CMAKE_SOURCE_DIR}/src/ui/ui_elements.cpp ${CMAKE_SOURCE_DIR}/src/ui/ui_mod_details_panel.cpp + ${CMAKE_SOURCE_DIR}/src/ui/ui_mod_installer.cpp ${CMAKE_SOURCE_DIR}/src/ui/ui_mod_menu.cpp ${CMAKE_SOURCE_DIR}/src/ui/ui_api.cpp ${CMAKE_SOURCE_DIR}/src/ui/ui_api_events.cpp diff --git a/include/recomp_ui.h b/include/recomp_ui.h index acae932..43e9b46 100644 --- a/include/recomp_ui.h +++ b/include/recomp_ui.h @@ -114,6 +114,8 @@ namespace recompui { void queue_image_from_bytes_rgba32(const std::string &src, const std::vector &bytes, uint32_t width, uint32_t height); void queue_image_from_bytes_file(const std::string &src, const std::vector &bytes); void release_image(const std::string &src); + + void drop_files(const std::list &file_list); } #endif diff --git a/src/game/input.cpp b/src/game/input.cpp index 7384e37..1c6984b 100644 --- a/src/game/input.cpp +++ b/src/game/input.cpp @@ -42,6 +42,10 @@ static struct { bool rumble_active; } InputState; +static struct { + std::list files_dropped; +} DropState; + std::atomic scanning_device = recomp::InputDevice::COUNT; std::atomic scanned_input; @@ -276,6 +280,18 @@ bool sdl_event_filter(void* userdata, SDL_Event* event) { InputState.pending_mouse_delta[0] += motion_event->xrel; InputState.pending_mouse_delta[1] += motion_event->yrel; } + queue_if_enabled(event); + break; + case SDL_EventType::SDL_DROPBEGIN: + DropState.files_dropped.clear(); + break; + case SDL_EventType::SDL_DROPFILE: + DropState.files_dropped.emplace_back(std::filesystem::path(std::u8string_view((const char8_t *)(event->drop.file)))); + SDL_free(event->drop.file); + break; + case SDL_EventType::SDL_DROPCOMPLETE: + recompui::drop_files(DropState.files_dropped); + break; default: queue_if_enabled(event); break; diff --git a/src/ui/ui_mod_installer.cpp b/src/ui/ui_mod_installer.cpp new file mode 100644 index 0000000..2e1f52a --- /dev/null +++ b/src/ui/ui_mod_installer.cpp @@ -0,0 +1,299 @@ +#include "ui_mod_installer.h" + +#include "librecomp/mods.hpp" + +namespace recompui { + static const std::string ManifestFilename = "mod.json"; + static const char *TextureDatabaseFilename = "rt64.json"; + static const std::u8string OldExtension = u8".old"; + static const std::u8string NewExtension = u8".new"; + + size_t zip_write_func(void *opaque, mz_uint64 offset, const void *bytes, size_t count) { + std::ofstream &stream = *(std::ofstream *)(opaque); + stream.seekp(offset, std::ios::beg); + stream.write((const char *)(bytes), count); + return stream.bad() ? 0 : count; + } + + void start_single_mod_installation(const std::filesystem::path &file_path, recomp::mods::ZipModFileHandle &file_handle, std::function progress_callback, ModInstaller::Result &result) { + // Check for the existence of the manifest file. + std::filesystem::path mods_directory = recomp::mods::get_mods_directory(); + std::filesystem::path target_path = mods_directory / file_path.filename(); + std::filesystem::path target_write_path = target_path.u8string() + NewExtension; + ModInstaller::Installation installation; + bool exists = false; + std::vector manifest_bytes = file_handle.read_file(ManifestFilename, exists); + if (exists) { + // Parse the manifest file to check for its validity. + std::string error; + recomp::mods::ModManifest manifest; + recomp::mods::ModOpenError open_error = parse_manifest(manifest, manifest_bytes, error); + exists = (open_error == recomp::mods::ModOpenError::Good); + + if (exists) { + installation.mod_id = manifest.mod_id; + installation.display_name = manifest.display_name; + installation.mod_version = manifest.version; + installation.mod_files.emplace_back(target_path); + } + } + else if (file_path.extension() == ".rtz") { + // When it's an rtz file, check if the texture database file exists. + exists = mz_zip_reader_locate_file(file_handle.archive.get(), TextureDatabaseFilename, nullptr, 0) >= 0; + + if (exists) { + installation.mod_id = std::string((const char *)(target_path.stem().u8string().c_str())); + installation.display_name = installation.mod_id; + installation.mod_version = recomp::Version(); + installation.mod_files.emplace_back(target_path); + } + } + + std::error_code ec; + if (exists) { + std::filesystem::copy(file_path, target_path, ec); + if (ec) { + result.error_messages.emplace_back(std::format("Unable to copy to {}.", target_write_path.string())); + return; + } + } + else { + result.error_messages.emplace_back(std::format("Unable to install {} as it does not seem to be a mod.", file_path.string())); + std::filesystem::remove(target_write_path, ec); + return; + } + + for (const std::filesystem::path &path : installation.mod_files) { + if (std::filesystem::exists(path, ec)) { + installation.needs_overwrite_confirmation = true; + break; + } + } + + result.pending_installations.emplace_back(installation); + } + + void start_package_mod_installation(recomp::mods::ZipModFileHandle &file_handle, std::function progress_callback, ModInstaller::Result &result) { + std::error_code ec; + char filename[1024]; + std::filesystem::path mods_directory = recomp::mods::get_mods_directory(); + mz_zip_archive *zip_archive = file_handle.archive.get(); + mz_uint num_files = mz_zip_reader_get_num_files(file_handle.archive.get()); + std::list dynamic_lib_files; + std::list::iterator first_nrm_iterator = result.pending_installations.end(); + for (mz_uint i = 0; i < num_files; i++) { + mz_uint filename_length = mz_zip_reader_get_filename(zip_archive, i, filename, sizeof(filename)); + if (filename_length == 0) { + continue; + } + + std::filesystem::path target_path = mods_directory / std::u8string_view((const char8_t *)(filename)); + if ((target_path.extension() == ".rtz") || (target_path.extension() == ".nrm")) { + ModInstaller::Installation installation; + std::filesystem::path target_write_path = target_path.u8string() + NewExtension; + std::ofstream output_stream(target_write_path, std::ios::binary); + if (!output_stream.is_open()) { + result.error_messages.emplace_back(std::format("Unable to open {} for writing.", target_write_path.string())); + continue; + } + + if (!mz_zip_reader_extract_to_callback(zip_archive, i, &zip_write_func, &output_stream, 0)) { + output_stream.close(); + std::filesystem::remove(target_write_path, ec); + result.error_messages.emplace_back(std::format("Unable to extract to {}.", target_write_path.string())); + continue; + } + + output_stream.close(); + if (output_stream.bad()) { + std::filesystem::remove(target_write_path, ec); + result.error_messages.emplace_back(std::format("Unable to write to {}.", target_write_path.string())); + continue; + } + + // Try to load the extracted file as a mod file handle. + recomp::mods::ModOpenError open_error; + recomp::mods::ZipModFileHandle extracted_file_handle(target_write_path, open_error); + if (open_error != recomp::mods::ModOpenError::Good) { + result.error_messages.emplace_back(std::format("Unable to open {}.", target_write_path.string())); + std::filesystem::remove(target_write_path, ec); + continue; + } + + // Check for the existence of the manifest file. + bool exists = false; + std::vector manifest_bytes = extracted_file_handle.read_file(ManifestFilename, exists); + if (exists) { + // Parse the manifest file to check for its validity. + std::string error; + recomp::mods::ModManifest manifest; + open_error = parse_manifest(manifest, manifest_bytes, error); + exists = (open_error == recomp::mods::ModOpenError::Good); + + if (exists) { + installation.mod_id = manifest.mod_id; + installation.display_name = manifest.display_name; + installation.mod_version = manifest.version; + installation.mod_files.emplace_back(target_path); + } + } + else if (target_path.extension() == ".rtz") { + // When it's an rtz file, check if the texture database file exists. + exists = mz_zip_reader_locate_file(extracted_file_handle.archive.get(), TextureDatabaseFilename, nullptr, 0) >= 0; + + if (exists) { + installation.mod_id = std::string((const char *)(target_path.stem().u8string().c_str())); + installation.display_name = installation.mod_id; + installation.mod_version = recomp::Version(); + installation.mod_files.emplace_back(target_path); + } + } + + if (!exists) { + result.error_messages.emplace_back(std::format("Unable to install {} as it does not seem to be a mod.", target_path.filename().string())); + std::filesystem::remove(target_write_path, ec); + continue; + } + + for (const std::filesystem::path &path : installation.mod_files) { + if (std::filesystem::exists(path, ec)) { + installation.needs_overwrite_confirmation = true; + break; + } + } + + result.pending_installations.emplace_back(installation); + + // Store the first nrm found for any dynamic libraries that might be found. + if ((first_nrm_iterator == result.pending_installations.end()) && (target_path.extension() == ".nrm")) { + first_nrm_iterator = std::prev(result.pending_installations.end()); + } + } +#if defined(_WIN32) + else if (target_path.extension() == ".dll") { +#elif defined(__linux__) + else if (target_path.extension() == ".so") { +#elif defined(__APPLE__) + else if (target_path.extension() == ".dylib") { +#else + static_assert(false, "Unimplemented for this platform."); { +#endif + std::filesystem::path target_write_path = target_path.u8string() + NewExtension; + std::ofstream output_stream(target_write_path, std::ios::binary); + if (!output_stream.is_open()) { + result.error_messages.emplace_back(std::format("Unable to open {} for writing.", target_write_path.string())); + continue; + } + + if (!mz_zip_reader_extract_to_callback(zip_archive, i, &zip_write_func, &output_stream, 0)) { + output_stream.close(); + std::filesystem::remove(target_write_path, ec); + result.error_messages.emplace_back(std::format("Unable to extract to {}.", target_write_path.string())); + continue; + } + + output_stream.close(); + if (output_stream.bad()) { + std::filesystem::remove(target_write_path, ec); + result.error_messages.emplace_back(std::format("Unable to write to {}.", target_write_path.string())); + continue; + } + + dynamic_lib_files.emplace_back(target_write_path); + } + } + + if (!dynamic_lib_files.empty()) { + if (first_nrm_iterator != result.pending_installations.end()) { + // Associate all these files to the first mod that is found. + for (const std::filesystem::path &path : dynamic_lib_files) { + first_nrm_iterator->mod_files.emplace_back(path); + + // Run verification against for overwrite confirmations. + for (const std::filesystem::path &path : first_nrm_iterator->mod_files) { + if (std::filesystem::exists(path, ec)) { + first_nrm_iterator->needs_overwrite_confirmation = true; + break; + } + } + } + } + else { + // These library files were not required by any mod, just delete them. + for (const std::filesystem::path &path : dynamic_lib_files) { + std::filesystem::path new_path(path.u8string() + NewExtension); + std::filesystem::remove(new_path, ec); + } + } + } + } + + void ModInstaller::start_mod_installation(const std::list &file_paths, std::function progress_callback, Result &result) { + result = Result(); + + for (const std::filesystem::path &path : file_paths) { + recomp::mods::ModOpenError open_error; + recomp::mods::ZipModFileHandle file_handle(path, open_error); + if (open_error != recomp::mods::ModOpenError::Good) { + result.error_messages = { std::format("File %s is not a valid container.", path.string()) }; + continue; + } + + // First we verify if the container itself isn't a mod already. + if ((path.extension() == ".rtz") || (path.extension() == ".nrm")) { + start_single_mod_installation(path, file_handle, progress_callback, result); + } + else { + // Scan the container for compatible mods instead. This is the case for packages made by users or how they're tipically uploaded to Thunderstore. + start_package_mod_installation(file_handle, progress_callback, result); + } + } + } + + void ModInstaller::finish_mod_installation(const std::unordered_set &confirmed_overwrites, Result &result) { + result.error_messages.clear(); + + std::error_code ec; + for (const Installation &installation : result.pending_installations) { + if (installation.needs_overwrite_confirmation && !confirmed_overwrites.contains(installation.mod_id)) { + // If the user hasn't confirmed this overwrite, simply delete all the files that were extracted for this mod. + for (const std::filesystem::path &path : installation.mod_files) { + std::filesystem::path new_path(path.u8string() + NewExtension); + std::filesystem::remove(new_path, ec); + } + + continue; + } + + for (const std::filesystem::path &path : installation.mod_files) { + std::filesystem::path old_path(path.u8string() + OldExtension); + std::filesystem::path new_path(path.u8string() + NewExtension); + + // Rename the current path to a temporary old path, but only if the current path already exists. + if (std::filesystem::exists(path, ec)) { + std::filesystem::remove(old_path, ec); + std::filesystem::rename(path, old_path, ec); + if (ec) { + // If it fails, remove the new path. + std::filesystem::remove(new_path, ec); + result.error_messages.emplace_back(std::format("Unable to rename {}.", path.string())); + continue; + } + } + + // Rename the new path to the current path. + std::filesystem::rename(new_path, path, ec); + if (ec) { + // If it fails, remove the new path and also restore the temporary old path to the current path. + std::filesystem::remove(new_path, ec); + std::filesystem::rename(old_path, path, ec); + result.error_messages.emplace_back(std::format("Unable to rename {}.", path.string())); + continue; + } + + // If nothing failed, just remove the temporary old path. + std::filesystem::remove(old_path, ec); + } + } + } +}; \ No newline at end of file diff --git a/src/ui/ui_mod_installer.h b/src/ui/ui_mod_installer.h new file mode 100644 index 0000000..7a9df6e --- /dev/null +++ b/src/ui/ui_mod_installer.h @@ -0,0 +1,28 @@ +#ifndef RECOMPUI_MOD_INSTALLER_H +#define RECOMPUI_MOD_INSTALLER_H + +#include + +#include + +namespace recompui { + struct ModInstaller { + struct Installation { + std::string mod_id; + std::string display_name; + recomp::Version mod_version; + std::list mod_files; + bool needs_overwrite_confirmation = false; + }; + + struct Result { + std::list error_messages; + std::list pending_installations; + }; + + static void start_mod_installation(const std::list &file_paths, std::function progress_callback, Result &result); + static void finish_mod_installation(const std::unordered_set &confirmed_overwrites, Result &result); + }; +}; + +#endif \ No newline at end of file diff --git a/src/ui/ui_state.cpp b/src/ui/ui_state.cpp index 0a04c57..b55bd24 100644 --- a/src/ui/ui_state.cpp +++ b/src/ui/ui_state.cpp @@ -23,6 +23,7 @@ #include "ui_rml_hacks.hpp" #include "ui_elements.h" #include "ui_mod_menu.h" +#include "ui_mod_installer.h" #include "ui_renderer.h" bool can_focus(Rml::Element* element) { @@ -833,3 +834,10 @@ void recompui::queue_image_from_bytes_rgba32(const std::string &src, const std:: void recompui::release_image(const std::string &src) { Rml::ReleaseTexture(src); } + +void recompui::drop_files(const std::list &file_list) { + // TODO: Needs a progress callback and a prompt for every mod that needs to be confirmed to be overwritten. + ModInstaller::Result result; + ModInstaller::start_mod_installation(file_list, nullptr, result); + ModInstaller::finish_mod_installation({}, result); +} \ No newline at end of file