From 212fca3e41dbe09918a2a6b6f73384c3f29f9edd Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 1 Sep 2026 23:06:56 -0600 Subject: [PATCH] Mod queue, install, drag-drop --- CMakeLists.txt | 11 +- extern/aurora | 2 +- extern/borealis | 2 +- files.cmake | 11 + res/rml/mod_browser.rcss | 156 ++++- res/rml/mods.rcss | 41 +- res/rml/window.rcss | 103 +++ src/dusk/archive.cpp | 145 ++++ src/dusk/archive.hpp | 39 ++ src/dusk/hash.hpp | 26 + src/dusk/iso_validate.hpp | 2 +- src/dusk/mod_loader.hpp | 65 +- src/dusk/mods/catalog.cpp | 51 +- src/dusk/mods/catalog.hpp | 29 +- src/dusk/mods/loader/bundle_zip.cpp | 57 +- src/dusk/mods/loader/loader.cpp | 679 +++++++++++++++--- src/dusk/mods/loader/loader.hpp | 12 +- src/dusk/mods/queue.cpp | 884 ++++++++++++++++++++++++ src/dusk/mods/queue.hpp | 81 +++ src/dusk/mods/svc/http.cpp | 23 +- src/dusk/mods/svc/registry.cpp | 12 +- src/dusk/ui/command_console.cpp | 2 +- src/dusk/ui/command_console.hpp | 4 +- src/dusk/ui/drop_install_modal.cpp | 158 +++++ src/dusk/ui/drop_install_modal.hpp | 40 ++ src/dusk/ui/mod_browser.cpp | 320 ++++++++- src/dusk/ui/mod_browser.hpp | 2 + src/dusk/ui/modal.cpp | 3 +- src/dusk/ui/mods_window.cpp | 275 ++++++-- src/dusk/ui/mods_window.hpp | 7 + src/dusk/ui/package_row.cpp | 121 ++++ src/dusk/ui/package_row.hpp | 32 + src/dusk/ui/prelaunch.cpp | 14 +- src/dusk/ui/queue_window.cpp | 196 ++++++ src/dusk/ui/queue_window.hpp | 23 + src/dusk/ui/remote_texture_provider.cpp | 44 +- src/dusk/ui/remote_texture_provider.hpp | 2 +- src/dusk/ui/runtime_image.cpp | 32 +- src/dusk/ui/runtime_image.hpp | 8 +- src/dusk/ui/settings.cpp | 2 +- src/dusk/ui/touch_controls.cpp | 73 +- src/dusk/ui/touch_controls.hpp | 8 +- src/dusk/ui/touch_controls_common.hpp | 2 +- src/dusk/ui/touch_controls_editor.cpp | 22 +- src/dusk/ui/touch_controls_editor.hpp | 16 +- src/dusk/ui/ui.cpp | 70 +- src/m_Do/m_Do_main.cpp | 11 +- 47 files changed, 3497 insertions(+), 421 deletions(-) create mode 100644 src/dusk/archive.cpp create mode 100644 src/dusk/archive.hpp create mode 100644 src/dusk/hash.hpp create mode 100644 src/dusk/mods/queue.cpp create mode 100644 src/dusk/mods/queue.hpp create mode 100644 src/dusk/ui/drop_install_modal.cpp create mode 100644 src/dusk/ui/drop_install_modal.hpp create mode 100644 src/dusk/ui/package_row.cpp create mode 100644 src/dusk/ui/package_row.hpp create mode 100644 src/dusk/ui/queue_window.cpp create mode 100644 src/dusk/ui/queue_window.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e81a7ce434..4a8362430a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -243,8 +243,15 @@ FetchContent_Declare(miniz DOWNLOAD_EXTRACT_TIMESTAMP TRUE EXCLUDE_FROM_ALL ) +message(STATUS "dusklight: Fetching PicoSHA2") +FetchContent_Declare(picosha2 + URL https://github.com/okdshin/PicoSHA2/archive/refs/tags/v1.0.1.tar.gz + URL_HASH SHA256=9983136544234e573fe07cc1a22fdf978ad7979043e7be2e9082f6d4991ff8a8 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + EXCLUDE_FROM_ALL +) -set(_fetch_content_deps miniz) +set(_fetch_content_deps miniz picosha2) if (DUSK_HAS_FUNCHOOK) message(STATUS "dusklight: Fetching funchook") # cmake/PatchFunchook.cmake patches funchook's cmake/capstone.cmake.in to inject a @@ -305,7 +312,7 @@ find_package(Threads REQUIRED) set(GAME_COMPILE_DEFS DUSK_BUILDING_GAME=1) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd aurora::card borealis::cli borealis::crash borealis::data borealis::disc borealis::discord borealis::file_select borealis::http borealis::io borealis::log borealis::presentation borealis::sentry borealis::update freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt - Threads::Threads zstd::libzstd dusklight_game_headers) + Threads::Threads zstd::libzstd dusklight_game_headers picosha2) if (DUSK_HAS_FUNCHOOK) list(APPEND GAME_LIBS funchook-static) endif () diff --git a/extern/aurora b/extern/aurora index f8573d34e6..f1189541e5 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit f8573d34e632aea81039526a728b34f373d247ef +Subproject commit f1189541e5d8b97fdf61946377853488d504d9df diff --git a/extern/borealis b/extern/borealis index 4b76f9ea35..539fd17b3c 160000 --- a/extern/borealis +++ b/extern/borealis @@ -1 +1 @@ -Subproject commit 4b76f9ea35eaa116a57fd732b1c4954b6f4d0316 +Subproject commit 539fd17b3cde8d246ee518f014d3edc58c89628a diff --git a/files.cmake b/files.cmake index c8f1470e5f..64d80b1eb2 100644 --- a/files.cmake +++ b/files.cmake @@ -1422,6 +1422,8 @@ set(DUSK_FILES src/dusk/achievements.cpp src/dusk/action_bindings.cpp src/dusk/action_bindings.h + src/dusk/archive.cpp + src/dusk/archive.hpp src/dusk/asserts.cpp src/dusk/autosave.cpp src/dusk/config.cpp @@ -1436,6 +1438,7 @@ set(DUSK_FILES src/dusk/commands.cpp src/dusk/commands.hpp src/dusk/game_clock.cpp + src/dusk/hash.hpp src/dusk/game_mode.cpp src/dusk/gamepad_color.cpp src/dusk/globals.cpp @@ -1484,6 +1487,8 @@ set(DUSK_FILES src/dusk/mods/loader/prepatch.hpp src/dusk/mods/catalog.cpp src/dusk/mods/catalog.hpp + src/dusk/mods/queue.cpp + src/dusk/mods/queue.hpp src/dusk/mods/item.hpp src/dusk/mods/item_checks.cpp src/dusk/mods/item_gives.cpp @@ -1545,6 +1550,8 @@ set(DUSK_FILES src/dusk/ui/controls.hpp src/dusk/ui/document.cpp src/dusk/ui/document.hpp + src/dusk/ui/drop_install_modal.cpp + src/dusk/ui/drop_install_modal.hpp src/dusk/ui/editor.cpp src/dusk/ui/editor.hpp src/dusk/ui/event.cpp @@ -1567,6 +1574,10 @@ set(DUSK_FILES src/dusk/ui/menu_bar.hpp src/dusk/ui/mod_browser.cpp src/dusk/ui/mod_browser.hpp + src/dusk/ui/queue_window.cpp + src/dusk/ui/queue_window.hpp + src/dusk/ui/package_row.cpp + src/dusk/ui/package_row.hpp src/dusk/ui/mod_texture_provider.cpp src/dusk/ui/mod_texture_provider.hpp src/dusk/ui/remote_texture_provider.cpp diff --git a/res/rml/mod_browser.rcss b/res/rml/mod_browser.rcss index 22186313e3..8dd934d6ed 100644 --- a/res/rml/mod_browser.rcss +++ b/res/rml/mod_browser.rcss @@ -368,12 +368,137 @@ catalog-source-actions button { line-height: 1; } +button.catalog-install-action { + display: flex; + position: relative; + align-items: center; + justify-content: center; + gap: 10dp; + min-width: 132dp; + padding: 8dp 22dp; + border-radius: var(--radius-window); + overflow: hidden; + font-size: var(--font-size-2xl); + opacity: 1; + --button-background: rgba(var(--color-control-rgb), 20%); + --button-background-hover: rgba(var(--color-interactive-rgb), 20%); + --button-background-selected: rgba(var(--color-interactive-rgb), 20%); + --button-background-active: rgba(var(--color-interactive-rgb), 20%); + box-shadow: var(--color-accent) 0 0 0 2dp; +} + +button.catalog-install-action icon { + flex: 0 0 22dp; + font-size: 22dp; + line-height: 1; +} + +button.catalog-install-action catalog-action-label { + white-space: nowrap; +} + +button.catalog-install-action.idle { + --button-background: rgba(var(--color-interactive-rgb), 40%); + --button-background-hover: rgba(var(--color-interactive-rgb), 55%); + --button-background-selected: rgba(var(--color-interactive-rgb), 55%); + --button-background-active: rgba(var(--color-interactive-rgb), 55%); +} + +button.catalog-install-action.paused { + box-shadow: rgba(var(--color-border-rgb), 25%) 0 0 0 1dp; +} + +button.catalog-install-action.paused:not(:disabled):hover, +button.catalog-install-action.paused:not(:disabled):focus-visible { + box-shadow: var(--color-accent) 0 0 0 2dp; +} + +button.catalog-install-action progress { + position: absolute; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 4dp; + margin: 0; + border-radius: 0; + overflow: hidden; + background-color: rgba(var(--color-white-rgb), 10%); +} + +button.catalog-install-action progress fill { + border-radius: 0; + background-color: rgba(var(--color-accent-rgb), 80%); +} + +button.catalog-install-action.paused progress fill { + background-color: rgba(var(--color-text-rgb), 35%); +} + +button.catalog-install-action.retrying { + color: var(--color-warning); + box-shadow: rgba(255, 168, 38, 60%) 0 0 0 2dp; +} + +button.catalog-install-action.retrying progress fill { + background-color: rgba(255, 168, 38, 60%); +} + +button.catalog-install-action.failed { + color: var(--color-white); + --button-background: rgba(133, 34, 33, 20%); + --button-background-hover: rgba(133, 34, 33, 35%); + --button-background-selected: rgba(133, 34, 33, 35%); + --button-background-active: rgba(133, 34, 33, 35%); + box-shadow: #B3261E 0 0 0 2dp; +} + +button.catalog-install-action.failed progress fill { + background-color: rgba(179, 38, 30, 70%); +} + +button.catalog-install-action.installed { + color: var(--color-success); + box-shadow: rgba(68, 204, 85, 50%) 0 0 0 2dp; +} + +button.catalog-install-action.installed:disabled { + opacity: 1; +} + +button.catalog-install-action.installing progress fill { + background-color: #2255BB; +} + +catalog-install-control { + display: flex; + flex-flow: column; + align-items: flex-end; + flex: 0 0 auto; + gap: var(--space-xs); +} + +catalog-install-caption { + display: block; + max-width: 240dp; + overflow: hidden; + font-size: var(--font-size-xs); + color: rgba(var(--color-text-rgb), 50%); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +catalog-install-caption.failed { + color: #B3261E; +} + catalog-detail-identity { display: flex; position: relative; z-index: 1; - align-items: center; - gap: 14dp; + align-items: flex-end; + gap: var(--space-lg); } catalog-detail-icon { @@ -395,6 +520,7 @@ catalog-detail-icon-image { catalog-detail-heading { display: block; + flex: 1 1 auto; min-width: 0; } @@ -782,10 +908,36 @@ catalog-screenshot-actions button { height: 56dp; } + catalog-detail-identity { + gap: var(--space-md); + } + catalog-detail-category { font-size: var(--font-size-2xs); } + button.catalog-install-action { + gap: var(--space-sm); + min-width: 112dp; + padding: 7dp 18dp; + border-radius: 12dp; + font-size: var(--font-size-md); + } + + button.catalog-install-action icon { + flex-basis: 18dp; + font-size: var(--font-size-xl); + } + + catalog-install-control { + gap: 3dp; + } + + catalog-install-caption { + max-width: 190dp; + font-size: var(--font-size-3xs); + } + catalog-detail-identity h1 { font-size: var(--font-size-3xl); } diff --git a/res/rml/mods.rcss b/res/rml/mods.rcss index 81ef5800de..c815dc95c7 100644 --- a/res/rml/mods.rcss +++ b/res/rml/mods.rcss @@ -15,11 +15,42 @@ window.mods content pane.mod-detail { gap: var(--space-md); } -window.mods content pane.mod-list button.mod-browser-entry { - margin-bottom: var(--space-sm); - text-align: left; - font-family: var(--font-family-heading); - font-weight: bold; +mod-entry.mod-browser-entry, +mod-entry.mod-installs-entry { + min-height: 76dp; +} + +mod-entry icon.mod-icon.mod-browser-icon { + color: var(--color-accent); + decorator: text("" center center); +} + +mod-entry icon.mod-icon.mod-installs-icon { + color: var(--color-accent); + decorator: text("" center center); +} + +mod-entry.mod-installs-entry { + background-color: rgba(var(--color-control-rgb), 20%); + box-shadow: rgba(var(--color-border-rgb), 25%) 0 0 0 1dp; +} + +mod-entry.mod-installs-entry:hover, +mod-entry.mod-installs-entry:focus-visible { + background-color: rgba(var(--color-interactive-rgb), 12%); + box-shadow: var(--color-accent) 0 0 0 2dp; +} + +mod-entry.mod-installs-entry progress { + height: 6dp; + margin: var(--space-xs) 0 0 0; +} + +mod-list-separator { + display: block; + height: 1dp; + margin: var(--space-sm) 10dp; + background-color: rgba(var(--color-border-rgb), 30%); } mod-info-row { diff --git a/res/rml/window.rcss b/res/rml/window.rcss index c652396726..df429e2b50 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -630,6 +630,109 @@ modal-content pane > * { flex: 0 0 auto; } +window.modal.install-queue { + max-height: 720dp; +} + +window.modal.drop-install { + max-height: 720dp; +} + +window.modal.drop-install package-row { + padding-right: 0; +} + +window.modal.install-queue modal-body { + font-size: var(--font-size-sm); + color: rgba(var(--color-text-rgb), 60%); +} + +package-row { + display: flex; + flex-direction: column; + position: relative; + width: 100%; + gap: var(--space-2xs); + padding: var(--space-sm) 78dp var(--space-sm) 0; + border-bottom-width: 1dp; + border-bottom-color: rgba(var(--color-border-rgb), 25%); +} + +package-row-heading { + display: flex; + width: 100%; + gap: var(--space-sm); + align-items: center; +} + +package-row-name { + display: block; + flex: 1 1 auto; + min-width: 0; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +package-row-state { + display: block; + flex: 0 0 auto; + font-size: var(--font-size-xs); + color: rgba(var(--color-text-rgb), 60%); +} + +package-row.retrying package-row-state { + color: var(--color-warning); +} + +package-row.failed package-row-state, +package-row.failed package-row-detail { + color: var(--color-error); +} + +package-row.installed package-row-state, +package-row.installed package-row-detail { + color: var(--color-success); +} + +package-row progress { + height: 4dp; + margin: var(--space-2xs) 0; +} + +package-row.failed progress fill { + background-color: var(--color-error); +} + +package-row.installed progress fill { + background-color: var(--color-success); +} + +package-row-detail { + display: block; + min-width: 0; + font-size: var(--font-size-2xs); + color: rgba(var(--color-text-rgb), 48%); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +package-row-actions { + display: flex; + position: absolute; + right: 0; + top: 22dp; + gap: var(--space-2xs); +} + +package-row-actions button { + min-width: 34dp; + padding: 4dp 7dp; + font-size: var(--font-size-3xs); +} + verification-progress { display: flex; flex-direction: column; diff --git a/src/dusk/archive.cpp b/src/dusk/archive.cpp new file mode 100644 index 0000000000..a749d5432b --- /dev/null +++ b/src/dusk/archive.cpp @@ -0,0 +1,145 @@ +#include "archive.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace dusk::archive { +namespace { + +constexpr std::array ZipMagic{'P', 'K', '\x03', '\x04'}; + +PackageFormat detect_package_format(mz_zip_archive& zip) { + size_t modManifests = 0; + for (mz_uint index = 0, count = mz_zip_reader_get_num_files(&zip); index < count; ++index) { + mz_zip_archive_file_stat stat{}; + if (!mz_zip_reader_file_stat(&zip, index, &stat) || + mz_zip_reader_is_file_a_directory(&zip, index)) + { + continue; + } + const std::string_view name{stat.m_filename}; + modManifests += name == "mod.json"; + } + if (modManifests == 1) { + return PackageFormat::Mod; + } + return PackageFormat::Unknown; +} + +} // namespace + +struct ZipArchive::Impl { + ~Impl() { + if (open) { + mz_zip_reader_end(&zip); + } + } + + static size_t read_zip( + void* opaque, mz_uint64 offset, void* buffer, const size_t size) { + auto& archive = *static_cast(opaque); + std::error_code error; + return archive.file.read_at(offset, {static_cast(buffer), size}, error); + } + + borealis::io::RandomAccessFile file; + mz_zip_archive zip{}; + PackageFormat format = PackageFormat::Unknown; + bool open = false; + std::mutex mutex; +}; + +ZipArchive::ZipArchive(const std::filesystem::path& path) : m_impl{std::make_unique()} { + auto opened = borealis::io::RandomAccessFile::open(path); + if (opened.status != borealis::io::Status::Ok) { + throw std::runtime_error(opened.message); + } + m_impl->file = std::move(opened.file); + + std::array header{}; + std::error_code error; + const auto read = m_impl->file.read_at( + 0, {reinterpret_cast(header.data()), header.size()}, error); + if (error) { + throw std::runtime_error(fmt::format("Reading ZIP magic failed: {}", error.message())); + } + if (read != header.size() || header != ZipMagic) { + throw std::runtime_error("File does not have ZIP magic"); + } + + m_impl->zip.m_pRead = Impl::read_zip; + m_impl->zip.m_pIO_opaque = m_impl.get(); + if (!mz_zip_reader_init(&m_impl->zip, m_impl->file.size(), 0)) { + const auto zipError = mz_zip_get_last_error(&m_impl->zip); + throw std::runtime_error( + fmt::format("Opening ZIP failed: {}", mz_zip_get_error_string(zipError))); + } + m_impl->open = true; + m_impl->format = detect_package_format(m_impl->zip); +} + +ZipArchive::~ZipArchive() = default; + +ZipArchive::ZipArchive(ZipArchive&&) noexcept = default; +ZipArchive& ZipArchive::operator=(ZipArchive&&) noexcept = default; + +PackageFormat ZipArchive::package_format() const noexcept { + return m_impl->format; +} + +std::vector ZipArchive::read_file(const std::string_view name) { + std::lock_guard lock{m_impl->mutex}; + const std::string fileName{name}; + size_t size = 0; + void* extracted = mz_zip_reader_extract_file_to_heap(&m_impl->zip, fileName.c_str(), &size, 0); + if (extracted == nullptr) { + throw std::runtime_error(fmt::format("File does not exist: {}", name)); + } + + const std::unique_ptr owner{extracted, &mz_free}; + const std::span data{static_cast(owner.get()), size}; + std::vector result; + result.assign(data.begin(), data.end()); + return result; +} + +std::vector ZipArchive::file_names() { + std::lock_guard lock{m_impl->mutex}; + std::vector results; + for (mz_uint index = 0, count = mz_zip_reader_get_num_files(&m_impl->zip); index < count; + ++index) + { + mz_zip_archive_file_stat stat{}; + if (!mz_zip_reader_file_stat(&m_impl->zip, index, &stat) || + mz_zip_reader_is_file_a_directory(&m_impl->zip, index)) + { + continue; + } + results.emplace_back(stat.m_filename); + } + return results; +} + +size_t ZipArchive::file_size(const std::string_view name) { + std::lock_guard lock{m_impl->mutex}; + const std::string fileName{name}; + const auto index = mz_zip_reader_locate_file(&m_impl->zip, fileName.c_str(), nullptr, 0); + if (index < 0) { + throw std::runtime_error(fmt::format("Unable to locate file in ZIP: {}", name)); + } + + mz_zip_archive_file_stat stat{}; + if (!mz_zip_reader_file_stat(&m_impl->zip, static_cast(index), &stat)) { + throw std::runtime_error(fmt::format("Unable to inspect file in ZIP: {}", name)); + } + return static_cast(stat.m_uncomp_size); +} + +} // namespace dusk::archive diff --git a/src/dusk/archive.hpp b/src/dusk/archive.hpp new file mode 100644 index 0000000000..4fe3196199 --- /dev/null +++ b/src/dusk/archive.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::archive { + +enum class PackageFormat { + Unknown, + Mod, +}; + +/** File-backed ZIP reader shared by Dusklight package formats. */ +class ZipArchive { +public: + explicit ZipArchive(const std::filesystem::path& path); + ~ZipArchive(); + + ZipArchive(ZipArchive&&) noexcept; + ZipArchive& operator=(ZipArchive&&) noexcept; + ZipArchive(const ZipArchive&) = delete; + ZipArchive& operator=(const ZipArchive&) = delete; + + PackageFormat package_format() const noexcept; + std::vector read_file(std::string_view name); + std::vector file_names(); + size_t file_size(std::string_view name); + +private: + struct Impl; + std::unique_ptr m_impl; +}; + +} // namespace dusk::archive diff --git a/src/dusk/hash.hpp b/src/dusk/hash.hpp new file mode 100644 index 0000000000..da88aa5547 --- /dev/null +++ b/src/dusk/hash.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include +#include +#include + +namespace dusk::hash { + +class Sha256 { +public: + void update(std::span bytes) { + mHasher.process(bytes.begin(), bytes.end()); + } + + std::string finish() { + mHasher.finish(); + return picosha2::get_hash_hex_string(mHasher); + } + +private: + picosha2::hash256_one_by_one mHasher; +}; + +} // namespace dusk::hash diff --git a/src/dusk/iso_validate.hpp b/src/dusk/iso_validate.hpp index e5a8eac1ef..b7740b636f 100644 --- a/src/dusk/iso_validate.hpp +++ b/src/dusk/iso_validate.hpp @@ -38,7 +38,7 @@ using VerificationStatus = borealis::disc::Progress; struct DiscInfo { Platform platform = Platform::Unknown; Region region = Region::NorthAmerica; - std::uint8_t revision = 0; + uint8_t revision = 0; }; ValidationError inspect(const char* path, DiscInfo& info); diff --git a/src/dusk/mod_loader.hpp b/src/dusk/mod_loader.hpp index bf3e93aa53..98c0075290 100644 --- a/src/dusk/mod_loader.hpp +++ b/src/dusk/mod_loader.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -35,6 +36,7 @@ struct ModManifestInfo { struct Import { std::string id; uint16_t major = 0; + uint16_t minMinor = 0; bool required = false; bool operator==(const Import&) const = default; }; @@ -67,6 +69,25 @@ struct ModSearchDir { std::filesystem::path nativeLibDir; }; +enum class ModOrigin : u8 { + User, + Bundled, + BundledInPlace, +}; + +struct ModOperation { + enum class State : u8 { + Pending, + Succeeded, + Failed, + }; + + State state = State::Pending; + std::string message; +}; + +using ModOperationHandle = std::shared_ptr; + struct ModMetaParsed { uint32_t abiVersion = 0; std::vector imports; @@ -161,6 +182,14 @@ enum class NativeModStatus : u8 { }; struct LoadedMod { + struct FileIdentity { + std::uintmax_t size = 0; + std::filesystem::file_time_type modified{}; + bool valid = false; + + bool operator==(const FileIdentity&) const = default; + }; + ModMetadata metadata; std::filesystem::path modPath; std::filesystem::path dir; @@ -170,8 +199,10 @@ struct LoadedMod { std::string dataDirUtf8; uint32_t searchDirIndex = 0; + ModOrigin origin = ModOrigin::User; // Native lib is dlopen'd in place and stays resident for the session. Reload is unsupported. bool inPlace = false; + FileIdentity fileIdentity; std::unique_ptr> cvarIsEnabled; config::Subscription enabledSubscription = 0; @@ -226,9 +257,16 @@ public: void request_enable(std::string_view id); void request_disable(std::string_view id); - void request_reload(std::string_view id); + ModOperationHandle request_reload(std::string_view id); + ModOperationHandle request_install(std::filesystem::path path); + ModOperationHandle request_uninstall(std::string_view id); + ModOperationHandle request_reactivate(std::string_view id); void notify_mod_failure(LoadedMod& mod, bool firstFailure); + [[nodiscard]] std::filesystem::path user_mods_dir() const; + [[nodiscard]] bool can_uninstall(const LoadedMod& mod) const; + [[nodiscard]] uint64_t generation() const noexcept { return m_generation; } + [[nodiscard]] auto mods() const { return m_mods | std::views::transform([](const auto& m) -> LoadedMod& { return *m; }); } @@ -238,10 +276,19 @@ public: } private: - enum class RequestKind : u8 { Enable, Disable, Reload }; + enum class RequestKind : u8 { Enable, Disable, Sync, Install, Reactivate }; struct Request { std::string modId; RequestKind kind; + std::filesystem::path path; + std::shared_ptr operation; + bool force = false; + bool remove = false; + }; + struct SyncResult { + bool success = true; + std::string message; + LoadedMod* mod = nullptr; }; // ModLoader::tick runs inside fapGm_Execute, so code from an unloading mod can still be // live on the stack (its frame unwinds after the tick). dlclose is therefore deferred to @@ -257,10 +304,13 @@ private: std::vector m_pendingRequests; std::vector m_pendingFailures; std::vector m_retiredNatives; + uint64_t m_generation = 0; bool m_initialized = false; bool m_startupComplete = false; - void try_load_mod(const std::filesystem::path& modPath, bool fromDir, uint32_t searchDirIndex); + LoadedMod* try_load_mod(const std::filesystem::path& modPath, bool fromDir, + uint32_t searchDirIndex, std::unique_ptr bundle = {}, + std::optional metadata = {}); void load_native(LoadedMod& mod, const std::string& dllEntry, const std::vector& runtimeEntries); bool load_native_if_present(LoadedMod& mod); @@ -282,6 +332,10 @@ private: LoadedMod* find_mod(std::string_view id) const; void drain_retired_natives(); void apply_pending_requests(); + [[nodiscard]] SyncResult install_staged(const std::filesystem::path& path); + [[nodiscard]] SyncResult sync_path( + const std::filesystem::path& path, bool force, uint32_t searchDirIndex = 0); + void forget_mod(LoadedMod& mod); void flush_toasts(); void on_enabled_changed(LoadedMod& mod); // Deactivates `target` (if needed) and its transitive dependents, optionally re-reads the @@ -289,10 +343,15 @@ private: void apply_lifecycle_change(LoadedMod& target, bool reload); // `target` plus transitive active/suspended dependents, in m_mods (init) order. std::vector collect_lifecycle_set(LoadedMod& target); + void resume_lifecycle_set(const std::vector& mods); bool reload_bundle(LoadedMod& mod); bool ensure_native_loaded(LoadedMod& mod); }; +// Reads and validates mod.json without loading native code or changing loader state. +bool inspect_mod_bundle(const std::filesystem::path& path, ModMetadata& metadata, + std::string& error, bool* hasNative = nullptr) noexcept; + using ModIndex = std::ranges::range_difference_t().mods())>; } // namespace dusk::mods diff --git a/src/dusk/mods/catalog.cpp b/src/dusk/mods/catalog.cpp index bda411ca37..0e4bb4bc5c 100644 --- a/src/dusk/mods/catalog.cpp +++ b/src/dusk/mods/catalog.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -82,10 +83,8 @@ std::string url_encode(std::string_view value) { } void append_query(std::string& url, std::string_view name, std::string_view value) { - url.push_back(url.find('?') == std::string::npos ? '?' : '&'); - url.append(name); - url.push_back('='); - url.append(url_encode(value)); + fmt::format_to(std::back_inserter(url), "{}{}={}", + url.find('?') == std::string::npos ? '?' : '&', name, url_encode(value)); } std::string make_url(const Query& query) { @@ -138,23 +137,23 @@ bool required_bool(const json& object, const char* name) { return value.get(); } -std::uint64_t required_count(const json& object, const char* name) { +uint64_t required_count(const json& object, const char* name) { const auto& value = required_field(object, name); if (value.is_number_unsigned()) { - return value.get(); + return value.get(); } if (value.is_number_integer()) { - const auto count = value.get(); + const auto count = value.get(); if (count >= 0) { - return static_cast(count); + return static_cast(count); } } throw std::runtime_error{fmt::format("field '{}' is not a non-negative integer", name)}; } int required_int(const json& object, const char* name) { - const std::uint64_t value = required_count(object, name); - if (value > static_cast(std::numeric_limits::max())) { + const uint64_t value = required_count(object, name); + if (value > static_cast(std::numeric_limits::max())) { throw std::runtime_error{fmt::format("field '{}' is too large", name)}; } return static_cast(value); @@ -171,25 +170,25 @@ std::optional optional_string(const json& object, const char* name) return value.get(); } -std::uint16_t required_u16(const json& object, const char* name) { +uint16_t required_u16(const json& object, const char* name) { const auto value = required_count(object, name); - if (value > std::numeric_limits::max()) { + if (value > std::numeric_limits::max()) { throw std::runtime_error{fmt::format("field '{}' is too large", name)}; } - return static_cast(value); + return static_cast(value); } Image parse_image(const json& value) { const auto width = required_count(value, "width"); const auto height = required_count(value, "height"); - if (width > std::numeric_limits::max() || - height > std::numeric_limits::max()) + if (width > std::numeric_limits::max() || + height > std::numeric_limits::max()) { throw std::runtime_error{"image dimensions are too large"}; } Image image{ - .width = static_cast(width), - .height = static_cast(height), + .width = static_cast(width), + .height = static_cast(height), }; const auto& sources = required_field(value, "sources"); if (!sources.is_array()) { @@ -198,11 +197,11 @@ Image parse_image(const json& value) { image.sources.reserve(sources.size()); for (const auto& source : sources) { const auto sourceWidth = required_count(source, "width"); - if (sourceWidth > std::numeric_limits::max()) { + if (sourceWidth > std::numeric_limits::max()) { throw std::runtime_error{"image source width is too large"}; } image.sources.push_back({ - .width = static_cast(sourceWidth), + .width = static_cast(sourceWidth), .pngUrl = required_string(source, "png_url"), }); } @@ -307,13 +306,23 @@ Detail parse_detail(std::string_view body) { .packageSha256 = required_string(root, "package_sha256"), }; + const auto& download = required_field(root, "download"); + if (!download.is_object()) { + throw std::runtime_error{"field 'download' is not an object"}; + } + detail.download = { + .url = required_string(download, "url"), + .sha256 = required_string(download, "sha256"), + .size = required_count(download, "size"), + }; + const auto& modAbi = required_field(root, "mod_abi"); if (!modAbi.is_null()) { const auto value = required_count(root, "mod_abi"); - if (value > std::numeric_limits::max()) { + if (value > std::numeric_limits::max()) { throw std::runtime_error{"field 'mod_abi' is too large"}; } - detail.modAbi = static_cast(value); + detail.modAbi = static_cast(value); } const auto& banner = required_field(root, "banner"); diff --git a/src/dusk/mods/catalog.hpp b/src/dusk/mods/catalog.hpp index b819525d2b..18bd335124 100644 --- a/src/dusk/mods/catalog.hpp +++ b/src/dusk/mods/catalog.hpp @@ -28,7 +28,7 @@ struct Query { struct Category { std::string slug; std::string name; - std::uint64_t modCount = 0; + uint64_t modCount = 0; }; struct Tag { @@ -43,13 +43,13 @@ struct Author { }; struct ImageSource { - std::uint32_t width = 0; + uint32_t width = 0; std::string pngUrl; }; struct Image { - std::uint32_t width = 0; - std::uint32_t height = 0; + uint32_t width = 0; + uint32_t height = 0; std::vector sources; }; @@ -61,11 +61,11 @@ struct Mod { std::string summary; std::optional category; std::vector tags; - std::uint64_t downloads = 0; - std::uint64_t endorsements = 0; + uint64_t downloads = 0; + uint64_t endorsements = 0; std::string publishedAt; std::string updatedAt; - std::uint64_t packageSize = 0; + uint64_t packageSize = 0; bool containsNativeCode = false; std::vector supportedPlatforms; std::optional icon; @@ -79,11 +79,17 @@ struct Screenshot { struct ServiceImport { std::string id; - std::uint16_t major = 0; - std::uint16_t minMinor = 0; + uint16_t major = 0; + uint16_t minMinor = 0; bool optional = false; }; +struct Download { + std::string url; + std::string sha256; + uint64_t size = 0; +}; + struct Detail { Mod mod; std::string slug; @@ -93,7 +99,8 @@ struct Detail { std::string descriptionHtml; std::string changelogHtml; std::string packageSha256; - std::optional modAbi; + Download download; + std::optional modAbi; std::optional banner; std::vector screenshots; std::vector serviceImports; @@ -103,7 +110,7 @@ struct Pagination { int page = 1; int pageSize = 0; int pageCount = 0; - std::uint64_t total = 0; + uint64_t total = 0; }; struct Page { diff --git a/src/dusk/mods/loader/bundle_zip.cpp b/src/dusk/mods/loader/bundle_zip.cpp index 517c5943c2..630fa3ae97 100644 --- a/src/dusk/mods/loader/bundle_zip.cpp +++ b/src/dusk/mods/loader/bundle_zip.cpp @@ -1,68 +1,25 @@ -#include "fmt/format.h" #include "loader.hpp" -#include +#include namespace dusk::mods { -ModBundleZip::ModBundleZip(std::vector&& data) : zip_data(std::move(data)) { - if (!mz_zip_reader_init_mem(&res_zip, zip_data.data(), zip_data.size(), 0)) { - const auto error = mz_zip_get_last_error(&res_zip); - throw std::runtime_error( - fmt::format("Opening zip failed: {}", mz_zip_get_error_string(error))); +ModBundleZip::ModBundleZip(const std::filesystem::path& path) : m_archive{path} { + if (m_archive.package_format() != archive::PackageFormat::Mod) { + throw std::runtime_error("Archive is not a valid mod package"); } } -ModBundleZip::~ModBundleZip() { - mz_zip_reader_end(&res_zip); -} - std::vector ModBundleZip::readFile(const std::string& fileName) { - std::lock_guard lock{m_mutex}; - size_t size; - const auto ptr = mz_zip_reader_extract_file_to_heap(&res_zip, fileName.c_str(), &size, 0); - - if (!ptr) { - throw std::runtime_error(fmt::format("File does not exist: {}", fileName)); - } - - std::span data(static_cast(ptr), size); - std::vector vec(data.begin(), data.end()); - - mz_free(ptr); - - return vec; + return m_archive.read_file(fileName); } std::vector ModBundleZip::getFileNames() { - std::lock_guard lock{m_mutex}; - std::vector results; - - for (mz_uint i = 0, n = mz_zip_reader_get_num_files(&res_zip); i < n; ++i) { - mz_zip_archive_file_stat stat{}; - if (!mz_zip_reader_file_stat(&res_zip, i, &stat)) { - continue; - } - if (mz_zip_reader_is_file_a_directory(&res_zip, i)) { - continue; - } - - results.emplace_back(stat.m_filename); - } - - return results; + return m_archive.file_names(); } size_t ModBundleZip::getFileSize(const std::string& fileName) { - std::lock_guard lock{m_mutex}; - const auto idx = mz_zip_reader_locate_file(&res_zip, fileName.c_str(), nullptr, 0); - if (idx < 0) { - throw std::runtime_error(fmt::format("Unable to locate file in zip: {}", fileName)); - } - - mz_zip_archive_file_stat stat{}; - mz_zip_reader_file_stat(&res_zip, idx, &stat); - return stat.m_uncomp_size; + return m_archive.file_size(fileName); } } // namespace dusk::mods diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 83c7660410..da056a09cd 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -3,6 +3,7 @@ #include "dusk/mod_loader.hpp" #include +#include #include #include @@ -34,6 +35,7 @@ using namespace std::string_literals; using namespace std::string_view_literals; +namespace fs = std::filesystem; #if defined(_WIN32) #if defined(_M_ARM64) @@ -96,23 +98,22 @@ public: ~DirectoryRollback() { if (!mPath.empty()) { std::error_code ec; - std::filesystem::remove_all(mPath, ec); + fs::remove_all(mPath, ec); } } - void set_path(std::filesystem::path path) { mPath = std::move(path); } + void set_path(fs::path path) { mPath = std::move(path); } void release() { mPath.clear(); } private: - std::filesystem::path mPath; + fs::path mPath; }; -std::unique_ptr load_bundle(const std::filesystem::path& modPath, bool fromDir) { +std::unique_ptr load_bundle(const fs::path& modPath, bool fromDir) { if (fromDir) { return std::make_unique(modPath); } else { - std::vector data = io::FileStream::ReadAllBytes(modPath); - return std::make_unique(std::move(data)); + return std::make_unique(modPath); } } @@ -193,6 +194,54 @@ NativeLocateResult locate_native_runtime(ModBundle& bundle) { result.runtimeEntries.end()); return result; } + +void complete_operation(const std::shared_ptr& operation, const bool success = true, + std::string message = {}) { + if (operation == nullptr) { + return; + } + operation->state = success ? ModOperation::State::Succeeded : ModOperation::State::Failed; + operation->message = std::move(message); +} + +std::string safe_filename(std::string_view id) { + std::string result{id}; + std::ranges::replace_if( + result, + [](char character) { + return !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '.' || + character == '_' || character == '-'); + }, + '_'); + return result; +} + +LoadedMod::FileIdentity file_identity(const fs::path& path) { + std::error_code error; + const bool isDirectory = fs::is_directory(path, error); + if (error) { + return {}; + } + const auto size = isDirectory ? 0 : fs::file_size(path, error); + if (error) { + return {}; + } + const auto modified = fs::last_write_time(path, error); + if (error) { + return {}; + } + return {.size = size, .modified = modified, .valid = true}; +} + +bool same_path(const fs::path& lhs, const fs::path& rhs) { + std::error_code error; + if (fs::equivalent(lhs, rhs, error)) { + return true; + } + return fs::absolute(lhs).lexically_normal() == fs::absolute(rhs).lexically_normal(); +} } // namespace ModLoader& ModLoader::instance() { @@ -269,7 +318,7 @@ static std::string resolve_image_path(ModBundle& bundle, const std::string& modI return {}; } -static ModMetadata load_metadata(const std::filesystem::path& modPath, ModBundle& bundle) { +static ModMetadata load_metadata(const fs::path& modPath, ModBundle& bundle) { const auto metaJson = bundle.readFile("mod.json"); auto j = nlohmann::json::parse(metaJson); @@ -308,6 +357,25 @@ static ModMetadata load_metadata(const std::filesystem::path& modPath, ModBundle }; } +bool inspect_mod_bundle( + const fs::path& path, ModMetadata& metadata, std::string& error, bool* hasNative) noexcept { + try { + auto bundle = load_bundle(path, false); + metadata = load_metadata(path, *bundle); + if (hasNative != nullptr) { + *hasNative = std::ranges::any_of(bundle->getFileNames(), + [](const auto& name) { return has_native_library_extension(name); }); + } + error.clear(); + return true; + } catch (const std::exception& exception) { + error = exception.what(); + } catch (...) { + error = "Unknown bundle validation error"; + } + return false; +} + // True if the first `capacity` bytes of `str` contain a NUL. static bool terminated_within(const char* str, size_t capacity) { return std::memchr(str, '\0', capacity) != nullptr; @@ -508,8 +576,7 @@ static std::string native_status_message(const NativeModStatus status) { return "native mod failed to load"; } -std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) const { - namespace fs = std::filesystem; +fs::path ModLoader::external_native_lib_path(const LoadedMod& mod) const { if (k_nativeLibName.empty()) { return {}; } @@ -517,8 +584,9 @@ std::filesystem::path ModLoader::external_native_lib_path(const LoadedMod& mod) if (libDir.empty()) { return {}; } - fs::path path = libDir / fs::path(mod.metadata.id + - borealis::io::fs_path_to_string(fs::path(k_nativeLibName).extension())); + const auto filename = fmt::format("{}{}", mod.metadata.id, + borealis::io::fs_path_to_string(fs::path(k_nativeLibName).extension())); + fs::path path = libDir / fs::path{filename}; std::error_code ec; if (!fs::is_regular_file(path, ec)) { return {}; @@ -534,8 +602,6 @@ void ModLoader::load_native( return; } - namespace fs = std::filesystem; - const fs::path cacheDir = m_cacheDir / mod.metadata.id; const fs::path scratchDir = cacheDir / "data"; std::error_code ec; @@ -714,7 +780,7 @@ void ModLoader::drain_retired_natives() { retired.native.reset(); if (!retired.directory.empty()) { std::error_code ec; - std::filesystem::remove_all(retired.directory, ec); + fs::remove_all(retired.directory, ec); } } m_retiredNatives.clear(); @@ -727,15 +793,22 @@ static ModManifestInfo build_manifest_info(const ModMetaParsed& parsed) { if (!svc::valid_service_id(record->service_id.chars)) { continue; } - info.imports.push_back({record->service_id.chars, record->major_version, - (record->rec.flags & SERVICE_IMPORT_OPTIONAL) == 0}); + info.imports.push_back({ + .id = record->service_id.chars, + .major = record->major_version, + .minMinor = record->min_minor_version, + .required = (record->rec.flags & SERVICE_IMPORT_OPTIONAL) == 0, + }); } info.exports.reserve(parsed.exports.size()); for (const auto* record : parsed.exports) { if (!svc::valid_service_id(record->service_id.chars)) { continue; } - info.exports.push_back({record->service_id.chars, record->major_version}); + info.exports.push_back({ + .id = record->service_id.chars, + .major = record->major_version, + }); } return info; } @@ -789,36 +862,37 @@ static void warn_unpublished_deferred_exports(const LoadedMod& mod) { } } -void ModLoader::try_load_mod( - const std::filesystem::path& modPath, bool fromDir, uint32_t searchDirIndex) { - namespace fs = std::filesystem; - - std::unique_ptr bundle; - try { - bundle = load_bundle(modPath, fromDir); - } catch (const std::exception& e) { - Log.error("Failed to open {} bundle: {}", data::abbreviated_path_string(modPath), e.what()); - return; +LoadedMod* ModLoader::try_load_mod(const fs::path& modPath, bool fromDir, uint32_t searchDirIndex, + std::unique_ptr bundle, std::optional metadata) { + if (bundle == nullptr) { + try { + bundle = load_bundle(modPath, fromDir); + } catch (const std::exception& e) { + Log.error( + "Failed to open {} bundle: {}", data::abbreviated_path_string(modPath), e.what()); + return nullptr; + } } - ModMetadata metadata; - try { - metadata = load_metadata(modPath, *bundle); - } catch (const std::exception& e) { - Log.error("bad mod.json in {}: {}", data::abbreviated_path_string(modPath), e.what()); - return; + if (!metadata) { + try { + metadata = load_metadata(modPath, *bundle); + } catch (const std::exception& e) { + Log.error("bad mod.json in {}: {}", data::abbreviated_path_string(modPath), e.what()); + return nullptr; + } } - if (const auto* existing = find_mod(metadata.id)) { + if (const auto* existing = find_mod(metadata->id)) { if (existing->searchDirIndex < searchDirIndex) { - log::write(metadata.id, LOG_LEVEL_INFO, "{} shadowed by higher-priority duplicate {}", + log::write(metadata->id, LOG_LEVEL_INFO, "{} shadowed by higher-priority duplicate {}", data::abbreviated_path_string(modPath), data::abbreviated_path_string(existing->modPath)); } else { - log::write(metadata.id, LOG_LEVEL_ERROR, "duplicate mod id, not loading {}", + log::write(metadata->id, LOG_LEVEL_ERROR, "duplicate mod id, not loading {}", data::abbreviated_path_string(modPath)); } - return; + return nullptr; } const auto& inserted = m_mods.emplace_back(std::make_unique()); @@ -827,7 +901,11 @@ void ModLoader::try_load_mod( mod.modPath = fs::absolute(modPath); mod.searchDirIndex = searchDirIndex; mod.inPlace = m_searchDirs[searchDirIndex].inPlaceNative && fromDir; - mod.metadata = std::move(metadata); + mod.origin = searchDirIndex == 0 ? ModOrigin::User : + mod.inPlace ? ModOrigin::BundledInPlace : + ModOrigin::Bundled; + mod.fileIdentity = file_identity(modPath); + mod.metadata = std::move(*metadata); mod.bundle = std::move(bundle); mod.context = std::make_unique(); mod.context->mod = &mod; @@ -839,6 +917,7 @@ void ModLoader::try_load_mod( log::write(mod.metadata.id, LOG_LEVEL_INFO, "found '{}' v{} by {} ({})", mod.metadata.name, mod.metadata.version, mod.metadata.author, data::abbreviated_path_string(modPath)); + return &mod; } bool ModLoader::activate_mod(LoadedMod& mod) { @@ -947,12 +1026,36 @@ void ModLoader::init() { m_cacheDir = m_searchDirs.front().path / ".cache"; } - namespace fs = std::filesystem; std::error_code ec; // Stale libs from previous sessions (see load_native). fs::remove_all(m_cacheDir, ec); + // A Windows update can be interrupted between moving the live archive aside and publishing + // its replacement. Recover that narrow crash window before scanning the user directory. + if (fs::is_directory(m_searchDirs.front().path, ec)) { + for (const auto& entry : fs::directory_iterator(m_searchDirs.front().path, ec)) { + const auto path = entry.path(); + if (!entry.is_regular_file() || path.extension() != ".old" || + path.stem().extension() != ".dusk") + { + continue; + } + auto primary = path; + primary.replace_extension(); + if (fs::exists(primary, ec)) { + fs::remove(path, ec); + } else { + fs::rename(path, primary, ec); + } + if (ec) { + Log.warn("failed to recover stale mod archive '{}': {}", + data::abbreviated_path_string(path), ec.message()); + ec.clear(); + } + } + } + for (size_t dirIndex = 0; dirIndex < m_searchDirs.size(); ++dirIndex) { const auto& searchDir = m_searchDirs[dirIndex]; @@ -978,7 +1081,7 @@ void ModLoader::init() { std::vector entries; for (auto& e : fs::directory_iterator(searchDir.path, ec)) { - if (e.is_directory() && std::filesystem::exists(e.path() / "mod.json")) { + if (e.is_directory() && fs::exists(e.path() / "mod.json")) { entries.push_back(e); } else if (e.is_regular_file() && e.path().extension() == ".dusk") { entries.push_back(e); @@ -990,12 +1093,15 @@ void ModLoader::init() { }); for (auto& entry : entries) { - try_load_mod(entry.path(), entry.is_directory(), static_cast(dirIndex)); + (void)sync_path(entry.path(), false, static_cast(dirIndex)); } } if (m_mods.empty()) { + init_services(); Log.info("no mods found"); + svc::modules_lifecycle_applied(); + m_startupComplete = true; return; } @@ -1081,8 +1187,65 @@ void ModLoader::request_disable(std::string_view id) { } } -void ModLoader::request_reload(std::string_view id) { - m_pendingRequests.push_back({std::string{id}, RequestKind::Reload}); +ModOperationHandle ModLoader::request_reload(std::string_view id) { + auto operation = std::make_shared(); + if (auto* mod = find_mod(id)) { + m_pendingRequests.push_back({ + .modId = std::string{id}, + .kind = RequestKind::Sync, + .path = mod->modPath, + .operation = operation, + .force = true, + }); + } else { + complete_operation(operation, false, "The mod is no longer installed"); + } + return operation; +} + +ModOperationHandle ModLoader::request_install(fs::path path) { + auto operation = std::make_shared(); + m_pendingRequests.push_back({ + .kind = RequestKind::Install, + .path = std::move(path), + .operation = operation, + }); + return operation; +} + +ModOperationHandle ModLoader::request_uninstall(std::string_view id) { + auto operation = std::make_shared(); + if (auto* mod = find_mod(id)) { + m_pendingRequests.push_back({ + .modId = std::string{id}, + .kind = RequestKind::Sync, + .path = mod->modPath, + .operation = operation, + .force = false, + .remove = true, + }); + } else { + complete_operation(operation); + } + return operation; +} + +ModOperationHandle ModLoader::request_reactivate(std::string_view id) { + auto operation = std::make_shared(); + m_pendingRequests.push_back({ + .modId = std::string{id}, + .kind = RequestKind::Reactivate, + .operation = operation, + }); + return operation; +} + +fs::path ModLoader::user_mods_dir() const { + return m_searchDirs.empty() ? fs::path{} : m_searchDirs.front().path; +} + +bool ModLoader::can_uninstall(const LoadedMod& mod) const { + return mod.origin == ModOrigin::User && !mod.inPlace && mod.modPath.extension() == ".dusk"; } void ModLoader::notify_mod_failure(LoadedMod& mod, bool firstFailure) { @@ -1163,7 +1326,6 @@ bool ModLoader::ensure_native_loaded(LoadedMod& mod) { } bool ModLoader::reload_bundle(LoadedMod& mod) { - namespace fs = std::filesystem; log::write(mod.metadata.id, LOG_LEVEL_INFO, "reloading from {}", data::abbreviated_path_string(mod.modPath)); @@ -1187,6 +1349,7 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { mod.metadata = std::move(newMetadata); // In-flight readers of the old bundle keep it alive through their shared_ptr. mod.bundle = std::move(newBundle); + mod.fileIdentity = file_identity(mod.modPath); mod.loadFailed = false; mod.failureReason.clear(); @@ -1212,6 +1375,48 @@ bool ModLoader::reload_bundle(LoadedMod& mod) { return true; } +void ModLoader::resume_lifecycle_set(const std::vector& affected) { + // Publish every candidate's static exports before any initialize, so optional cycles and + // provider changes use the same ordering rules as startup. + for (auto* mod : affected) { + if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { + continue; + } + if (!ensure_native_loaded(*mod)) { + continue; + } + if (mod->native && !mod->servicesRegistered) { + if (register_static_service_exports(*mod)) { + mod->servicesRegistered = true; + } else { + log::write(mod->metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); + deactivate_mod(*mod); + } + } + } + + for (auto* mod : affected) { + if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { + continue; + } + if (!required_deps_active(*mod)) { + mod->suspendedByProvider = true; + log::write( + mod->metadata.id, LOG_LEVEL_INFO, "suspended: a required provider is disabled"); + continue; + } + mod->suspendedByProvider = false; + activate_mod(*mod); + } + + for (auto* mod : affected) { + if (!mod->active && mod->servicesRegistered) { + svc::remove_services_for_provider(*mod); + mod->servicesRegistered = false; + } + } +} + void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { auto affected = collect_lifecycle_set(target); @@ -1249,48 +1454,7 @@ void ModLoader::apply_lifecycle_change(LoadedMod& target, const bool reload) { affected = std::move(reordered); } - // Mirror startup: publish every candidate's static exports before any of them initialize, - // so importers within the set resolve providers regardless of initialization order - // (optional cycles rely on this). - for (auto* mod : affected) { - if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { - continue; - } - if (!ensure_native_loaded(*mod)) { - continue; - } - if (mod->native && !mod->servicesRegistered) { - if (register_static_service_exports(*mod)) { - mod->servicesRegistered = true; - } else { - log::write(mod->metadata.id, LOG_LEVEL_ERROR, "failed to register service exports"); - deactivate_mod(*mod); - } - } - } - - // Providers first (init order). The target is naturally first among the affected mods. - for (auto* mod : affected) { - if (mod->active || mod->loadFailed || !mod->cvarIsEnabled->getValue()) { - continue; - } - if (!required_deps_active(*mod)) { - mod->suspendedByProvider = true; - log::write( - mod->metadata.id, LOG_LEVEL_INFO, "suspended: a required provider is disabled"); - continue; - } - mod->suspendedByProvider = false; - activate_mod(*mod); - } - - // Mods that stayed down must not leave their exports resolvable. - for (auto* mod : affected) { - if (!mod->active && mod->servicesRegistered) { - svc::remove_services_for_provider(*mod); - mod->servicesRegistered = false; - } - } + resume_lifecycle_set(affected); } void ModLoader::on_enabled_changed(LoadedMod& mod) { @@ -1313,6 +1477,279 @@ void ModLoader::on_enabled_changed(LoadedMod& mod) { mod.cvarIsEnabled->getValue() ? RequestKind::Enable : RequestKind::Disable}); } +void ModLoader::forget_mod(LoadedMod& mod) { + auto affected = collect_lifecycle_set(mod); + std::vector blocked; + std::vector pending{&mod}; + while (!pending.empty()) { + auto* provider = pending.back(); + pending.pop_back(); + for (const auto& edge : provider->dependents) { + if (!edge.required || edge.mod == nullptr || + std::ranges::find(blocked, edge.mod) != blocked.end()) + { + continue; + } + blocked.push_back(edge.mod); + pending.push_back(edge.mod); + } + } + + for (auto* affectedMod : affected | std::views::reverse) { + const bool wasActive = affectedMod->active; + if (affectedMod->active || affectedMod->initialized || affectedMod->native != nullptr) { + log::write(affectedMod->metadata.id, LOG_LEVEL_INFO, "deactivating mod"); + deactivate_mod(*affectedMod); + } + if (affectedMod != &mod && wasActive) { + affectedMod->suspendedByProvider = true; + } + } + + const std::string modId = mod.metadata.id; + auto* removedMod = &mod; + if (mod.enabledSubscription != 0) { + config::unsubscribe(mod.enabledSubscription); + mod.enabledSubscription = 0; + } + unregister(*mod.cvarIsEnabled); + + std::vector remaining; + remaining.reserve(affected.size()); + for (auto* affectedMod : affected) { + if (affectedMod != &mod) { + remaining.push_back(affectedMod); + } + } + std::erase_if( + m_mods, [removedMod](const auto& candidate) { return candidate.get() == removedMod; }); + loader::sort_mods(m_mods); + + std::vector resumable; + for (auto* affectedMod : remaining) { + const bool needsRemovedProvider = std::ranges::find(blocked, affectedMod) != blocked.end(); + affectedMod->suspendedByProvider = needsRemovedProvider; + if (needsRemovedProvider) { + log::write(affectedMod->metadata.id, LOG_LEVEL_INFO, + "suspended: required provider '{}' was removed", modId); + } else { + resumable.push_back(affectedMod); + } + } + resume_lifecycle_set(resumable); + + ++m_generation; + log::write(modId, LOG_LEVEL_INFO, "forgot removed package"); +} + +ModLoader::SyncResult ModLoader::sync_path( + const fs::path& requestedPath, const bool force, const uint32_t searchDirIndex) { + const auto path = fs::absolute(requestedPath).lexically_normal(); + auto loaded = std::ranges::find_if( + m_mods, [&](const auto& candidate) { return same_path(candidate->modPath, path); }); + LoadedMod* mod = loaded == m_mods.end() ? nullptr : loaded->get(); + + std::error_code error; + const auto status = fs::status(path, error); + const bool exists = !error && fs::exists(status); + if (!exists) { + const bool isGone = error == std::errc::no_such_file_or_directory +#if defined(_WIN32) + || error == std::errc::permission_denied +#endif + ; + if (error && !isGone) { + return { + .success = false, + .message = fmt::format("Could not inspect the package: {}", error.message()), + }; + } + if (mod != nullptr) { + forget_mod(*mod); + } + return {}; + } + + const auto identity = file_identity(path); + if (mod != nullptr) { + if (!force && identity.valid && identity == mod->fileIdentity) { + return {.mod = mod}; + } + if (mod->inPlace) { + return { + .success = false, + .message = "Built-in mods cannot be updated in-game", + .mod = mod, + }; + } + apply_lifecycle_change(*mod, true); + ++m_generation; + } else { + const bool fromDir = fs::is_directory(status); + std::unique_ptr bundle; + std::optional metadata; + try { + bundle = load_bundle(path, fromDir); + metadata = load_metadata(path, *bundle); + } catch (const std::exception& exception) { + return { + .success = false, + .message = fmt::format("Invalid mod package: {}", exception.what()), + }; + } + if (const auto* duplicate = find_mod(metadata->id)) { + return { + .success = false, + .message = fmt::format("A mod with this ID is already loaded from {}", + data::abbreviated_path_string(duplicate->modPath)), + }; + } + mod = try_load_mod(path, fromDir, searchDirIndex, std::move(bundle), std::move(metadata)); + if (mod == nullptr) { + return { + .success = false, + .message = "The mod could not be loaded", + }; + } + + if (m_startupComplete) { + mod->enabledSubscription = Register(*mod->cvarIsEnabled, + [this, mod](const bool&, const bool&) { on_enabled_changed(*mod); }); + loader::sort_mods(m_mods); + if (!mod->cvarIsEnabled->getValue()) { + mod->active = false; + mod->suspendedByProvider = false; + log::write(mod->metadata.id, LOG_LEVEL_INFO, "installed disabled by config"); + } else if (!mod->loadFailed) { + mod->active = false; + apply_lifecycle_change(*mod, false); + } + ++m_generation; + log::write(mod->metadata.id, LOG_LEVEL_INFO, "installed at runtime"); + } + } + + if (!m_startupComplete) { + return {.mod = mod}; + } + if (mod->loadFailed) { + return { + .success = false, + .message = + mod->failureReason.empty() ? "The mod failed to activate" : mod->failureReason, + .mod = mod, + }; + } + if (mod->cvarIsEnabled->getValue() && !mod->active) { + return { + .success = false, + .message = "A required provider is unavailable", + .mod = mod, + }; + } + if (!mod->cvarIsEnabled->getValue()) { + return { + .message = "Installed, disabled by config", + .mod = mod, + }; + } + return {.mod = mod}; +} + +ModLoader::SyncResult ModLoader::install_staged(const fs::path& requestedPath) { + if (m_searchDirs.empty()) { + return { + .success = false, + .message = "No writable mods directory is configured", + }; + } + std::error_code error; + const auto userDir = fs::weakly_canonical(m_searchDirs.front().path, error); + if (error) { + return { + .success = false, + .message = + fmt::format("Could not resolve the user mods directory: {}", error.message()), + }; + } + const auto stagingDir = userDir / ".staging"; + const auto path = fs::weakly_canonical(requestedPath, error); + const bool stagedName = path.extension() == ".part" && path.stem().extension() == ".dusk"; + if (error || path.parent_path() != stagingDir || !stagedName || + !fs::is_regular_file(path, error)) + { + Log.error("refusing staged install from {}", data::abbreviated_path_string(requestedPath)); + return { + .success = false, + .message = "The package is not in the mod staging directory", + }; + } + + ModMetadata metadata; + std::string validationError; + if (!inspect_mod_bundle(path, metadata, validationError)) { + return { + .success = false, + .message = fmt::format("Invalid mod package: {}", validationError), + }; + } + + fs::path destination; + if (auto* installed = find_mod(metadata.id)) { + if (!can_uninstall(*installed)) { + return { + .success = false, + .message = "This bundled mod cannot be updated in-game", + }; + } + destination = installed->modPath; + } else { + destination = userDir / fmt::format("{}.dusk", safe_filename(metadata.id)); + } + + std::string replaceError; +#ifdef _WIN32 + fs::path aside = destination; + aside += ".old"; + const bool hadDestination = fs::exists(destination, error); + if (hadDestination) { + fs::remove(aside, error); + error.clear(); + fs::rename(destination, aside, error); + if (error) { + return { + .success = false, + .message = + fmt::format("Failed to prepare the installed package: {}", error.message()), + }; + } + } + if (!borealis::io::atomic_replace(path, destination, replaceError)) { + if (hadDestination) { + std::error_code restoreError; + fs::rename(aside, destination, restoreError); + } + return { + .success = false, + .message = std::move(replaceError), + }; + } + auto result = sync_path(destination, true); + if (hadDestination) { + fs::remove(aside, error); + } + return result; +#else + if (!borealis::io::atomic_replace(path, destination, replaceError)) { + return { + .success = false, + .message = std::move(replaceError), + }; + } + return sync_path(destination, true); +#endif +} + void ModLoader::apply_pending_requests() { // Images retired by the previous tick have had a full frame to unwind off the stack. drain_retired_natives(); @@ -1321,14 +1758,53 @@ void ModLoader::apply_pending_requests() { return; } - // Coalesce per mod, last request wins. Failures during apply re-enqueue for next tick. + // Path mutations retain queue order. Enable/disable/reactivate can still coalesce per mod. const auto requests = std::exchange(m_pendingRequests, {}); std::vector coalesced; for (const auto& request : requests) { + if (request.kind == RequestKind::Install) { + auto result = install_staged(request.path); + complete_operation(request.operation, result.success, std::move(result.message)); + continue; + } + if (request.kind == RequestKind::Sync) { + std::string removedName; + if (request.remove) { + auto* mod = find_mod(request.modId); + if (mod == nullptr) { + complete_operation(request.operation); + continue; + } + if (!can_uninstall(*mod)) { + complete_operation( + request.operation, false, "The mod is part of this Dusklight installation"); + continue; + } + removedName = mod->metadata.name; + std::error_code error; + if (!fs::remove(request.path, error)) { + complete_operation(request.operation, false, + error ? error.message() : "The package was not found"); + continue; + } + } + auto result = sync_path(request.path, request.force); + complete_operation(request.operation, result.success, std::move(result.message)); + if (request.remove && result.success) { + ui::push_toast({ + .title = "Mod uninstalled", + .content = std::move(removedName), + .duration = std::chrono::seconds{4}, + }); + } + continue; + } const auto existing = std::ranges::find_if( coalesced, [&](const Request& r) { return r.modId == request.modId; }); if (existing != coalesced.end()) { - existing->kind = request.kind; + complete_operation( + existing->operation, false, "Superseded by a newer lifecycle request"); + *existing = request; } else { coalesced.push_back(request); } @@ -1338,10 +1814,7 @@ void ModLoader::apply_pending_requests() { auto* mod = find_mod(request.modId); if (mod == nullptr) { Log.warn("lifecycle request for unknown mod '{}'", request.modId); - continue; - } - if (request.kind == RequestKind::Reload && mod->inPlace) { - log::write(mod->metadata.id, LOG_LEVEL_WARN, "is a built-in mod and can't be reloaded"); + complete_operation(request.operation, false, "The mod is no longer installed"); continue; } if (request.kind == RequestKind::Enable && mod->enabledApplied) { @@ -1350,7 +1823,27 @@ void ModLoader::apply_pending_requests() { if (request.kind == RequestKind::Disable && !mod->enabledApplied && !mod->active) { continue; } - apply_lifecycle_change(*mod, request.kind == RequestKind::Reload); + + if (request.kind == RequestKind::Reactivate) { + mod->loadFailed = false; + mod->failureReason.clear(); + mod->suspendedByProvider = false; + if (!mod->cvarIsEnabled->getValue()) { + mod->cvarIsEnabled->setValue(true); + } + } + apply_lifecycle_change(*mod, false); + + if (request.kind == RequestKind::Reactivate) { + std::string error; + if (mod->loadFailed) { + error = + mod->failureReason.empty() ? "The mod failed to activate" : mod->failureReason; + } else if (mod->cvarIsEnabled->getValue() && !mod->active) { + error = "A required provider is unavailable"; + } + complete_operation(request.operation, error.empty(), std::move(error)); + } } svc::modules_lifecycle_applied(); diff --git a/src/dusk/mods/loader/loader.hpp b/src/dusk/mods/loader/loader.hpp index 6b9fd39232..d5708c9be1 100644 --- a/src/dusk/mods/loader/loader.hpp +++ b/src/dusk/mods/loader/loader.hpp @@ -1,10 +1,9 @@ #pragma once #include -#include #include -#include "miniz.h" +#include "dusk/archive.hpp" #include "dusk/mod_loader.hpp" namespace dusk::mods { @@ -28,17 +27,14 @@ public: class ModBundleZip final : public ModBundle { public: - explicit ModBundleZip(std::vector&& data); - ~ModBundleZip() override; + explicit ModBundleZip(const std::filesystem::path& path); + ~ModBundleZip() override = default; std::vector readFile(const std::string& fileName) override; std::vector getFileNames() override; size_t getFileSize(const std::string& fileName) override; private: - std::vector zip_data; - mz_zip_archive res_zip{}; - bool res_zip_open = false; - std::mutex m_mutex; + archive::ZipArchive m_archive; }; class ModBundleDisk final : public ModBundle { diff --git a/src/dusk/mods/queue.cpp b/src/dusk/mods/queue.cpp new file mode 100644 index 0000000000..3707396ca3 --- /dev/null +++ b/src/dusk/mods/queue.cpp @@ -0,0 +1,884 @@ +#include "queue.hpp" + +#include "dusk/hash.hpp" +#include "dusk/mod_loader.hpp" +#include "dusk/ui/ui.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::queue { +namespace { + +using clock = std::chrono::steady_clock; + +struct VerifyResult { + std::string error; + ModMetadata metadata; + std::filesystem::path stagedPath; + bool canceled = false; +}; + +struct QueueItem { + std::string key; + Request request; + State state = State::Queued; + std::filesystem::path partialPath; + std::filesystem::path installPath; + uint64_t completed = 0; + uint64_t total = 0; + std::string message; + int retryCount = 0; + clock::time_point retryAt{}; + borealis::Task task; + borealis::Task verification; + ModOperationHandle operation; + bool packagePublished = false; + bool removeAfterOperation = false; + bool pauseRequested = false; + bool resumeRequested = false; + bool cancelRequested = false; +}; + +std::vector queueItems; +uint64_t nextLocalKey = 1; + +bool terminal(State state); + +QueueItem* find_queue_item(std::string_view key) { + const auto item = std::ranges::find(queueItems, key, + [](const QueueItem& candidate) { return std::string_view{candidate.key}; }); + return item == queueItems.end() ? nullptr : &*item; +} + +QueueItem* find_queue_item_by_mod_id(std::string_view id) { + const auto item = std::ranges::find(queueItems, id, + [](const QueueItem& candidate) { return std::string_view{candidate.request.id}; }); + return item == queueItems.end() ? nullptr : &*item; +} + +const Url* url_source(const QueueItem& item) { + return std::get_if(&item.request.source); +} + +const LocalFile* local_source(const QueueItem& item) { + return std::get_if(&item.request.source); +} + +const LoadedMod* find_loaded_mod(std::string_view id) { + for (const auto& mod : ModLoader::instance().mods()) { + if (mod.metadata.id == id) { + return &mod; + } + } + return nullptr; +} + +std::string lowercase(std::string value) { + std::ranges::transform(value, value.begin(), [](char character) { + return character >= 'A' && character <= 'Z' ? static_cast(character + ('a' - 'A')) : + character; + }); + return value; +} + +bool valid_sha256(std::string_view value) { + return value.size() == 64 && std::ranges::all_of(value, [](char character) { + return (character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F'); + }); +} + +std::string safe_filename(std::string_view id) { + std::string result{id}; + std::ranges::replace_if( + result, + [](char character) { + return !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '.' || + character == '_' || character == '-'); + }, + '_'); + return result; +} + +std::string sha256_file( + const std::filesystem::path& path, borealis::TaskContext& context, std::string& error) { + std::ifstream input{path, std::ios::binary}; + if (!input) { + error = "Could not open the downloaded package"; + return {}; + } + + hash::Sha256 hash; + std::array buffer{}; + uint64_t completed = 0; + while (input) { + if (context.cancel_requested()) { + error = "Canceled"; + return {}; + } + input.read(reinterpret_cast(buffer.data()), buffer.size()); + const auto count = input.gcount(); + if (count > 0) { + hash.update(std::span{buffer.data(), static_cast(count)}); + completed += static_cast(count); + context.report_progress(completed); + } + } + if (!input.eof()) { + error = "Could not read the downloaded package"; + return {}; + } + + return hash.finish(); +} + +std::filesystem::path staging_path( + const std::filesystem::path& stagingDir, std::string_view modId, std::string_view key) { + return stagingDir / fmt::format("{}-{}.dusk.part", safe_filename(modId), safe_filename(key)); +} + +bool copy_to_staging(const std::filesystem::path& source, const std::filesystem::path& destination, + uint64_t total, borealis::TaskContext& context, std::string& error) { + std::error_code filesystemError; + std::filesystem::create_directories(destination.parent_path(), filesystemError); + if (filesystemError) { + error = + fmt::format("Could not create the staging directory: {}", filesystemError.message()); + return false; + } + std::ifstream input{source, std::ios::binary}; + std::ofstream output{destination, std::ios::binary | std::ios::trunc}; + if (!input || !output) { + error = "Could not stage the local package"; + return false; + } + std::array buffer{}; + uint64_t completed = 0; + while (input) { + if (context.cancel_requested()) { + error = "Canceled"; + output.close(); + std::filesystem::remove(destination, filesystemError); + return false; + } + input.read(buffer.data(), buffer.size()); + const auto count = input.gcount(); + if (count > 0) { + output.write(buffer.data(), count); + completed += static_cast(count); + context.report_progress(completed, total); + } + } + if (!input.eof() || !output) { + error = "Could not copy the local package"; + output.close(); + std::filesystem::remove(destination, filesystemError); + return false; + } + return true; +} + +VerifyResult verify_url_package(const std::filesystem::path& path, const Request& request, + const Url& source, const std::filesystem::path& stagingDir, std::string key, + borealis::TaskContext& context) { + std::error_code ec; + const auto actualSize = std::filesystem::file_size(path, ec); + if (ec) { + return {.error = fmt::format("Could not read the downloaded package: {}", ec.message())}; + } + if (actualSize != source.size) { + return {.error = "Package size mismatch"}; + } + + std::string error; + const auto actualHash = sha256_file(path, context, error); + if (!error.empty()) { + return {.error = std::move(error)}; + } + if (context.cancel_requested()) { + return {.canceled = true}; + } + if (actualHash != lowercase(source.sha256)) { + return {.error = "Package checksum mismatch"}; + } + + ModMetadata metadata; + if (!inspect_mod_bundle(path, metadata, error)) { + return {.error = fmt::format("Invalid mod package: {}", error)}; + } + if (metadata.id != request.id) { + return {.error = "Package ID does not match the catalog entry"}; + } + if (metadata.version != request.version) { + return {.error = "Package version does not match the catalog entry"}; + } + if (context.cancel_requested()) { + return {.canceled = true}; + } + const auto stagedPath = staging_path(stagingDir, metadata.id, key); + std::filesystem::create_directories(stagedPath.parent_path(), ec); + if (ec) { + return {.error = fmt::format("Could not create the staging directory: {}", ec.message())}; + } + std::string replaceError; + if (!borealis::io::atomic_replace(path, stagedPath, replaceError)) { + return {.error = std::move(replaceError)}; + } + return {.metadata = std::move(metadata), .stagedPath = stagedPath}; +} + +VerifyResult verify_local_package(const LocalFile& source, const std::filesystem::path& stagingDir, + std::string key, borealis::TaskContext& context) { + std::error_code ec; + const auto size = std::filesystem::file_size(source.path, ec); + if (ec) { + return {.error = fmt::format("Could not read the local package: {}", ec.message())}; + } + context.report_progress(0, size); + ModMetadata metadata; + std::string error; + if (!inspect_mod_bundle(source.path, metadata, error)) { + return {.error = fmt::format("Invalid mod package: {}", error)}; + } + const auto stagedPath = staging_path(stagingDir, metadata.id, key); + if (!copy_to_staging(source.path, stagedPath, size, context, error)) { + return {.error = std::move(error), .canceled = context.cancel_requested()}; + } + return {.metadata = std::move(metadata), .stagedPath = stagedPath}; +} + +void remove_partial(const QueueItem& item) { + std::error_code ec; + if (!item.partialPath.empty()) { + std::filesystem::remove(item.partialPath, ec); + auto metadataPath = item.partialPath; + metadataPath += ".borealis-resume.json"; + std::filesystem::remove(metadataPath, ec); + } + if (!item.installPath.empty()) { + std::filesystem::remove(item.installPath, ec); + } +} + +void fail(QueueItem& item, State state, std::string message, bool discardPartial) { + item.task = {}; + item.verification = {}; + item.operation.reset(); + item.state = state; + item.message = std::move(message); + item.pauseRequested = false; + item.resumeRequested = false; + item.cancelRequested = false; + item.removeAfterOperation = false; + if (discardPartial) { + remove_partial(item); + item.completed = 0; + } + const char* title = state == State::ActivationFailed ? "Mod activation failed" : + state == State::InstallFailed ? "Mod install failed" : + local_source(item) != nullptr ? "Mod package failed" : + "Mod download failed"; + ui::push_toast({ + .type = "warning", + .title = title, + .content = fmt::format("{}: {}", item.request.name, item.message), + .duration = std::chrono::seconds{6}, + }); +} + +bool retryable(const borealis::http::Result& result) { + if (result.error == borealis::http::Error::Network || + result.error == borealis::http::Error::Timeout) + { + return true; + } + const int status = result.response.statusCode; + return result.error == borealis::http::Error::None && + (status == 408 || status == 425 || status == 429 || status >= 500); +} + +void schedule_retry(QueueItem& item, std::string message) { + ++item.retryCount; + const int delaySeconds = std::min(30, 1 << std::min(item.retryCount, 4)); + item.retryAt = clock::now() + std::chrono::seconds{delaySeconds}; + item.state = State::Retrying; + item.message = std::move(message); + item.task = {}; +} + +void start_download(QueueItem& item) { + const auto* source = url_source(item); + if (source == nullptr) { + fail(item, State::Failed, "The install source is not a URL", false); + return; + } + const auto userDir = ModLoader::instance().user_mods_dir(); + if (userDir.empty()) { + fail(item, State::Failed, "No writable mods directory is configured", false); + return; + } + + item.partialPath = + userDir / ".downloads" / fmt::format("{}.dusk.part", safe_filename(item.request.id)); + std::error_code ec; + std::filesystem::create_directories(item.partialPath.parent_path(), ec); + if (ec) { + fail(item, State::Failed, + fmt::format("Could not create the download directory: {}", ec.message()), false); + return; + } + + item.pauseRequested = false; + item.resumeRequested = false; + item.cancelRequested = false; + item.message.clear(); + item.total = source->size; + item.state = State::Downloading; + item.task = borealis::http::start({ + .url = source->url, + .downloadTo = item.partialPath, + .connectTimeout = std::chrono::seconds{10}, + .idleTimeout = std::chrono::seconds{15}, + .totalTimeout = std::nullopt, + }); +} + +void start_local_verification(QueueItem& item) { + const auto* source = local_source(item); + if (source == nullptr) { + fail(item, State::Failed, "The install source is not a local file", false); + return; + } + const auto userDir = ModLoader::instance().user_mods_dir(); + if (userDir.empty()) { + fail(item, State::Failed, "No writable mods directory is configured", false); + return; + } + item.state = State::Verifying; + item.message.clear(); + item.completed = 0; + std::error_code error; + item.total = std::filesystem::file_size(source->path, error); + const auto stagingDir = userDir / ".staging"; + const auto local = *source; + item.verification = + borealis::spawn([local, stagingDir, key = item.key](borealis::TaskContext& context) { + return verify_local_package(local, stagingDir, key, context); + }); +} + +void finish_download(QueueItem& item) { + const auto progress = item.task.progress(); + item.completed = std::max(item.completed, progress.completed); + + std::optional completed; + try { + completed = item.task.try_take(); + } catch (const std::exception& exception) { + item.task = {}; + if (item.cancelRequested) { + remove_partial(item); + item.state = State::Canceled; + return; + } + if (item.pauseRequested) { + item.state = item.resumeRequested ? State::Queued : State::Paused; + item.pauseRequested = false; + item.resumeRequested = false; + return; + } + schedule_retry(item, exception.what()); + return; + } + if (!completed) { + return; + } + item.task = {}; + + if (item.cancelRequested) { + remove_partial(item); + item.completed = 0; + item.state = State::Canceled; + return; + } + if (item.pauseRequested) { + item.state = item.resumeRequested ? State::Queued : State::Paused; + item.pauseRequested = false; + item.resumeRequested = false; + return; + } + + if (completed->error != borealis::http::Error::None || completed->response.statusCode < 200 || + completed->response.statusCode >= 300) + { + const auto message = !completed->message.empty() ? completed->message : + completed->response.statusCode != 0 ? + fmt::format("Server returned HTTP {}", + completed->response.statusCode) : + "The download failed"; + if (retryable(*completed)) { + schedule_retry(item, message); + } else { + fail(item, State::Failed, message, true); + } + return; + } + + const auto* source = url_source(item); + if (source == nullptr) { + fail(item, State::Failed, "The install source changed", true); + return; + } + item.completed = source->size; + item.state = State::Verifying; + item.message.clear(); + const auto stagingDir = ModLoader::instance().user_mods_dir() / ".staging"; + item.verification = + borealis::spawn([path = item.partialPath, request = item.request, source = *source, + stagingDir, key = item.key](borealis::TaskContext& context) { + return verify_url_package(path, request, source, stagingDir, key, context); + }); +} + +void start_install(QueueItem& item) { + auto& loader = ModLoader::instance(); + item.state = State::Installing; + item.message.clear(); + item.operation = loader.request_install(item.installPath); +} + +void publish_verified(QueueItem& item) { + start_install(item); +} + +void finish_verification(QueueItem& item) { + VerifyResult result; + try { + auto completed = item.verification.try_take(); + if (!completed) { + return; + } + result = std::move(*completed); + } catch (const std::exception& exception) { + result.error = exception.what(); + } catch (...) { + result.error = "Package verification failed"; + } + item.verification = {}; + if (result.canceled || item.cancelRequested) { + if (!result.stagedPath.empty()) { + std::error_code error; + std::filesystem::remove(result.stagedPath, error); + } + remove_partial(item); + item.state = State::Canceled; + item.completed = 0; + return; + } + if (!result.error.empty()) { + fail(item, State::Failed, std::move(result.error), true); + return; + } + if (const auto duplicate = find_queue_item_by_mod_id(result.metadata.id); + duplicate != nullptr && duplicate != &item && !terminal(duplicate->state)) + { + fail(item, State::InstallFailed, "This mod already has an active install", true); + return; + } + if (local_source(item) != nullptr && !item.request.id.empty() && + (item.request.id != result.metadata.id || item.request.version != result.metadata.version)) + { + fail(item, State::InstallFailed, "The local package changed after confirmation", true); + return; + } + item.request.id = result.metadata.id; + item.request.name = result.metadata.name; + item.request.version = result.metadata.version; + item.installPath = std::move(result.stagedPath); + item.completed = item.total; + publish_verified(item); +} + +void update_install(QueueItem& item) { + if (item.operation == nullptr || item.operation->state == ModOperation::State::Pending) { + return; + } + + const auto* mod = find_loaded_mod(item.request.id); + item.packagePublished = mod != nullptr && mod->metadata.version == item.request.version; + if (item.operation->state == ModOperation::State::Failed) { + const bool activationFailed = + item.packagePublished && + (mod->loadFailed || (mod->cvarIsEnabled->getValue() && !mod->active)); + fail(item, activationFailed ? State::ActivationFailed : State::InstallFailed, + item.operation->message.empty() ? "The mod could not be installed" : + item.operation->message, + false); + return; + } + + item.message = item.operation->message; + item.operation.reset(); + if (mod == nullptr || mod->metadata.version != item.request.version) { + fail(item, State::InstallFailed, "The installed package was not loaded", false); + return; + } + + item.state = State::Installed; + ui::push_toast({ + .title = "Mod installed", + .content = fmt::format("{} {}", item.request.name, item.request.version), + .duration = std::chrono::seconds{4}, + }); +} + +void update_uninstall(QueueItem& item) { + if (item.operation == nullptr || item.operation->state == ModOperation::State::Pending) { + return; + } + if (item.operation->state == ModOperation::State::Failed) { + fail(item, State::ActivationFailed, + item.operation->message.empty() ? + "The mod could not be uninstalled" : + fmt::format("Uninstall failed: {}", item.operation->message), + false); + return; + } + + item.operation.reset(); + item.state = State::Canceled; + item.message.clear(); +} + +Item snapshot(const QueueItem& item) { + Item result{ + .id = item.key, + .modId = item.request.id, + .name = item.request.name, + .version = item.request.version, + .state = item.state, + .completed = item.completed, + .total = item.total, + .message = item.message, + .local = local_source(item) != nullptr, + }; + if (item.task) { + result.completed = std::max(result.completed, item.task.progress().completed); + } + if (item.verification) { + const auto progress = item.verification.progress(); + result.completed = progress.completed; + if (progress.total) { + result.total = *progress.total; + } + } + if (item.state == State::Retrying) { + const auto remaining = item.retryAt - clock::now(); + result.retrySeconds = std::max( + 0, static_cast(std::chrono::ceil(remaining).count())); + } + return result; +} + +bool terminal(State state) { + return state == State::Installed || state == State::Failed || state == State::InstallFailed || + state == State::ActivationFailed || state == State::Canceled; +} + +} // namespace + +bool enqueue(Request request, std::string* keyOut) { + const auto* source = std::get_if(&request.source); + const auto* local = std::get_if(&request.source); + if (source != nullptr) { + if (request.id.empty() || request.version.empty() || source->size == 0 || + !source->url.starts_with("https://") || !valid_sha256(source->sha256)) + { + return false; + } + } else if (local == nullptr || request.id.empty() || request.version.empty()) { + return false; + } + const auto total = source == nullptr ? 0 : source->size; + if (request.name.empty()) { + request.name = request.id; + } + + if (auto* existing = find_queue_item_by_mod_id(request.id)) { + if (!terminal(existing->state)) { + return false; + } + existing->request = std::move(request); + existing->state = State::Queued; + existing->completed = 0; + existing->total = total; + existing->partialPath.clear(); + existing->installPath.clear(); + existing->message.clear(); + existing->retryCount = 0; + existing->operation.reset(); + existing->packagePublished = false; + existing->removeAfterOperation = false; + existing->pauseRequested = false; + existing->resumeRequested = false; + existing->cancelRequested = false; + if (keyOut != nullptr) { + *keyOut = existing->key; + } + return true; + } + + const auto key = source != nullptr ? request.id : fmt::format("local-{}", nextLocalKey++); + if (keyOut != nullptr) { + *keyOut = key; + } + queueItems.push_back({.key = key, .request = std::move(request), .total = total}); + return true; +} + +void update() { + for (auto& item : queueItems) { + if (item.task && item.task.ready()) { + finish_download(item); + } + if (item.state == State::Verifying && item.verification && item.verification.ready()) { + finish_verification(item); + } + if (item.state == State::Installing) { + update_install(item); + } + if (item.state == State::Activating) { + update_install(item); + } + if (item.state == State::Uninstalling) { + update_uninstall(item); + } + if (item.state == State::ActivationFailed && item.packagePublished) { + const auto* mod = find_loaded_mod(item.request.id); + if (mod != nullptr && mod->active && mod->metadata.version == item.request.version) { + item.state = State::Installed; + item.message.clear(); + } + } + } + + std::erase_if(queueItems, [](const QueueItem& item) { + if (item.removeAfterOperation && item.state == State::Canceled) { + return true; + } + const bool trackedInstalledState = + item.state == State::Installed || item.state == State::ActivationFailed; + return item.packagePublished && trackedInstalledState && + find_loaded_mod(item.request.id) == nullptr; + }); + + for (auto& item : queueItems) { + if (item.task || item.verification) { + return; + } + if (terminal(item.state) || item.state == State::Paused) { + continue; + } + if (item.state == State::Downloading || item.state == State::Verifying || + item.state == State::Installing || item.state == State::Activating || + item.state == State::Uninstalling) + { + return; + } + if (item.state == State::Retrying && clock::now() < item.retryAt) { + return; + } + if (item.state == State::Queued || item.state == State::Retrying) { + if (local_source(item) != nullptr) { + start_local_verification(item); + } else { + start_download(item); + } + } + return; + } +} + +void shutdown() noexcept { + for (auto& item : queueItems) { + if (item.task) { + item.task.cancel(); + } + if (item.verification) { + item.verification.cancel(); + } + } + queueItems.clear(); +} + +std::vector items() { + std::vector result; + result.reserve(queueItems.size()); + for (const auto& item : queueItems) { + result.push_back(snapshot(item)); + } + return result; +} + +std::optional find(std::string_view id) { + const auto* item = find_queue_item(id); + return item == nullptr ? std::nullopt : std::optional{snapshot(*item)}; +} + +std::optional find_by_mod_id(std::string_view id) { + const auto* item = find_queue_item_by_mod_id(id); + return item == nullptr ? std::nullopt : std::optional{snapshot(*item)}; +} + +bool has_active_items() { + return std::ranges::any_of( + queueItems, [](const QueueItem& item) { return !terminal(item.state); }); +} + +void pause(std::string_view id) { + auto* item = find_queue_item(id); + if (item == nullptr || local_source(*item) != nullptr) { + return; + } + if (item->state == State::Queued || item->state == State::Retrying) { + item->state = State::Paused; + return; + } + if (item->state == State::Downloading && item->task) { + item->completed = std::max(item->completed, item->task.progress().completed); + item->pauseRequested = true; + item->state = State::Paused; + item->task.cancel(); + } +} + +void resume(std::string_view id) { + auto* item = find_queue_item(id); + if (item == nullptr || item->state != State::Paused) { + return; + } + if (item->task) { + item->resumeRequested = true; + } else { + item->state = State::Queued; + } +} + +void retry(std::string_view id) { + auto* item = find_queue_item(id); + if (item == nullptr) { + return; + } + + if (item->state == State::ActivationFailed) { + item->message.clear(); + item->state = State::Activating; + item->operation = ModLoader::instance().request_reactivate(item->request.id); + return; + } + if (item->state == State::InstallFailed) { + item->message.clear(); + std::error_code ec; + if (!item->installPath.empty() && std::filesystem::is_regular_file(item->installPath, ec)) { + start_install(*item); + } else { + item->completed = 0; + item->state = State::Queued; + } + return; + } + if (item->state == State::Failed) { + remove_partial(*item); + item->completed = 0; + item->retryCount = 0; + item->message.clear(); + item->state = State::Queued; + } +} + +void cancel(std::string_view id) { + auto* item = find_queue_item(id); + if (item == nullptr || item->state == State::Installing || item->state == State::Activating || + item->state == State::Uninstalling) + { + return; + } + if ((item->state == State::ActivationFailed || item->state == State::InstallFailed) && + item->packagePublished) + { + const auto* mod = find_loaded_mod(item->request.id); + if (mod == nullptr) { + std::error_code ec; + if (!std::filesystem::remove(item->installPath, ec) && ec) { + fail(*item, State::InstallFailed, fmt::format("Uninstall failed: {}", ec.message()), + false); + return; + } + item->state = State::Canceled; + item->message.clear(); + return; + } + item->message.clear(); + item->state = State::Uninstalling; + item->removeAfterOperation = true; + item->operation = ModLoader::instance().request_uninstall(item->request.id); + return; + } + if (item->task) { + item->cancelRequested = true; + item->pauseRequested = false; + item->resumeRequested = false; + item->message = "Canceling..."; + item->task.cancel(); + return; + } + if (item->verification) { + item->cancelRequested = true; + item->message = "Canceling..."; + item->verification.cancel(); + return; + } + remove_partial(*item); + item->completed = 0; + item->state = State::Canceled; +} + +void pause_all() { + std::vector ids; + for (const auto& item : queueItems) { + if (item.state == State::Queued || item.state == State::Retrying || + item.state == State::Downloading) + { + ids.push_back(item.key); + } + } + for (const auto& id : ids) { + pause(id); + } +} + +void clear_finished() { + std::erase_if(queueItems, [](const QueueItem& item) { + return item.state == State::Installed || item.state == State::Canceled; + }); +} + +} // namespace dusk::mods::queue diff --git a/src/dusk/mods/queue.hpp b/src/dusk/mods/queue.hpp new file mode 100644 index 0000000000..e19278863d --- /dev/null +++ b/src/dusk/mods/queue.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods::queue { + +enum class State { + Queued, + Downloading, + Paused, + Retrying, + Verifying, + Installing, + Activating, + Installed, + Failed, + InstallFailed, + ActivationFailed, + Uninstalling, + Canceled, +}; + +struct Url { + std::string url; + std::string sha256; + uint64_t size = 0; +}; + +struct LocalFile { + std::filesystem::path path; +}; + +using Source = std::variant; + +struct Request { + std::string id; + std::string name; + std::string version; + Source source; +}; + +struct Item { + // Queue key. URL installs use their mod ID; local installs receive a generated key. + std::string id; + std::string modId; + std::string name; + std::string version; + State state = State::Queued; + uint64_t completed = 0; + uint64_t total = 0; + std::string message; + int retrySeconds = 0; + bool local = false; +}; + +/** Adds an install, replacing terminal URL history for the same package ID. */ +bool enqueue(Request request, std::string* key = nullptr); + +/** Polls transfer and verification work. Call once per UI frame on the main thread. */ +void update(); +void shutdown() noexcept; + +[[nodiscard]] std::vector items(); +[[nodiscard]] std::optional find(std::string_view key); +[[nodiscard]] std::optional find_by_mod_id(std::string_view id); +[[nodiscard]] bool has_active_items(); + +void pause(std::string_view id); +void resume(std::string_view id); +void retry(std::string_view id); +void cancel(std::string_view id); +void pause_all(); +void clear_finished(); + +} // namespace dusk::mods::queue diff --git a/src/dusk/mods/svc/http.cpp b/src/dusk/mods/svc/http.cpp index 337758d5d4..99241ba2bd 100644 --- a/src/dusk/mods/svc/http.cpp +++ b/src/dusk/mods/svc/http.cpp @@ -183,7 +183,6 @@ HttpError map_error(borealis::http::Error error) { case borealis::http::Error::Io: return HTTP_ERROR_IO; case borealis::http::Error::NoBackend: - case borealis::http::Error::NotInitialized: case borealis::http::Error::Network: return HTTP_ERROR_NETWORK; default: @@ -216,7 +215,7 @@ borealis::http::Result publish_download(borealis::http::Result result, } std::filesystem::path temporary = destination; - temporary += "." + borealis::io::fs_path_to_string(staging.filename()) + ".part"; + temporary += fmt::format(".{}.part", borealis::io::fs_path_to_string(staging.filename())); std::error_code ec; std::filesystem::copy_file( staging, temporary, std::filesystem::copy_options::overwrite_existing, ec); @@ -225,7 +224,7 @@ borealis::http::Result publish_download(borealis::http::Result result, std::error_code ignored; std::filesystem::remove(temporary, ignored); result.error = borealis::http::Error::Io; - result.message = "Failed to publish download: " + copyError; + result.message = fmt::format("Failed to publish download: {}", copyError); return result; } @@ -233,14 +232,14 @@ borealis::http::Result publish_download(borealis::http::Result result, if (!borealis::io::atomic_replace(temporary, destination, replaceError)) { std::filesystem::remove(temporary, ec); result.error = borealis::http::Error::Io; - result.message = "Failed to publish download: " + replaceError; + result.message = fmt::format("Failed to publish download: {}", replaceError); return result; } std::filesystem::remove(staging, ec); return result; } catch (const std::exception& exception) { result.error = borealis::http::Error::Io; - result.message = std::string{"Failed to publish download: "} + exception.what(); + result.message = fmt::format("Failed to publish download: {}", exception.what()); return result; } catch (...) { result.error = borealis::http::Error::Io; @@ -438,14 +437,12 @@ ModResult start_request(LoadedMod& mod, const HttpRequestDesc& desc, HttpComplet .connectTimeout = desc.connect_timeout_ms != 0 ? std::chrono::milliseconds{desc.connect_timeout_ms} : DefaultTimeout, - .idleTimeout = desc.idle_timeout_ms != 0 ? - std::chrono::milliseconds{desc.idle_timeout_ms} : - DefaultTimeout, + .idleTimeout = desc.idle_timeout_ms != 0 ? std::chrono::milliseconds{desc.idle_timeout_ms} : + DefaultTimeout, .totalTimeout = desc.total_timeout_ms != 0 ? std::optional{std::chrono::milliseconds{desc.total_timeout_ms}} : std::nullopt, - .maxBodyBytes = - desc.max_body_bytes != 0 ? desc.max_body_bytes : DefaultResponseBodyBytes, + .maxBodyBytes = desc.max_body_bytes != 0 ? desc.max_body_bytes : DefaultResponseBodyBytes, }; request.headers.reserve(desc.header_count + 1); for (uint32_t i = 0; i < desc.header_count; ++i) { @@ -463,9 +460,7 @@ ModResult start_request(LoadedMod& mod, const HttpRequestDesc& desc, HttpComplet if (!immediate.has_value()) { return MOD_UNAVAILABLE; } - if (immediate->error == borealis::http::Error::NoBackend || - immediate->error == borealis::http::Error::NotInitialized) - { + if (immediate->error == borealis::http::Error::NoBackend) { return MOD_UNAVAILABLE; } task = borealis::detail::make_ready_task(std::move(*immediate)); @@ -550,7 +545,7 @@ void http_shutdown() { } bool http_available() { - return borealis::http::available() && borealis::http::initialize(); + return borealis::http::available(); } constexpr HttpService s_httpService{ diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 13c042af3d..e1f6864e6c 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -3,6 +3,9 @@ #include "dusk/app_info.hpp" #include "dusk/logging.h" #include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/log_buffer.hpp" + +#include #include #include @@ -16,10 +19,7 @@ std::unordered_map s_services; std::vector s_modules; std::string service_key(std::string_view id, const uint16_t majorVersion) { - std::string key{id}; - key.push_back('\x1f'); - key += std::to_string(majorVersion); - return key; + return fmt::format("{}\x1f{}", id, majorVersion); } const char* mod_id(const LoadedMod* mod) { @@ -318,7 +318,9 @@ bool ModLoader::resolve_service_imports(LoadedMod& mod) { continue; } - fail_mod(mod, MOD_UNAVAILABLE, + mod.active = false; + mod.suspendedByProvider = true; + log::write(mod.metadata.id, LOG_LEVEL_INFO, "suspended: {}", describe_missing_import(serviceImport->service_id.chars, serviceImport->major_version, serviceImport->min_minor_version)); return false; diff --git a/src/dusk/ui/command_console.cpp b/src/dusk/ui/command_console.cpp index 0b8d70983f..302c4c47d0 100644 --- a/src/dusk/ui/command_console.cpp +++ b/src/dusk/ui/command_console.cpp @@ -253,7 +253,7 @@ void CommandConsole::append_message(std::string text) { } void CommandConsole::limit_visible_messages() { - std::size_t visibleCount = 0; + size_t visibleCount = 0; for (auto it = mMessages.rbegin(); it != mMessages.rend(); ++it) { if (it->expired) { continue; diff --git a/src/dusk/ui/command_console.hpp b/src/dusk/ui/command_console.hpp index f40b592369..ef2f91ff90 100644 --- a/src/dusk/ui/command_console.hpp +++ b/src/dusk/ui/command_console.hpp @@ -39,8 +39,8 @@ private: static constexpr auto kMessageDuration = std::chrono::seconds{6}; static constexpr auto kFadeDuration = std::chrono::milliseconds{800}; - static constexpr std::size_t kMaxVisibleLines = 24; - static constexpr std::size_t kMaxMessageHistory = 500; + static constexpr size_t kMaxVisibleLines = 24; + static constexpr size_t kMaxMessageHistory = 500; Rml::Element* mConsole = nullptr; Rml::Element* mOutput = nullptr; diff --git a/src/dusk/ui/drop_install_modal.cpp b/src/dusk/ui/drop_install_modal.cpp new file mode 100644 index 0000000000..41a47298f5 --- /dev/null +++ b/src/dusk/ui/drop_install_modal.cpp @@ -0,0 +1,158 @@ +#include "drop_install_modal.hpp" + +#include "dusk/mods/loader/loader.hpp" +#include "dusk/mods/queue.hpp" +#include "package_row.hpp" +#include "queue_window.hpp" + +#include +#include + +#include + +namespace dusk::ui { +namespace { + +const mods::LoadedMod* installed_mod(std::string_view id) { + for (const auto& mod : mods::ModLoader::instance().mods()) { + if (mod.metadata.id == id) { + return &mod; + } + } + return nullptr; +} + +size_t valid_count(const std::vector& packages) { + return std::ranges::count(packages, true, &DropPackage::valid); +} + +std::vector prepare_packages(std::vector packages) { + std::vector batchIds; + for (auto& package : packages) { + if (!package.error.empty()) { + package.status = package.error; + } else if (std::ranges::find(batchIds, package.metadata.id) != batchIds.end()) { + package.status = "Duplicate package in this drop"; + } else if (package.hasNative && !mods::EnableCodeMods) { + package.status = "Native mods cannot be installed on this platform"; + } else if (const auto queued = mods::queue::find_by_mod_id(package.metadata.id); + queued && queued->state != mods::queue::State::Installed && + queued->state != mods::queue::State::Failed && + queued->state != mods::queue::State::InstallFailed && + queued->state != mods::queue::State::ActivationFailed && + queued->state != mods::queue::State::Canceled) + { + package.status = "Already in the install queue"; + } else if (const auto* installed = installed_mod(package.metadata.id)) { + if (!mods::ModLoader::instance().can_uninstall(*installed)) { + package.status = "Bundled mods cannot be updated in-game"; + } else if (installed->metadata.version == package.metadata.version) { + package.status = fmt::format("Reinstall {}", package.metadata.version); + package.valid = true; + } else { + package.status = fmt::format("Update from {}", installed->metadata.version); + package.valid = true; + } + } else { + package.status = "New"; + package.valid = true; + } + batchIds.push_back(package.metadata.id); + } + return packages; +} + +} // namespace + +std::vector inspect_drop_packages( + const std::vector& paths, borealis::TaskContext& context) { + std::vector packages; + packages.reserve(paths.size()); + for (const auto& path : paths) { + if (context.cancel_requested()) { + break; + } + DropPackage package{.path = path}; + std::error_code error; + package.size = std::filesystem::file_size(path, error); + if (error) { + package.error = fmt::format("Could not read package: {}", error.message()); + } else if (!mods::inspect_mod_bundle( + path, package.metadata, package.error, &package.hasNative)) + { + package.error = fmt::format("Invalid package: {}", package.error); + } + packages.push_back(std::move(package)); + context.report_progress(packages.size(), paths.size()); + } + return packages; +} + +DropInstallModal::DropInstallModal(std::vector packages) + : DropInstallModal{prepare_packages(std::move(packages)), PreparedTag{}} {} + +DropInstallModal::DropInstallModal(std::vector packages, PreparedTag) + : Modal{Props{ + .title = "Install mods?", + .bodyText = "Only install mods from trusted authors.", + .actions = + { + ModalAction{ + .label = "Cancel", + .onPressed = [](Modal& modal) { modal.pop(); }, + .isDisabled = {}, + }, + ModalAction{ + .label = fmt::format("Install {}", valid_count(packages)), + .onPressed = [this](Modal&) { install(); }, + .isDisabled = [this] { return valid_count(mPackages) == 0; }, + }, + }, + .variant = "drop-install", + }}, + mPackages{std::move(packages)} { + auto& pane = content_pane(); + for (auto& package : mPackages) { + auto& row = pane.add_child(); + const auto name = package.metadata.name.empty() ? + borealis::io::fs_path_to_string(package.path.filename()) : + fmt::format("{} {}", package.metadata.name, package.metadata.version); + const auto detail = + package.metadata.author.empty() ? + format_bytes(package.size) : + fmt::format("{} · {}", package.metadata.author, format_bytes(package.size)); + row.set_package(name, package.status, detail, package.valid ? "queued" : "failed"); + row.set_disabled(!package.valid); + } +} + +void DropInstallModal::install() { + std::string firstKey; + for (const auto& package : mPackages) { + if (!package.valid) { + continue; + } + std::string key; + if (mods::queue::enqueue( + { + .id = package.metadata.id, + .name = package.metadata.name, + .version = package.metadata.version, + .source = mods::queue::LocalFile{package.path}, + }, + &key) && + firstKey.empty()) + { + firstKey = std::move(key); + } + } + pop(); + if (!firstKey.empty()) { + if (auto* current = top_document()) { + current->cover(); + } + push_document(std::make_unique(std::move(firstKey))); + } +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/drop_install_modal.hpp b/src/dusk/ui/drop_install_modal.hpp new file mode 100644 index 0000000000..06d6389f61 --- /dev/null +++ b/src/dusk/ui/drop_install_modal.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "dusk/mod_loader.hpp" +#include "modal.hpp" + +#include + +#include +#include +#include +#include + +namespace dusk::ui { + +struct DropPackage { + std::filesystem::path path; + mods::ModMetadata metadata; + uint64_t size = 0; + std::string error; + std::string status; + bool hasNative = false; + bool valid = false; +}; + +std::vector inspect_drop_packages( + const std::vector& paths, borealis::TaskContext& context); + +class DropInstallModal final : public Modal { +public: + explicit DropInstallModal(std::vector packages); + +private: + struct PreparedTag {}; + DropInstallModal(std::vector packages, PreparedTag); + void install(); + + std::vector mPackages; +}; + +} // namespace dusk::ui diff --git a/src/dusk/ui/mod_browser.cpp b/src/dusk/ui/mod_browser.cpp index 0321f856fb..13944c92b3 100644 --- a/src/dusk/ui/mod_browser.cpp +++ b/src/dusk/ui/mod_browser.cpp @@ -3,9 +3,11 @@ #include "bool_button.hpp" #include "button.hpp" #include "dusk/mod_loader.hpp" +#include "dusk/mods/queue.hpp" #include "dusk/mods/svc/registry.hpp" #include "fmt/format.h" #include "nav_group.hpp" +#include "queue_window.hpp" #include "remote_texture_provider.hpp" #include "string_button.hpp" @@ -43,7 +45,7 @@ std::string_view sort_label(mods::catalog::Sort sort) noexcept { return iter != sortOptions.end() ? iter->label : sortOptions.front().label; } -std::string format_count(std::uint64_t value) { +std::string format_count(uint64_t value) { if (value >= 1'000'000) { return fmt::format("{:.1f}m", static_cast(value) / 1'000'000.0); } @@ -53,24 +55,24 @@ std::string format_count(std::uint64_t value) { return fmt::format("{}", value); } -std::string format_bytes(std::uint64_t bytes) { +std::string format_bytes(uint64_t bytes) { constexpr double kiB = 1024.0; constexpr double miB = kiB * 1024.0; constexpr double giB = miB * 1024.0; - if (bytes >= static_cast(giB)) { + if (bytes >= static_cast(giB)) { return fmt::format("{:.1f} GiB", static_cast(bytes) / giB); } - if (bytes >= static_cast(miB)) { + if (bytes >= static_cast(miB)) { return fmt::format("{:.1f} MiB", static_cast(bytes) / miB); } - if (bytes >= static_cast(kiB)) { + if (bytes >= static_cast(kiB)) { return fmt::format("{:.0f} KiB", static_cast(bytes) / kiB); } return fmt::format("{} B", bytes); } std::string display_date(std::string_view timestamp) { - return std::string{timestamp.substr(0, std::min(timestamp.size(), 10))}; + return std::string{timestamp.substr(0, std::min(timestamp.size(), 10))}; } std::string relative_date(std::string_view timestamp) { @@ -118,15 +120,15 @@ std::string relative_date(std::string_view timestamp) { return fmt::format("{} years ago", age / 365); } -std::string snippet(std::string_view text, std::size_t maxBytes) { +std::string snippet(std::string_view text, size_t maxBytes) { if (text.size() <= maxBytes) { return std::string{text}; } - std::size_t end = maxBytes; + size_t end = maxBytes; while (end > 0 && (static_cast(text[end]) & 0xc0) == 0x80) { --end; } - return std::string{text.substr(0, end)} + "..."; + return fmt::format("{}...", text.substr(0, end)); } void add_list_markers(Rml::Element* fragment) { @@ -157,7 +159,7 @@ void add_list_markers(Rml::Element* fragment) { } } -std::string image_source(const mods::catalog::Image& image, std::uint32_t preferredWidth) { +std::string image_source(const mods::catalog::Image& image, uint32_t preferredWidth) { const auto wider = std::ranges::find_if(image.sources, [preferredWidth](const auto& source) { return source.width >= preferredWidth; }); if (wider != image.sources.end()) { @@ -167,7 +169,7 @@ std::string image_source(const mods::catalog::Image& image, std::uint32_t prefer } void set_image(Rml::Element* element, const mods::catalog::Image& image, - std::uint32_t preferredWidth, std::string_view fit = "cover") { + uint32_t preferredWidth, std::string_view fit = "cover") { if (element == nullptr) { return; } @@ -185,6 +187,15 @@ bool installed(std::string_view id) { [id](const mods::LoadedMod& mod) { return mod.metadata.id == id; }); } +const mods::LoadedMod* installed_mod(std::string_view id) { + for (const auto& mod : mods::ModLoader::instance().mods()) { + if (mod.metadata.id == id) { + return &mod; + } + } + return nullptr; +} + bool safe_web_url(std::string_view url) { return url.starts_with("https://") || url.starts_with("http://"); } @@ -244,7 +255,7 @@ public: auto* identity = append(body, "catalog-card-identity"); append_text_element(identity, "catalog-card-title", mod.name); - append_text_element(identity, "catalog-card-author", "by " + mod.author.name); + append_text_element(identity, "catalog-card-author", fmt::format("by {}", mod.author.name)); append_text_element(body, "catalog-card-summary", snippet(mod.summary, 126)); auto* meta = append(body, "catalog-card-meta"); @@ -273,7 +284,7 @@ public: class ScreenshotViewer final : public Window { public: - ScreenshotViewer(std::vector screenshots, std::size_t index) + ScreenshotViewer(std::vector screenshots, size_t index) : Window{Props{ .tabBar = false, .styleSheets = {"res/rml/mod_browser.rcss"}, @@ -348,7 +359,7 @@ private: } std::vector mScreenshots; - std::size_t mIndex = 0; + size_t mIndex = 0; bool mRebuildRequested = false; int mRestoreNav = 0; }; @@ -401,12 +412,14 @@ public: Window::update(); } - void show_screenshot(std::size_t index) { + void show_screenshot(size_t index) { if (mDetail && index < mDetail->screenshots.size()) { push(std::make_unique(mDetail->screenshots, index)); } } + void show_downloads(const std::string& id) { push(std::make_unique(id)); } + private: void begin_fetch() { mDetail.reset(); @@ -424,10 +437,11 @@ private: auto* status = append(content, "catalog-detail-status"); if (mError.empty()) { - append_status(status, "Loading " + mSummary.name, "Fetching mod details and images..."); + append_status(status, fmt::format("Loading {}", mSummary.name), + "Fetching mod details and images..."); return; } - append_status(status, "Could not load " + mSummary.name, mError); + append_status(status, fmt::format("Could not load {}", mSummary.name), mError); auto* retryRoot = append(status, "catalog-retry-actions"); auto& retry = add_child(retryRoot, NavGroup::Props{}); retry.add_item