Mod queue, install, drag-drop

This commit is contained in:
Luke Street
2026-09-01 23:06:56 -06:00
parent 548b98072d
commit 212fca3e41
47 changed files with 3497 additions and 421 deletions
+9 -2
View File
@@ -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 ()
+1 -1
+11
View File
@@ -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
+154 -2
View File
@@ -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);
}
+36 -5
View File
@@ -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 {
+103
View File
@@ -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;
+145
View File
@@ -0,0 +1,145 @@
#include "archive.hpp"
#include <borealis/io.hpp>
#include <fmt/format.h>
#include <miniz.h>
#include <array>
#include <mutex>
#include <span>
#include <stdexcept>
#include <system_error>
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<Impl*>(opaque);
std::error_code error;
return archive.file.read_at(offset, {static_cast<std::byte*>(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<Impl>()} {
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<char, ZipMagic.size()> header{};
std::error_code error;
const auto read = m_impl->file.read_at(
0, {reinterpret_cast<std::byte*>(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<uint8_t> 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<void, decltype(&mz_free)> owner{extracted, &mz_free};
const std::span data{static_cast<const uint8_t*>(owner.get()), size};
std::vector<uint8_t> result;
result.assign(data.begin(), data.end());
return result;
}
std::vector<std::string> ZipArchive::file_names() {
std::lock_guard lock{m_impl->mutex};
std::vector<std::string> 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<mz_uint>(index), &stat)) {
throw std::runtime_error(fmt::format("Unable to inspect file in ZIP: {}", name));
}
return static_cast<size_t>(stat.m_uncomp_size);
}
} // namespace dusk::archive
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
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<uint8_t> read_file(std::string_view name);
std::vector<std::string> file_names();
size_t file_size(std::string_view name);
private:
struct Impl;
std::unique_ptr<Impl> m_impl;
};
} // namespace dusk::archive
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include <picosha2.h>
#include <cstdint>
#include <span>
#include <string>
namespace dusk::hash {
class Sha256 {
public:
void update(std::span<const uint8_t> 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
+1 -1
View File
@@ -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);
+62 -3
View File
@@ -2,6 +2,7 @@
#include <filesystem>
#include <memory>
#include <optional>
#include <ranges>
#include <string>
#include <string_view>
@@ -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<const ModOperation>;
struct ModMetaParsed {
uint32_t abiVersion = 0;
std::vector<ModMetaImport*> 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<ConfigVar<bool>> 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<ModOperation> 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<Request> m_pendingRequests;
std::vector<std::string> m_pendingFailures;
std::vector<RetiredNative> 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<ModBundle> bundle = {},
std::optional<ModMetadata> metadata = {});
void load_native(LoadedMod& mod, const std::string& dllEntry,
const std::vector<std::string>& 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<LoadedMod*> collect_lifecycle_set(LoadedMod& target);
void resume_lifecycle_set(const std::vector<LoadedMod*>& 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<decltype(std::declval<ModLoader>().mods())>;
} // namespace dusk::mods
+30 -21
View File
@@ -6,6 +6,7 @@
#include <algorithm>
#include <chrono>
#include <iterator>
#include <limits>
#include <stdexcept>
#include <string_view>
@@ -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<bool>();
}
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<std::uint64_t>();
return value.get<uint64_t>();
}
if (value.is_number_integer()) {
const auto count = value.get<std::int64_t>();
const auto count = value.get<int64_t>();
if (count >= 0) {
return static_cast<std::uint64_t>(count);
return static_cast<uint64_t>(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::uint64_t>(std::numeric_limits<int>::max())) {
const uint64_t value = required_count(object, name);
if (value > static_cast<uint64_t>(std::numeric_limits<int>::max())) {
throw std::runtime_error{fmt::format("field '{}' is too large", name)};
}
return static_cast<int>(value);
@@ -171,25 +170,25 @@ std::optional<std::string> optional_string(const json& object, const char* name)
return value.get<std::string>();
}
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<std::uint16_t>::max()) {
if (value > std::numeric_limits<uint16_t>::max()) {
throw std::runtime_error{fmt::format("field '{}' is too large", name)};
}
return static_cast<std::uint16_t>(value);
return static_cast<uint16_t>(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<std::uint32_t>::max() ||
height > std::numeric_limits<std::uint32_t>::max())
if (width > std::numeric_limits<uint32_t>::max() ||
height > std::numeric_limits<uint32_t>::max())
{
throw std::runtime_error{"image dimensions are too large"};
}
Image image{
.width = static_cast<std::uint32_t>(width),
.height = static_cast<std::uint32_t>(height),
.width = static_cast<uint32_t>(width),
.height = static_cast<uint32_t>(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<std::uint32_t>::max()) {
if (sourceWidth > std::numeric_limits<uint32_t>::max()) {
throw std::runtime_error{"image source width is too large"};
}
image.sources.push_back({
.width = static_cast<std::uint32_t>(sourceWidth),
.width = static_cast<uint32_t>(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<std::uint32_t>::max()) {
if (value > std::numeric_limits<uint32_t>::max()) {
throw std::runtime_error{"field 'mod_abi' is too large"};
}
detail.modAbi = static_cast<std::uint32_t>(value);
detail.modAbi = static_cast<uint32_t>(value);
}
const auto& banner = required_field(root, "banner");
+18 -11
View File
@@ -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<ImageSource> sources;
};
@@ -61,11 +61,11 @@ struct Mod {
std::string summary;
std::optional<Category> category;
std::vector<Tag> 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<std::string> supportedPlatforms;
std::optional<Image> 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<std::uint32_t> modAbi;
Download download;
std::optional<uint32_t> modAbi;
std::optional<Image> banner;
std::vector<Screenshot> screenshots;
std::vector<ServiceImport> 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 {
+7 -50
View File
@@ -1,68 +1,25 @@
#include "fmt/format.h"
#include "loader.hpp"
#include <span>
#include <stdexcept>
namespace dusk::mods {
ModBundleZip::ModBundleZip(std::vector<u8>&& 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<u8> 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<u8*>(ptr), size);
std::vector vec(data.begin(), data.end());
mz_free(ptr);
return vec;
return m_archive.read_file(fileName);
}
std::vector<std::string> ModBundleZip::getFileNames() {
std::lock_guard lock{m_mutex};
std::vector<std::string> 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
+586 -93
View File
@@ -3,6 +3,7 @@
#include "dusk/mod_loader.hpp"
#include <borealis/io.hpp>
#include <fmt/format.h>
#include <algorithm>
#include <chrono>
@@ -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<ModBundle> load_bundle(const std::filesystem::path& modPath, bool fromDir) {
std::unique_ptr<ModBundle> load_bundle(const fs::path& modPath, bool fromDir) {
if (fromDir) {
return std::make_unique<ModBundleDisk>(modPath);
} else {
std::vector<u8> data = io::FileStream::ReadAllBytes(modPath);
return std::make_unique<ModBundleZip>(std::move(data));
return std::make_unique<ModBundleZip>(modPath);
}
}
@@ -193,6 +194,54 @@ NativeLocateResult locate_native_runtime(ModBundle& bundle) {
result.runtimeEntries.end());
return result;
}
void complete_operation(const std::shared_ptr<ModOperation>& 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<ModBundle> 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<ModBundle> bundle, std::optional<ModMetadata> 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<LoadedMod>());
@@ -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<ModContext>();
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<fs::directory_entry> 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<uint32_t>(dirIndex));
(void)sync_path(entry.path(), false, static_cast<uint32_t>(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<ModOperation>();
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<ModOperation>();
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<ModOperation>();
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<ModOperation>();
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<LoadedMod*>& 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<LoadedMod*> blocked;
std::vector<LoadedMod*> 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<LoadedMod*> 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<LoadedMod*> 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<ModBundle> bundle;
std::optional<ModMetadata> 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<Request> 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();
+4 -8
View File
@@ -1,10 +1,9 @@
#pragma once
#include <filesystem>
#include <mutex>
#include <string_view>
#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<u8>&& data);
~ModBundleZip() override;
explicit ModBundleZip(const std::filesystem::path& path);
~ModBundleZip() override = default;
std::vector<u8> readFile(const std::string& fileName) override;
std::vector<std::string> getFileNames() override;
size_t getFileSize(const std::string& fileName) override;
private:
std::vector<uint8_t> 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 {
+884
View File
@@ -0,0 +1,884 @@
#include "queue.hpp"
#include "dusk/hash.hpp"
#include "dusk/mod_loader.hpp"
#include "dusk/ui/ui.hpp"
#include <borealis/http.hpp>
#include <borealis/io.hpp>
#include <borealis/task.hpp>
#include <fmt/format.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <vector>
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<borealis::http::Result> task;
borealis::Task<VerifyResult> verification;
ModOperationHandle operation;
bool packagePublished = false;
bool removeAfterOperation = false;
bool pauseRequested = false;
bool resumeRequested = false;
bool cancelRequested = false;
};
std::vector<QueueItem> 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<Url>(&item.request.source);
}
const LocalFile* local_source(const QueueItem& item) {
return std::get_if<LocalFile>(&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<char>(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<uint8_t, 64 * 1024> buffer{};
uint64_t completed = 0;
while (input) {
if (context.cancel_requested()) {
error = "Canceled";
return {};
}
input.read(reinterpret_cast<char*>(buffer.data()), buffer.size());
const auto count = input.gcount();
if (count > 0) {
hash.update(std::span{buffer.data(), static_cast<size_t>(count)});
completed += static_cast<uint64_t>(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<char, 64 * 1024> 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<uint64_t>(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<borealis::http::Result> 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<int>(std::chrono::ceil<std::chrono::seconds>(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<Url>(&request.source);
const auto* local = std::get_if<LocalFile>(&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<Item> items() {
std::vector<Item> result;
result.reserve(queueItems.size());
for (const auto& item : queueItems) {
result.push_back(snapshot(item));
}
return result;
}
std::optional<Item> find(std::string_view id) {
const auto* item = find_queue_item(id);
return item == nullptr ? std::nullopt : std::optional<Item>{snapshot(*item)};
}
std::optional<Item> 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<Item>{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<std::string> 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
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <string_view>
#include <variant>
#include <vector>
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<Url, LocalFile>;
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<Item> items();
[[nodiscard]] std::optional<Item> find(std::string_view key);
[[nodiscard]] std::optional<Item> 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
+9 -14
View File
@@ -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{
+7 -5
View File
@@ -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 <fmt/format.h>
#include <ranges>
#include <string_view>
@@ -16,10 +19,7 @@ std::unordered_map<std::string, ServiceRecord> s_services;
std::vector<const ServiceModule*> 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;
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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;
+158
View File
@@ -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 <borealis/io.hpp>
#include <fmt/format.h>
#include <algorithm>
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<DropPackage>& packages) {
return std::ranges::count(packages, true, &DropPackage::valid);
}
std::vector<DropPackage> prepare_packages(std::vector<DropPackage> packages) {
std::vector<std::string> 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<DropPackage> inspect_drop_packages(
const std::vector<std::filesystem::path>& paths, borealis::TaskContext& context) {
std::vector<DropPackage> 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<DropPackage> packages)
: DropInstallModal{prepare_packages(std::move(packages)), PreparedTag{}} {}
DropInstallModal::DropInstallModal(std::vector<DropPackage> 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<PackageRow>();
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<QueueWindow>(std::move(firstKey)));
}
}
} // namespace dusk::ui
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include "dusk/mod_loader.hpp"
#include "modal.hpp"
#include <borealis/task.hpp>
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
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<DropPackage> inspect_drop_packages(
const std::vector<std::filesystem::path>& paths, borealis::TaskContext& context);
class DropInstallModal final : public Modal {
public:
explicit DropInstallModal(std::vector<DropPackage> packages);
private:
struct PreparedTag {};
DropInstallModal(std::vector<DropPackage> packages, PreparedTag);
void install();
std::vector<DropPackage> mPackages;
};
} // namespace dusk::ui
+296 -24
View File
@@ -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<double>(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<std::uint64_t>(giB)) {
if (bytes >= static_cast<uint64_t>(giB)) {
return fmt::format("{:.1f} GiB", static_cast<double>(bytes) / giB);
}
if (bytes >= static_cast<std::uint64_t>(miB)) {
if (bytes >= static_cast<uint64_t>(miB)) {
return fmt::format("{:.1f} MiB", static_cast<double>(bytes) / miB);
}
if (bytes >= static_cast<std::uint64_t>(kiB)) {
if (bytes >= static_cast<uint64_t>(kiB)) {
return fmt::format("{:.0f} KiB", static_cast<double>(bytes) / kiB);
}
return fmt::format("{} B", bytes);
}
std::string display_date(std::string_view timestamp) {
return std::string{timestamp.substr(0, std::min<std::size_t>(timestamp.size(), 10))};
return std::string{timestamp.substr(0, std::min<size_t>(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<unsigned char>(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<mods::catalog::Screenshot> screenshots, std::size_t index)
ScreenshotViewer(std::vector<mods::catalog::Screenshot> screenshots, size_t index)
: Window{Props{
.tabBar = false,
.styleSheets = {"res/rml/mod_browser.rcss"},
@@ -348,7 +359,7 @@ private:
}
std::vector<mods::catalog::Screenshot> 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<ScreenshotViewer>(mDetail->screenshots, index));
}
}
void show_downloads(const std::string& id) { push(std::make_unique<QueueWindow>(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<NavGroup>(retryRoot, NavGroup::Props{});
retry.add_item<Button>("Retry").on_pressed([this] { begin_fetch(); });
@@ -440,6 +454,250 @@ private:
bool mRebuildRequested = false;
};
class CatalogInstallButton final : public Button {
public:
CatalogInstallButton(
Rml::Element* parent, ModBrowserDetail& window, const mods::catalog::Detail& detail)
: Button{parent, Props{}}, mWindow{window}, mRequest{
.id = detail.mod.id,
.name = detail.mod.name,
.version = detail.mod.version,
.source =
mods::queue::Url{
.url = detail.download.url,
.sha256 = detail.download.sha256,
.size = detail.download.size,
},
} {
mRoot->SetClass("catalog-install-action", true);
mCaption = append(parent, "catalog-install-caption");
on_pressed([this] { press(); });
update();
}
void update() override {
auto queued = mods::queue::find_by_mod_id(mRequest.id);
const auto* local = installed_mod(mRequest.id);
if (queued && (queued->version != mRequest.version ||
(queued->state == mods::queue::State::Installed &&
(local == nullptr || local->metadata.version != mRequest.version))))
{
queued.reset();
}
std::string glyph = "\uE2C4";
std::string label;
std::string caption = format_bytes(package_size());
std::string state = "idle";
float progress = 0.0f;
bool disabled = false;
if (queued && queued->state != mods::queue::State::Canceled) {
using enum mods::queue::State;
state = state_class(queued->state);
progress = queued->total == 0 ? 0.0f :
std::clamp(static_cast<float>(queued->completed) /
static_cast<float>(queued->total),
0.0f, 1.0f);
switch (queued->state) {
case Queued:
glyph = "\uE8B5";
label = "Queued";
if (const auto ahead = queue_items_ahead(mRequest.id); ahead != 0) {
caption = fmt::format("{} ahead · opens the queue", ahead);
} else {
caption = "Next · opens the queue";
}
break;
case Downloading:
label = fmt::format(
"{} / {}", format_bytes(queued->completed), format_bytes(queued->total));
caption = "Tap to open the queue";
break;
case Paused:
glyph = "\uE037";
label = "Resume";
caption = fmt::format("{} kept on disk", format_bytes(queued->completed));
break;
case Retrying:
glyph = "\uE002";
label = fmt::format("Retrying in {}s", queued->retrySeconds);
caption = "Network error · keeps retrying itself";
break;
case Verifying:
glyph = "\uE8B5";
label = "Verifying…";
caption = "Checking package integrity";
break;
case Installing:
glyph = "\uE8B5";
label = "Installing…";
caption = "Installing and activating";
break;
case Activating:
glyph = "\uE8B5";
label = "Activating…";
caption = "Retrying mod activation";
break;
case Installed:
glyph = "\uE86C";
label = "Installed";
caption = fmt::format("Installed · {} · {}", format_bytes(queued->total),
local != nullptr && local->active ? "enabled" : "disabled");
progress = 1.0f;
disabled = true;
break;
case Failed:
glyph = "\uE5D5";
label = "Retry download";
caption = queued->message.empty() ? "Download failed" : queued->message;
progress = 1.0f;
break;
case InstallFailed:
glyph = "\uE5D5";
label = "Retry install";
caption = queued->message.empty() ? "Install failed" : queued->message;
progress = 1.0f;
break;
case ActivationFailed:
glyph = "\uE5D5";
label = "Retry activation";
caption = queued->message.empty() ? "Activation failed" : queued->message;
progress = 1.0f;
break;
case Uninstalling:
glyph = "\uE8B5";
label = "Uninstalling…";
caption = "Removing the installed package";
progress = 1.0f;
break;
case Canceled:
break;
}
}
if (label.empty()) {
const bool current = local != nullptr && local->metadata.version == mRequest.version;
const bool updateable = local != nullptr && !current &&
local->origin == mods::ModOrigin::User && !local->inPlace;
if (current || (local != nullptr && !updateable)) {
glyph = "\uE86C";
label = "Installed";
caption = fmt::format("Installed · {} · {}", format_bytes(package_size()),
local != nullptr && local->active ? "enabled" : "disabled");
state = "installed";
progress = 1.0f;
disabled = true;
} else {
label = updateable ? "Update" : "Install";
}
}
if (mLabel != label || mGlyph != glyph) {
::dusk::ui::clear_children(mRoot);
append_text(append(mRoot, "icon"), glyph);
append_text_element(mRoot, "catalog-action-label", label);
mProgress = append(mRoot, "progress");
mLabel = std::move(label);
mGlyph = std::move(glyph);
}
set_text_content(mCaption, caption);
for (const auto* candidate : {"idle", "queued", "downloading", "paused", "retrying",
"installing", "installed", "failed"})
{
mRoot->SetClass(candidate, state == candidate);
mCaption->SetClass(candidate, state == candidate);
}
if (mProgress != nullptr) {
mProgress->SetAttribute("value", progress);
mProgress->SetProperty(
"display", state == "idle" || state == "installed" ? "none" : "block");
}
set_disabled(disabled);
Button::update();
}
private:
static size_t queue_items_ahead(std::string_view id) {
size_t result = 0;
for (const auto& item : mods::queue::items()) {
if (item.modId == id) {
break;
}
if (item.state != mods::queue::State::Installed &&
item.state != mods::queue::State::Failed &&
item.state != mods::queue::State::InstallFailed &&
item.state != mods::queue::State::ActivationFailed &&
item.state != mods::queue::State::Canceled)
{
++result;
}
}
return result;
}
static std::string state_class(mods::queue::State state) {
using enum mods::queue::State;
switch (state) {
case Queued:
return "queued";
case Downloading:
return "downloading";
case Paused:
return "paused";
case Retrying:
return "retrying";
case Verifying:
case Installing:
case Activating:
case Uninstalling:
return "installing";
case Installed:
return "installed";
case Failed:
case InstallFailed:
case ActivationFailed:
return "failed";
case Canceled:
return "idle";
}
return "idle";
}
void press() {
auto queued = mods::queue::find_by_mod_id(mRequest.id);
const auto* local = installed_mod(mRequest.id);
if (queued && (queued->version != mRequest.version ||
(queued->state == mods::queue::State::Installed &&
(local == nullptr || local->metadata.version != mRequest.version))))
{
queued.reset();
}
if (queued && queued->state != mods::queue::State::Canceled &&
queued->state != mods::queue::State::Installed)
{
mWindow.show_downloads(queued->id);
return;
}
if (!mods::queue::enqueue(mRequest)) {
push_toast({
.type = "warning",
.title = "Could not start download",
.content = "The catalog download descriptor is invalid.",
.duration = std::chrono::seconds{5},
});
}
}
ModBrowserDetail& mWindow;
mods::queue::Request mRequest;
Rml::Element* mCaption = nullptr;
Rml::Element* mProgress = nullptr;
std::string mLabel;
std::string mGlyph;
uint64_t package_size() const { return std::get<mods::queue::Url>(mRequest.source).size; }
};
DetailContent::DetailContent(
Rml::Element* root, ModBrowserDetail& window, const mods::catalog::Detail& detail)
: NavGroup{root, Props{
@@ -479,15 +737,23 @@ DetailContent::DetailContent(
detail.mod.category ? detail.mod.category->name : "Uncategorized");
auto* title = append(detailHeading, "h1");
append_text(title, detail.mod.name);
append_text_element(title, "small", "v" + detail.mod.version);
append_text_element(title, "small", fmt::format("v{}", detail.mod.version));
auto* author = append(detailHeading, "p");
append_text(author, "by " + detail.mod.author.name + " ");
append_text(author, fmt::format("by {} ", detail.mod.author.name));
if (detail.mod.author.official) {
append_text_element(author, "catalog-official-badge", "Official");
}
if (detail.mod.icon) {
set_image(detailIconImage, *detail.mod.icon, 256);
}
auto* installRoot = append(identity, "catalog-install-control");
auto& installControl =
add_existing_item<NavGroup>(installRoot, Props{
.layout = Layout::Vertical,
.horizontalBoundary = Boundary::Bubble,
.verticalBoundary = Boundary::Bubble,
});
installControl.add_item<CatalogInstallButton>(window, detail);
auto* stats = append(mRoot, "catalog-detail-stats");
append_stat(stats, "\uF090", format_count(detail.mod.downloads), " downloads");
@@ -529,8 +795,8 @@ DetailContent::DetailContent(
.horizontalBoundary = Boundary::Bubble,
.verticalBoundary = Boundary::Bubble,
});
const std::size_t shown = std::min<std::size_t>(detail.screenshots.size(), 3);
for (std::size_t index = 0; index < shown; ++index) {
const size_t shown = std::min<size_t>(detail.screenshots.size(), 3);
for (size_t index = 0; index < shown; ++index) {
auto& screenshot = gallery.add_item<Button>(Button::Props{});
screenshot.root()->SetClass("catalog-screenshot", true);
screenshot.root()->SetClass("primary", index == 0);
@@ -547,7 +813,7 @@ DetailContent::DetailContent(
add_existing_item<ScrollAnchor>(dependencies);
append_text(append(dependencies, "h2"), "Dependencies");
auto* dependencyList = append(dependencies, "catalog-dependencies");
std::size_t requiredDusklight = 0;
size_t requiredDusklight = 0;
bool dusklightSatisfied = true;
for (const auto& import : detail.serviceImports) {
if (import.optional) {
@@ -585,7 +851,7 @@ DetailContent::DetailContent(
auto* changelogTitle = append(changelog, "h2");
append_text(changelogTitle, "Changelog ");
append_text_element(changelogTitle, "small",
"v" + detail.mod.version + " · " + display_date(detail.mod.updatedAt));
fmt::format("v{} · {}", detail.mod.version, display_date(detail.mod.updatedAt)));
auto* changelogFragment = append(changelog, "catalog-fragment");
if (detail.changelogHtml.empty()) {
append_text_element(changelogFragment, "p", "No changelog was provided.");
@@ -624,6 +890,7 @@ ModBrowser::ModBrowser()
: Window{Props{.tabBar = false, .styleSheets = {"res/rml/mod_browser.rcss"}}} {
mRoot->SetClass("mod-browser", true);
mQuery.sort = mods::catalog::Sort::Updated;
mLoaderGeneration = mods::ModLoader::instance().generation();
mState = borealis::http::available() ? State::Loading : State::Unavailable;
set_content([this](Rml::Element* content) { build_content(content); });
if (mState == State::Loading) {
@@ -706,7 +973,7 @@ void ModBrowser::build_content(Rml::Element* content) {
std::ranges::find(mPage->categories, mQuery.category, &mods::catalog::Category::slug);
return iter == mPage->categories.end() ? std::string{"All mods"} : iter->name;
}();
const std::uint64_t total = mPage ? mPage->pagination.total : 0;
const uint64_t total = mPage ? mPage->pagination.total : 0;
append_text_element(heading, "h1", categoryName);
append_text_element(heading, "catalog-results-summary",
fmt::format("{} mods · sorted by {}", total, sort_label(mQuery.sort)));
@@ -883,6 +1150,11 @@ void ModBrowser::cycle_sort() {
}
void ModBrowser::update() {
const auto loaderGeneration = mods::ModLoader::instance().generation();
if (loaderGeneration != mLoaderGeneration) {
mLoaderGeneration = loaderGeneration;
mRebuildRequested = true;
}
if (mFetch && mFetch.ready()) {
try {
if (auto result = mFetch.try_take()) {
+2
View File
@@ -3,6 +3,7 @@
#include "dusk/mods/catalog.hpp"
#include "window.hpp"
#include <cstdint>
#include <optional>
#include <string>
@@ -43,6 +44,7 @@ private:
std::string mError;
State mState = State::Loading;
FocusTarget mFocusTarget = FocusTarget::Default;
uint64_t mLoaderGeneration = 0;
bool mRebuildRequested = false;
};
+2 -1
View File
@@ -1,6 +1,7 @@
#include "modal.hpp"
#include <algorithm>
#include <utility>
namespace dusk::ui {
@@ -48,7 +49,7 @@ void Modal::update() {
button->update();
}
if (mPendingAction) {
auto action = std::move(mPendingAction);
auto action = std::exchange(mPendingAction, {});
action(*this);
}
WindowSmall::update();
+224 -51
View File
@@ -1,23 +1,29 @@
#include "mods_window.hpp"
#include "dusk/mod_loader.hpp"
#include "dusk/mods/queue.hpp"
#include "dusk/mods/svc/ui.hpp"
#include "fmt/format.h"
#include "fmt/ranges.h"
#include "logs_window.hpp"
#include "mod_browser.hpp"
#include "mod_texture_provider.hpp"
#include "modal.hpp"
#include "mods/svc/http.h"
#include "pane.hpp"
#include "queue_window.hpp"
#include <borealis/http.hpp>
#include "Z2AudioLib/Z2SeMgr.h"
#include "m_Do/m_Do_audio.h"
#include <algorithm>
#include <memory>
#include <ranges>
#include <string>
#include <string_view>
#include <vector>
namespace dusk::ui {
namespace {
@@ -60,7 +66,7 @@ std::string snippet(std::string_view text, size_t maxBytes) {
while (end > 0 && (static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) {
--end;
}
return std::string{text.substr(0, end)} + "...";
return fmt::format("{}...", text.substr(0, end));
}
class ModListEntry : public FluentComponent<ModListEntry> {
@@ -81,9 +87,9 @@ public:
auto* info = append(mRoot, "mod-entry-info");
auto* name = append(info, "mod-entry-name");
append_text(append(name, "mod-entry-name-text"), mod.metadata.name);
append_text(append(name, "mod-entry-version"), "v" + mod.metadata.version);
append_text(append(name, "mod-entry-version"), fmt::format("v{}", mod.metadata.version));
auto* sub = append(info, "mod-entry-sub");
append_text(sub, mod.metadata.author + " - ");
append_text(sub, fmt::format("{} - ", mod.metadata.author));
auto* statusElement = append(sub, "mod-entry-status");
if (status.badgeClass[0] != '\0') {
statusElement->SetClass(status.badgeClass, true);
@@ -106,10 +112,101 @@ public:
}
};
class BrowseModsEntry final : public FluentComponent<BrowseModsEntry> {
public:
BrowseModsEntry(Rml::Element* parent, std::function<void()> onOpen)
: FluentComponent{append(parent, "mod-entry")} {
mRoot->SetClass("mod-browser-entry", true);
auto* icon = append(mRoot, "icon");
icon->SetClass("mod-icon", true);
icon->SetClass("mod-browser-icon", true);
auto* info = append(mRoot, "mod-entry-info");
auto* name = append(info, "mod-entry-name");
append_text(append(name, "mod-entry-name-text"), "Browse online mods");
append_text(append(info, "mod-entry-sub"), "Dusklight catalog");
append_text(append(info, "mod-entry-desc"), "Discover and install published mods.");
on_nav_command([callback = std::move(onOpen)](Rml::Event&, NavCommand cmd) {
if (cmd != NavCommand::Confirm) {
return false;
}
callback();
return true;
});
}
};
class InstallQueueEntry final : public FluentComponent<InstallQueueEntry> {
public:
InstallQueueEntry(Rml::Element* parent, std::function<void()> onOpen)
: FluentComponent{append(parent, "mod-entry")} {
mRoot->SetClass("mod-installs-entry", true);
auto* icon = append(mRoot, "icon");
icon->SetClass("mod-icon", true);
icon->SetClass("mod-installs-icon", true);
auto* info = append(mRoot, "mod-entry-info");
auto* name = append(info, "mod-entry-name");
append_text(append(name, "mod-entry-name-text"), "Installs");
mSummary = append(info, "mod-entry-sub");
mProgress = append(info, "progress");
on_nav_command([callback = std::move(onOpen)](Rml::Event&, NavCommand cmd) {
if (cmd != NavCommand::Confirm) {
return false;
}
callback();
return true;
});
update();
}
void update() override {
const auto queueItems = mods::queue::items();
const mods::queue::Item* current = nullptr;
size_t active = 0;
for (const auto& item : queueItems) {
if (item.state == mods::queue::State::Installed ||
item.state == mods::queue::State::Failed ||
item.state == mods::queue::State::InstallFailed ||
item.state == mods::queue::State::ActivationFailed ||
item.state == mods::queue::State::Canceled)
{
continue;
}
++active;
if (current == nullptr) {
current = &item;
}
}
if (current == nullptr) {
set_text_content(mSummary, fmt::format("{} finished", queueItems.size()));
mProgress->SetProperty("display", "none");
} else {
const float progress = current->total == 0 ?
0.0f :
std::clamp(static_cast<float>(current->completed) /
static_cast<float>(current->total),
0.0f, 1.0f);
set_text_content(
mSummary, fmt::format("{} in queue · {:.0f}%", active, progress * 100.0f));
mProgress->SetAttribute("value", progress);
mProgress->SetProperty("display", "block");
}
Component::update();
}
private:
Rml::Element* mSummary = nullptr;
Rml::Element* mProgress = nullptr;
};
class ModDetailHeader : public FluentComponent<ModDetailHeader> {
public:
ModDetailHeader(
Rml::Element* parent, const mods::LoadedMod& mod, std::function<void()> onShowLogs)
ModDetailHeader(Rml::Element* parent, const mods::LoadedMod& mod,
std::function<void()> onShowLogs, std::function<void()> onUninstall)
: FluentComponent{append(parent, "mod-header")} {
const bool hasBanner = !mod.metadata.bannerPath.empty();
mRoot->SetClass(hasBanner ? "has-banner" : "no-banner", true);
@@ -135,6 +232,9 @@ public:
});
}
make_button(actions, "Logs").on_pressed(std::move(onShowLogs));
if (mods::ModLoader::instance().can_uninstall(mod)) {
make_button(actions, "Uninstall").on_pressed(std::move(onUninstall));
}
listen(Rml::EventId::Keydown, [this](Rml::Event& event) {
const auto cmd = map_nav_event(event);
@@ -185,16 +285,8 @@ private:
ModsWindow::ModsWindow() : Window{Props{.tabBar = false, .styleSheets = {"res/rml/mods.rcss"}}} {
mRoot->SetClass("mods", true);
for (auto& trackedMod : mods::ModLoader::instance().mods()) {
mSnapshot.push_back({
.mod = &trackedMod,
.active = trackedMod.active,
.loadFailed = trackedMod.loadFailed,
.enabled = mod_enabled(trackedMod),
.suspended = trackedMod.suspendedByProvider,
.cacheGeneration = trackedMod.cacheGeneration,
});
}
refresh_snapshot();
mQueueItemCount = mods::queue::items().size();
set_content([this](Rml::Element* content) { build_content(content); });
}
@@ -209,21 +301,35 @@ void ModsWindow::build_content(Rml::Element* content) {
auto& detailPane = add_child<Pane>(content, Pane::Type::Uncontrolled);
detailPane.root()->SetClass("mod-detail", true);
bool hasUtilityEntries = false;
if (borealis::http::available()) {
auto& browse = listPane.add_button("Browse online mods");
auto& browse =
listPane.add_child<BrowseModsEntry>([this] { push(std::make_unique<ModBrowser>()); });
mBrowserEntry = &browse;
browse.root()->SetClass("mod-browser-entry", true);
browse.on_pressed([this] { push(std::make_unique<ModBrowser>()); });
hasUtilityEntries = true;
listPane.register_control(browse, detailPane, [this](Pane& pane) {
mBrowserSelected = true;
mSelectedMod = nullptr;
mSelectedModId.clear();
build_browser_detail(pane);
mark_current_entry();
});
}
if (mods::ModLoader::instance().mods().empty()) {
if (mQueueItemCount != 0) {
listPane.add_child<InstallQueueEntry>([this] { push(std::make_unique<QueueWindow>()); });
hasUtilityEntries = true;
}
const bool hasInstalledMods = !mods::ModLoader::instance().mods().empty();
if (hasUtilityEntries && hasInstalledMods) {
append(listPane.root(), "mod-list-separator");
}
if (!hasInstalledMods) {
listPane.add_text("No mods installed.");
mSelectedMod = nullptr;
mSelectedModId.clear();
if (borealis::http::available()) {
mBrowserSelected = true;
build_browser_detail(detailPane);
@@ -239,6 +345,7 @@ void ModsWindow::build_content(Rml::Element* content) {
listPane.register_control(entry, detailPane, [this, tracked = &trackedMod](Pane& pane) {
mBrowserSelected = false;
mSelectedMod = tracked;
mSelectedModId = tracked->metadata.id;
pane.clear();
build_detail(pane, *tracked);
mark_current_entry();
@@ -247,11 +354,21 @@ void ModsWindow::build_content(Rml::Element* content) {
if (mBrowserSelected && mBrowserEntry != nullptr) {
mSelectedMod = nullptr;
mSelectedModId.clear();
build_browser_detail(detailPane);
} else if (mSelectedMod == nullptr) {
mSelectedMod = mEntryMods.front();
build_detail(detailPane, *mSelectedMod);
} else {
mSelectedMod = nullptr;
if (!mSelectedModId.empty()) {
const auto selected = std::ranges::find_if(
mEntryMods, [this](const auto* mod) { return mod->metadata.id == mSelectedModId; });
if (selected != mEntryMods.end()) {
mSelectedMod = *selected;
}
}
if (mSelectedMod == nullptr) {
mSelectedMod = mEntryMods.front();
mSelectedModId = mSelectedMod->metadata.id;
}
build_detail(detailPane, *mSelectedMod);
}
mark_current_entry();
@@ -266,11 +383,12 @@ void ModsWindow::build_browser_detail(Pane& pane) {
void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
pane.root()->SetAttribute("mod-id", mod.metadata.id);
pane.add_child<ModDetailHeader>(
mod, [this, id = mod.metadata.id] { push(std::make_unique<LogsWindow>(id)); });
mod, [this, id = mod.metadata.id] { push(std::make_unique<LogsWindow>(id)); },
[this, tracked = &mod] { confirm_uninstall(*tracked); });
auto* title = append(pane.root(), "mod-title");
append_text(title, mod.metadata.name + " ");
append_text(append(title, "mod-title-version"), "v" + mod.metadata.version);
append_text(title, fmt::format("{} ", mod.metadata.name));
append_text(append(title, "mod-title-version"), fmt::format("v{}", mod.metadata.version));
if (mod.loadFailed || mod.suspendedByProvider) {
const auto status = mod_status(mod);
append_text(title, "\u00a0");
@@ -284,7 +402,7 @@ void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
badge->SetClass("network", true);
append_text(badge, "Network");
}
append_text(append(pane.root(), "mod-author"), "by " + mod.metadata.author);
append_text(append(pane.root(), "mod-author"), fmt::format("by {}", mod.metadata.author));
if (mod.loadFailed && !mod.failureReason.empty()) {
auto* row = append(pane.root(), "mod-info-row");
@@ -293,32 +411,27 @@ void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
append_text(label, "Reason");
append_text(append(row, "mod-info-value"), mod.failureReason);
} else if (mod.suspendedByProvider) {
std::string providers;
std::vector<std::string_view> providers;
for (const auto& edge : mod.dependencies) {
if (edge.required && edge.mod != nullptr && !edge.mod->active) {
if (!providers.empty()) {
providers += ", ";
}
providers += edge.mod->metadata.name;
providers.push_back(edge.mod->metadata.name);
}
}
auto* row = append(pane.root(), "mod-info-row");
append_text(append(row, "mod-info-label"), "Waiting on");
append_text(append(row, "mod-info-value"), providers);
append_text(append(row, "mod-info-value"), fmt::format("{}", fmt::join(providers, ", ")));
}
std::string activeDependents;
std::vector<std::string_view> activeDependents;
for (const auto& edge : mod.dependents) {
if (edge.mod != nullptr && edge.mod->active) {
if (!activeDependents.empty()) {
activeDependents += ", ";
}
activeDependents += edge.mod->metadata.name;
activeDependents.push_back(edge.mod->metadata.name);
}
}
if (mod.active && !activeDependents.empty()) {
append_text(append(pane.root(), "mod-restart-note"),
fmt::format("Disabling or reloading also restarts: {}", activeDependents));
fmt::format(
"Disabling or reloading also restarts: {}", fmt::join(activeDependents, ", ")));
}
if (!mod.metadata.description.empty()) {
@@ -331,6 +444,54 @@ void ModsWindow::build_detail(Pane& pane, mods::LoadedMod& mod) {
}
}
void ModsWindow::confirm_uninstall(const mods::LoadedMod& mod) {
std::string body = "The mod package will be removed. Settings and saved data are kept.";
std::vector<std::string_view> dependents;
for (const auto& edge : mod.dependents) {
if (!edge.required || edge.mod == nullptr) {
continue;
}
dependents.push_back(edge.mod->metadata.name);
}
if (!dependents.empty()) {
body = fmt::format(
"{} Required dependents will be suspended: {}.", body, fmt::join(dependents, ", "));
}
push(std::make_unique<Modal>(Modal::Props{
.title = fmt::format("Uninstall {}?", mod.metadata.name),
.bodyText = std::move(body),
.actions =
{
ModalAction{"Cancel", [](Modal& modal) { modal.pop(); }, {}},
ModalAction{"Uninstall",
[id = mod.metadata.id](Modal& modal) {
mods::ModLoader::instance().request_uninstall(id);
modal.pop();
},
{}},
},
.variant = "danger",
.icon = "warning",
}));
}
void ModsWindow::refresh_snapshot() {
mSnapshot.clear();
auto& loader = mods::ModLoader::instance();
mLoaderGeneration = loader.generation();
for (auto& trackedMod : loader.mods()) {
mSnapshot.push_back({
.mod = &trackedMod,
.active = trackedMod.active,
.loadFailed = trackedMod.loadFailed,
.enabled = mod_enabled(trackedMod),
.suspended = trackedMod.suspendedByProvider,
.cacheGeneration = trackedMod.cacheGeneration,
});
}
}
void ModsWindow::mark_current_entry() {
if (mBrowserEntry != nullptr) {
mBrowserEntry->root()->SetClass("current", mBrowserSelected);
@@ -341,21 +502,33 @@ void ModsWindow::mark_current_entry() {
}
void ModsWindow::update() {
bool dirty = false;
for (auto& snapshot : mSnapshot) {
const auto& mod = *snapshot.mod;
if (mod.active != snapshot.active || mod.loadFailed != snapshot.loadFailed ||
mod_enabled(mod) != snapshot.enabled || mod.suspendedByProvider != snapshot.suspended ||
mod.cacheGeneration != snapshot.cacheGeneration)
{
snapshot.active = mod.active;
snapshot.loadFailed = mod.loadFailed;
snapshot.enabled = mod_enabled(mod);
snapshot.suspended = mod.suspendedByProvider;
snapshot.cacheGeneration = mod.cacheGeneration;
dirty = true;
auto& loader = mods::ModLoader::instance();
bool dirty = loader.generation() != mLoaderGeneration;
if (dirty) {
mSelectedMod = nullptr;
refresh_snapshot();
} else {
for (auto& snapshot : mSnapshot) {
const auto& mod = *snapshot.mod;
if (mod.active != snapshot.active || mod.loadFailed != snapshot.loadFailed ||
mod_enabled(mod) != snapshot.enabled ||
mod.suspendedByProvider != snapshot.suspended ||
mod.cacheGeneration != snapshot.cacheGeneration)
{
snapshot.active = mod.active;
snapshot.loadFailed = mod.loadFailed;
snapshot.enabled = mod_enabled(mod);
snapshot.suspended = mod.suspendedByProvider;
snapshot.cacheGeneration = mod.cacheGeneration;
dirty = true;
}
}
}
const auto queueItemCount = mods::queue::items().size();
if (queueItemCount != mQueueItemCount) {
mQueueItemCount = queueItemCount;
dirty = true;
}
if (dirty) {
auto* focused = mDocument != nullptr ? mDocument->GetFocusLeafNode() : nullptr;
bool hadContentFocus = false;
+7
View File
@@ -2,6 +2,8 @@
#include "window.hpp"
#include <cstddef>
#include <string>
#include <vector>
#include "dusk/mod_loader.hpp"
@@ -28,6 +30,8 @@ private:
void build_content(Rml::Element* content);
void build_browser_detail(Pane& pane);
void build_detail(Pane& pane, mods::LoadedMod& mod);
void confirm_uninstall(const mods::LoadedMod& mod);
void refresh_snapshot();
void mark_current_entry();
std::vector<ModSnapshot> mSnapshot;
@@ -35,6 +39,9 @@ private:
std::vector<mods::LoadedMod*> mEntryMods;
Component* mBrowserEntry = nullptr;
mods::LoadedMod* mSelectedMod = nullptr;
std::string mSelectedModId;
uint64_t mLoaderGeneration = 0;
size_t mQueueItemCount = 0;
bool mBrowserSelected = false;
};
+121
View File
@@ -0,0 +1,121 @@
#include "package_row.hpp"
#include "fmt/format.h"
namespace dusk::ui {
namespace {
Rml::Element* create_row(Rml::Element* parent) {
auto element = parent->GetOwnerDocument()->CreateElement("package-row");
return parent->AppendChild(std::move(element));
}
} // namespace
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<uint64_t>(giB)) {
return fmt::format("{:.1f} GiB", static_cast<double>(bytes) / giB);
}
if (bytes >= static_cast<uint64_t>(miB)) {
return fmt::format("{:.1f} MiB", static_cast<double>(bytes) / miB);
}
if (bytes >= static_cast<uint64_t>(kiB)) {
return fmt::format("{:.0f} KiB", static_cast<double>(bytes) / kiB);
}
return fmt::format("{} B", bytes);
}
const char* state_class(mods::queue::State state) {
using enum mods::queue::State;
switch (state) {
case Downloading:
return "downloading";
case Paused:
return "paused";
case Retrying:
return "retrying";
case Verifying:
case Installing:
case Activating:
case Uninstalling:
return "installing";
case Installed:
return "installed";
case Failed:
case InstallFailed:
case ActivationFailed:
return "failed";
case Canceled:
return "canceled";
case Queued:
return "queued";
}
return "queued";
}
std::string state_label(const mods::queue::Item& item) {
using enum mods::queue::State;
switch (item.state) {
case Queued:
return "Queued";
case Downloading:
return "Downloading";
case Paused:
return "Paused";
case Retrying:
return fmt::format("Retrying in {}s", item.retrySeconds);
case Verifying:
return "Verifying";
case Installing:
return "Installing";
case Activating:
return "Activating";
case Installed:
return "Installed";
case Failed:
return item.local ? "Package failed" : "Download failed";
case InstallFailed:
return "Install failed";
case ActivationFailed:
return "Activation failed";
case Uninstalling:
return "Uninstalling";
case Canceled:
return "Canceled";
}
return {};
}
PackageRow::PackageRow(Rml::Element* parent) : Component{create_row(parent)} {
auto* heading = append(mRoot, "package-row-heading");
mName = append(heading, "package-row-name");
mState = append(heading, "package-row-state");
mProgress = append(mRoot, "progress");
mDetail = append(mRoot, "package-row-detail");
}
void PackageRow::set_package(std::string name, std::string status, std::string detail,
std::string stateClass, std::optional<float> progress) {
mRoot->SetClassNames(fmt::format("package-row {}", stateClass));
set_text_content(mName, name);
set_text_content(mState, status);
set_text_content(mDetail, detail);
if (progress) {
mProgress->SetProperty("display", "block");
mProgress->SetAttribute("value", *progress);
} else {
mProgress->SetProperty("display", "none");
}
}
Rml::Element* PackageRow::actions_root() {
if (mActions == nullptr) {
mActions = append(mRoot, "package-row-actions");
}
return mActions;
}
} // namespace dusk::ui
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "component.hpp"
#include "dusk/mods/queue.hpp"
#include <cstdint>
#include <optional>
#include <string>
namespace dusk::ui {
std::string format_bytes(uint64_t bytes);
const char* state_class(mods::queue::State state);
std::string state_label(const mods::queue::Item& item);
class PackageRow : public Component {
public:
explicit PackageRow(Rml::Element* parent);
void set_package(std::string name, std::string status, std::string detail,
std::string stateClass, std::optional<float> progress = {});
Rml::Element* actions_root();
private:
Rml::Element* mName = nullptr;
Rml::Element* mState = nullptr;
Rml::Element* mProgress = nullptr;
Rml::Element* mDetail = nullptr;
Rml::Element* mActions = nullptr;
};
} // namespace dusk::ui
+7 -7
View File
@@ -163,17 +163,17 @@ DiscVerificationState verification_to_config(iso::ValidationError validation) {
}
}
std::string format_bytes(std::size_t bytes) {
std::string format_bytes(size_t bytes) {
constexpr double KiB = 1024.0;
constexpr double MiB = KiB * 1024.0;
constexpr double GiB = MiB * 1024.0;
if (bytes >= static_cast<std::size_t>(GiB)) {
if (bytes >= static_cast<size_t>(GiB)) {
return fmt::format("{:.2f} GiB", static_cast<double>(bytes) / GiB);
}
if (bytes >= static_cast<std::size_t>(MiB)) {
if (bytes >= static_cast<size_t>(MiB)) {
return fmt::format("{:.0f} MiB", static_cast<double>(bytes) / MiB);
}
if (bytes >= static_cast<std::size_t>(KiB)) {
if (bytes >= static_cast<size_t>(KiB)) {
return fmt::format("{:.0f} KiB", static_cast<double>(bytes) / KiB);
}
return fmt::format("{} B", bytes);
@@ -445,9 +445,9 @@ private:
set_text_content(mFileName, fileName);
}
const std::size_t bytesRead =
const size_t bytesRead =
sDiscVerificationTask->status.bytesRead.load(std::memory_order_relaxed);
const std::size_t bytesTotal =
const size_t bytesTotal =
sDiscVerificationTask->status.bytesTotal.load(std::memory_order_relaxed);
if (bytesTotal == 0) {
@@ -626,7 +626,7 @@ private:
std::array<Rml::Element*, 2> mLabels{};
Rml::Element* mNext = nullptr;
Rml::String mText;
std::size_t mActiveLabel = 0;
size_t mActiveLabel = 0;
};
} // namespace
+196
View File
@@ -0,0 +1,196 @@
#include "queue_window.hpp"
#include "button.hpp"
#include "dusk/mods/queue.hpp"
#include "fmt/format.h"
#include "package_row.hpp"
#include "pane.hpp"
#include <algorithm>
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace dusk::ui {
namespace {
class QueueRow final : public PackageRow {
public:
QueueRow(Rml::Element* parent, std::string id) : PackageRow{parent}, mId{std::move(id)} {
auto* actions = actions_root();
auto pause = std::make_unique<Button>(actions, "Pause");
mPause = pause.get();
mPause->on_pressed([this] {
const auto item = mods::queue::find(mId);
if (!item) {
return;
}
switch (item->state) {
case mods::queue::State::Paused:
mods::queue::resume(mId);
break;
case mods::queue::State::Failed:
case mods::queue::State::InstallFailed:
case mods::queue::State::ActivationFailed:
mods::queue::retry(mId);
break;
default:
mods::queue::pause(mId);
break;
}
});
mChildren.push_back(std::move(pause));
auto cancel = std::make_unique<Button>(actions, "Cancel");
mCancel = cancel.get();
mCancel->on_pressed([this] {
const auto item = mods::queue::find(mId);
if (!item) {
return;
}
mods::queue::cancel(mId);
if (item->state == mods::queue::State::Installed ||
item->state == mods::queue::State::Failed ||
item->state == mods::queue::State::InstallFailed ||
item->state == mods::queue::State::Canceled)
{
mods::queue::clear_finished();
}
});
mChildren.push_back(std::move(cancel));
update();
}
void update() override {
const auto item = mods::queue::find(mId);
if (!item) {
mRoot->SetProperty("display", "none");
return;
}
mRoot->SetProperty("display", "flex");
const float progress =
item->total == 0 ?
0.0f :
std::clamp(static_cast<float>(item->completed) / static_cast<float>(item->total),
0.0f, 1.0f);
std::string detail;
if (item->completed != 0 || item->state == mods::queue::State::Downloading ||
item->state == mods::queue::State::Paused)
{
detail =
fmt::format("{} / {}", format_bytes(item->completed), format_bytes(item->total));
} else {
detail = format_bytes(item->total);
}
if (!item->message.empty()) {
detail = fmt::format("{} · {}", detail, item->message);
}
const auto name =
item->version.empty() ? item->name : fmt::format("{} {}", item->name, item->version);
set_package(name, state_label(*item), detail, state_class(item->state), progress);
const bool pauseVisible = (!item->local && item->state == mods::queue::State::Queued) ||
item->state == mods::queue::State::Downloading ||
item->state == mods::queue::State::Paused ||
item->state == mods::queue::State::Retrying ||
item->state == mods::queue::State::Failed ||
item->state == mods::queue::State::InstallFailed ||
item->state == mods::queue::State::ActivationFailed;
mPause->root()->SetProperty("display", pauseVisible ? "block" : "none");
if (item->state == mods::queue::State::Paused) {
mPause->set_text("Resume");
} else if (item->state == mods::queue::State::Failed ||
item->state == mods::queue::State::InstallFailed ||
item->state == mods::queue::State::ActivationFailed)
{
mPause->set_text("Retry");
} else {
mPause->set_text("Pause");
}
const bool cancelVisible = item->state != mods::queue::State::Installing &&
item->state != mods::queue::State::Activating &&
item->state != mods::queue::State::Uninstalling;
mCancel->root()->SetProperty("display", cancelVisible ? "block" : "none");
mCancel->set_text(item->state == mods::queue::State::Installed ||
item->state == mods::queue::State::Failed ||
item->state == mods::queue::State::InstallFailed ||
item->state == mods::queue::State::ActivationFailed ||
item->state == mods::queue::State::Canceled ?
"Clear" :
"Cancel");
Component::update();
}
private:
std::string mId;
Button* mPause = nullptr;
Button* mCancel = nullptr;
};
} // namespace
QueueWindow::QueueWindow(std::string focusId)
: Modal{Props{
.title = "Installs",
.bodyText = "Preparing installs...",
.actions =
{
ModalAction{"Close", [](Modal& modal) { modal.pop(); }, {}},
ModalAction{"Pause all", [](Modal&) { mods::queue::pause_all(); },
[] { return !mods::queue::has_active_items(); }},
ModalAction{"Clear finished", [](Modal&) { mods::queue::clear_finished(); }, {}},
},
.variant = "install-queue",
}},
mFocusId{std::move(focusId)} {
content_pane();
rebuild_rows();
}
void QueueWindow::update() {
const auto queueItems = mods::queue::items();
std::vector<std::string> ids;
ids.reserve(queueItems.size());
size_t active = 0;
for (const auto& item : queueItems) {
ids.push_back(item.id);
if (item.state != mods::queue::State::Installed &&
item.state != mods::queue::State::Failed &&
item.state != mods::queue::State::InstallFailed &&
item.state != mods::queue::State::ActivationFailed &&
item.state != mods::queue::State::Canceled)
{
++active;
}
}
if (ids != mItemIds) {
rebuild_rows();
}
set_body_text(fmt::format("{} active · {} total", active, queueItems.size()));
Modal::update();
}
void QueueWindow::rebuild_rows() {
auto& pane = content_pane();
pane.clear();
mItemIds.clear();
const auto queueItems = mods::queue::items();
if (queueItems.empty()) {
pane.add_text("No installs.");
return;
}
for (const auto& item : queueItems) {
mItemIds.push_back(item.id);
auto& row = pane.add_child<QueueRow>(item.id);
if (!mFocusId.empty() && item.id == mFocusId) {
row.focus();
mFocusId.clear();
}
}
}
} // namespace dusk::ui
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "modal.hpp"
#include <string>
#include <vector>
namespace dusk::ui {
class QueueWindow final : public Modal {
public:
explicit QueueWindow(std::string focusId = {});
void update() override;
private:
void rebuild_rows();
std::string mFocusId;
std::vector<std::string> mItemIds;
};
} // namespace dusk::ui
+22 -22
View File
@@ -31,11 +31,11 @@ using namespace std::chrono_literals;
constexpr borealis::Log Log{"dusk::ui"};
constexpr std::string_view kScheme = "https";
constexpr std::string_view kAllowedPrefix = "https://staging.twilitrealm.workers.dev/images/v1/";
constexpr std::size_t kMaxCachedImages = 64;
constexpr std::size_t kMaxImageFileSize = 16 * 1024 * 1024;
constexpr size_t kMaxCachedImages = 64;
constexpr size_t kMaxImageFileSize = 16 * 1024 * 1024;
// RmlUi caches the first texture dimensions in image decorators, so the async
// placeholder must preserve the final image's aspect ratio.
constexpr std::uint32_t kPlaceholderMaxDimension = 256;
constexpr uint32_t kPlaceholderMaxDimension = 256;
constexpr std::array<std::byte, kPlaceholderMaxDimension * kPlaceholderMaxDimension * 4>
kTransparentPixels{};
@@ -50,9 +50,9 @@ struct Entry {
borealis::Task<borealis::http::Result> request;
DecodedImage image;
State state = State::Unrequested;
std::uint64_t lastUsed = 0;
std::uint32_t placeholderWidth = 1;
std::uint32_t placeholderHeight = 1;
uint64_t lastUsed = 0;
uint32_t placeholderWidth = 1;
uint32_t placeholderHeight = 1;
};
std::unordered_map<std::string, Entry>& image_cache() {
@@ -60,8 +60,8 @@ std::unordered_map<std::string, Entry>& image_cache() {
return *cache;
}
std::uint64_t& use_counter() {
static auto* counter = new std::uint64_t{};
uint64_t& use_counter() {
static auto* counter = new uint64_t{};
return *counter;
}
@@ -71,7 +71,7 @@ bool make_cache_room() {
return true;
}
const auto victim = std::ranges::min_element(cache, {}, [](const auto& pair) {
return pair.second.state == State::Pending ? std::numeric_limits<std::uint64_t>::max() :
return pair.second.state == State::Pending ? std::numeric_limits<uint64_t>::max() :
pair.second.lastUsed;
});
if (victim == cache.end() || victim->second.state == State::Pending) {
@@ -82,8 +82,8 @@ bool make_cache_room() {
}
aurora::rmlui::RuntimeTexture transparent_texture(const Entry& entry) {
const auto size = static_cast<std::size_t>(entry.placeholderWidth) *
static_cast<std::size_t>(entry.placeholderHeight) * 4;
const auto size = static_cast<size_t>(entry.placeholderWidth) *
static_cast<size_t>(entry.placeholderHeight) * 4;
return {
.width = entry.placeholderWidth,
.height = entry.placeholderHeight,
@@ -152,7 +152,7 @@ void finish_request(const std::string& source, Entry& entry, borealis::http::Res
Log.warn("Image '{}' returned HTTP {}", source, result.response.statusCode);
return;
}
const auto* data = reinterpret_cast<const std::uint8_t*>(result.response.body.data());
const auto* data = reinterpret_cast<const uint8_t*>(result.response.body.data());
auto image = decode_png(std::span{data, result.response.body.size()}, source);
if (!image) {
entry.state = State::Failed;
@@ -198,7 +198,7 @@ void update_remote_texture_provider() noexcept {
}
void set_remote_texture_dimensions(
std::string_view source, std::uint32_t width, std::uint32_t height) noexcept {
std::string_view source, uint32_t width, uint32_t height) noexcept {
if (!source.starts_with(kAllowedPrefix) || width == 0 || height == 0) {
return;
}
@@ -214,14 +214,14 @@ void set_remote_texture_dimensions(
const auto maxDimension = std::max(width, height);
if (maxDimension > kPlaceholderMaxDimension) {
width = std::max(1u,
static_cast<std::uint32_t>(
(static_cast<std::uint64_t>(width) * kPlaceholderMaxDimension + maxDimension / 2) /
maxDimension));
height = std::max(1u,
static_cast<std::uint32_t>(
(static_cast<std::uint64_t>(height) * kPlaceholderMaxDimension + maxDimension / 2) /
maxDimension));
width = std::max(
1u, static_cast<uint32_t>(
(static_cast<uint64_t>(width) * kPlaceholderMaxDimension + maxDimension / 2) /
maxDimension));
height = std::max(
1u, static_cast<uint32_t>(
(static_cast<uint64_t>(height) * kPlaceholderMaxDimension + maxDimension / 2) /
maxDimension));
}
iter->second.placeholderWidth = width;
iter->second.placeholderHeight = height;
@@ -236,7 +236,7 @@ namespace dusk::ui {
void register_remote_texture_provider() noexcept {}
void unregister_remote_texture_provider() noexcept {}
void update_remote_texture_provider() noexcept {}
void set_remote_texture_dimensions(std::string_view, std::uint32_t, std::uint32_t) noexcept {}
void set_remote_texture_dimensions(std::string_view, uint32_t, uint32_t) noexcept {}
} // namespace dusk::ui
+1 -1
View File
@@ -9,6 +9,6 @@ void register_remote_texture_provider() noexcept;
void unregister_remote_texture_provider() noexcept;
void update_remote_texture_provider() noexcept;
void set_remote_texture_dimensions(
std::string_view source, std::uint32_t width, std::uint32_t height) noexcept;
std::string_view source, uint32_t width, uint32_t height) noexcept;
} // namespace dusk::ui
+15 -17
View File
@@ -11,12 +11,11 @@ namespace dusk::ui {
namespace {
constexpr borealis::Log Log{"dusk::ui"};
constexpr std::uint32_t kMaxImageDimension = 4096;
constexpr uint32_t kMaxImageDimension = 4096;
} // namespace
std::optional<DecodedImage> decode_png(
std::span<const std::uint8_t> data, std::string_view source) {
std::optional<DecodedImage> decode_png(std::span<const uint8_t> data, std::string_view source) {
SDL_IOStream* stream = SDL_IOFromConstMem(data.data(), data.size());
if (stream == nullptr) {
Log.warn("Failed to open image stream for '{}': {}", source, SDL_GetError());
@@ -36,32 +35,31 @@ std::optional<DecodedImage> decode_png(
return std::nullopt;
}
const auto width = static_cast<std::uint32_t>(rgbaSurface->w);
const auto height = static_cast<std::uint32_t>(rgbaSurface->h);
const auto width = static_cast<uint32_t>(rgbaSurface->w);
const auto height = static_cast<uint32_t>(rgbaSurface->h);
if (width == 0 || height == 0 || width > kMaxImageDimension || height > kMaxImageDimension) {
Log.warn("Image '{}' has unsupported dimensions {}x{}", source, width, height);
SDL_DestroySurface(rgbaSurface);
return std::nullopt;
}
const std::size_t rowSize = static_cast<std::size_t>(width) * 4;
const size_t rowSize = static_cast<size_t>(width) * 4;
DecodedImage image{
.pixels = std::vector<std::uint8_t>(rowSize * height),
.pixels = std::vector<uint8_t>(rowSize * height),
.width = width,
.height = height,
};
for (std::uint32_t row = 0; row < height; ++row) {
const auto* src =
static_cast<const std::uint8_t*>(rgbaSurface->pixels) +
static_cast<std::size_t>(row) * static_cast<std::size_t>(rgbaSurface->pitch);
auto* dst = image.pixels.data() + static_cast<std::size_t>(row) * rowSize;
for (uint32_t row = 0; row < height; ++row) {
const auto* src = static_cast<const uint8_t*>(rgbaSurface->pixels) +
static_cast<size_t>(row) * static_cast<size_t>(rgbaSurface->pitch);
auto* dst = image.pixels.data() + static_cast<size_t>(row) * rowSize;
std::memcpy(dst, src, rowSize);
for (std::size_t col = 0; col < rowSize; col += 4) {
const std::uint8_t alpha = dst[col + 3];
for (std::size_t channel = 0; channel < 3; ++channel) {
dst[col + channel] = static_cast<std::uint8_t>(
(static_cast<std::uint32_t>(dst[col + channel]) * alpha) / 255);
for (size_t col = 0; col < rowSize; col += 4) {
const uint8_t alpha = dst[col + 3];
for (size_t channel = 0; channel < 3; ++channel) {
dst[col + channel] =
static_cast<uint8_t>((static_cast<uint32_t>(dst[col + channel]) * alpha) / 255);
}
}
}
+4 -4
View File
@@ -9,11 +9,11 @@
namespace dusk::ui {
struct DecodedImage {
std::vector<std::uint8_t> pixels;
std::uint32_t width = 0;
std::uint32_t height = 0;
std::vector<uint8_t> pixels;
uint32_t width = 0;
uint32_t height = 0;
};
std::optional<DecodedImage> decode_png(std::span<const std::uint8_t> data, std::string_view source);
std::optional<DecodedImage> decode_png(std::span<const uint8_t> data, std::string_view source);
} // namespace dusk::ui
+1 -1
View File
@@ -337,7 +337,7 @@ bool gyro_enabled() {
}
Rml::String touch_targeting_label(TouchTargeting targeting) {
const auto index = static_cast<std::size_t>(targeting);
const auto index = static_cast<size_t>(targeting);
if (index >= kTouchTargetingLabels.size()) {
return "Unknown";
}
+36 -37
View File
@@ -44,7 +44,7 @@ constexpr float kPressedScale = 0.94f;
constexpr size_t kEquipTargetCount = 4;
std::array<EquipTarget, kEquipTargetCount> sEquipTargets{};
std::array<ControlOverride, static_cast<std::size_t>(Control::COUNT)> sControlOverrides{};
std::array<ControlOverride, static_cast<size_t>(Control::COUNT)> sControlOverrides{};
TouchControls* sTouchControls = nullptr;
struct ControlInfo {
@@ -58,7 +58,7 @@ struct ControlInfo {
std::optional<ActionBinds> holdAction;
};
constexpr std::array<ControlInfo, static_cast<std::size_t>(Control::COUNT)> kControls = {{
constexpr std::array<ControlInfo, static_cast<size_t>(Control::COUNT)> kControls = {{
{
.id = "button-a",
.padButton = PAD_BUTTON_A,
@@ -119,12 +119,12 @@ constexpr std::array<ControlInfo, static_cast<std::size_t>(Control::COUNT)> kCon
}};
constexpr const ControlInfo* control_info(Control control) noexcept {
const auto index = static_cast<std::size_t>(control);
const auto index = static_cast<size_t>(control);
return index < kControls.size() ? &kControls[index] : nullptr;
}
bool control_override_active(Control control) noexcept {
const auto index = static_cast<std::size_t>(control);
const auto index = static_cast<size_t>(control);
return index < sControlOverrides.size() && sControlOverrides[index] != ControlOverride::Default;
}
@@ -361,7 +361,7 @@ bool get_equip_target(int slot, EquipTarget& target) noexcept {
}
void set_control_override(Control control, ControlOverride override) noexcept {
const auto index = static_cast<std::size_t>(control);
const auto index = static_cast<size_t>(control);
if (index >= sControlOverrides.size()) {
return;
}
@@ -382,7 +382,7 @@ TouchControls::TouchControls()
mActionBar(mDocument != nullptr ? mDocument->GetElementById("action-bar") : nullptr) {
sTouchControls = this;
if (mDocument != nullptr) {
for (std::size_t i = 0; i < kControls.size(); ++i) {
for (size_t i = 0; i < kControls.size(); ++i) {
const auto& info = kControls[i];
auto& elements = mControlElements[i];
elements.root = info.id != nullptr ? mDocument->GetElementById(info.id) : nullptr;
@@ -405,7 +405,7 @@ TouchControls::TouchControls()
});
auto listenControl = [this](Control control) {
const auto index = static_cast<std::size_t>(control);
const auto index = static_cast<size_t>(control);
auto* element = index < mControlElements.size() ? mControlElements[index].root : nullptr;
if (element == nullptr) {
return;
@@ -429,7 +429,7 @@ TouchControls::TouchControls()
}
});
};
for (std::size_t i = 0; i < kControls.size(); ++i) {
for (size_t i = 0; i < kControls.size(); ++i) {
listenControl(static_cast<Control>(i));
}
@@ -572,7 +572,7 @@ bool TouchControls::fire_control_action(Control control, ControlAction action) n
return false;
}
const auto actionIndex = static_cast<std::size_t>(*actionBind);
const auto actionIndex = static_cast<size_t>(*actionBind);
if (actionIndex >= mQueuedActions.size()) {
return false;
}
@@ -582,7 +582,7 @@ bool TouchControls::fire_control_action(Control control, ControlAction action) n
}
bool TouchControls::start_control_touch(SDL_FingerID id, Control control) noexcept {
const auto index = static_cast<std::size_t>(control);
const auto index = static_cast<size_t>(control);
if (index >= mControlTouches.size()) {
return false;
}
@@ -603,7 +603,7 @@ bool TouchControls::start_control_touch(SDL_FingerID id, Control control) noexce
}
void TouchControls::release_control(Control control) noexcept {
const auto index = static_cast<std::size_t>(control);
const auto index = static_cast<size_t>(control);
if (index < mControlTouches.size()) {
mControlTouches[index] = {};
}
@@ -629,7 +629,7 @@ void TouchControls::release_control(Control control) noexcept {
void TouchControls::sync_control_button_mask() noexcept {
u16 buttonMask = 0;
for (std::size_t i = 0; i < mControlTouches.size() && i < kControls.size(); ++i) {
for (size_t i = 0; i < mControlTouches.size() && i < kControls.size(); ++i) {
if (mControlTouches[i].active) {
buttonMask |= kControls[i].padButton;
}
@@ -638,7 +638,7 @@ void TouchControls::sync_control_button_mask() noexcept {
}
void TouchControls::set_control_visual(Control control, bool pressed) noexcept {
const auto index = static_cast<std::size_t>(control);
const auto index = static_cast<size_t>(control);
auto* element = index < mControlElements.size() ? mControlElements[index].root : nullptr;
if (index >= mControlVisualPressed.size()) {
return;
@@ -651,7 +651,7 @@ void TouchControls::set_control_visual(Control control, bool pressed) noexcept {
}
void TouchControls::apply_control_transform(Control control) noexcept {
const auto index = static_cast<std::size_t>(control);
const auto index = static_cast<size_t>(control);
if (index >= mControlElements.size()) {
return;
}
@@ -694,7 +694,7 @@ void TouchControls::clear_motion_touch_input() noexcept {
}
void TouchControls::clear_control_input() noexcept {
for (std::size_t i = 0; i < mControlTouches.size(); ++i) {
for (size_t i = 0; i < mControlTouches.size(); ++i) {
release_control(static_cast<Control>(i));
}
mQueuedActions.reset();
@@ -816,12 +816,12 @@ void TouchControls::sync_virtual_input() noexcept {
for (const auto& control : kControls) {
if (control.tapAction) {
const bool queued = mQueuedActions.test(static_cast<std::size_t>(*control.tapAction));
const bool queued = mQueuedActions.test(static_cast<size_t>(*control.tapAction));
setVirtualActionBind(
*control.tapAction, kPort, queued, queued && visible() && !mWasSuppressed);
}
if (control.holdAction) {
const bool queued = mQueuedActions.test(static_cast<std::size_t>(*control.holdAction));
const bool queued = mQueuedActions.test(static_cast<size_t>(*control.holdAction));
setVirtualActionBind(
*control.holdAction, kPort, queued, queued && visible() && !mWasSuppressed);
}
@@ -870,7 +870,7 @@ void TouchControls::sync_control_layouts() noexcept {
const auto layout = resolve_control_layout(props, docSize);
if (info.hasControl) {
const auto index = static_cast<std::size_t>(info.control);
const auto index = static_cast<size_t>(info.control);
if (index >= mControlElements.size()) {
continue;
}
@@ -899,7 +899,7 @@ void TouchControls::sync_visual_state() noexcept {
if (mWasSuppressed || !getSettings().game.enableTouchControls) {
clear_motion_touch_input();
for (const auto control : {Control::L, Control::R}) {
const auto& elements = mControlElements[static_cast<std::size_t>(control)];
const auto& elements = mControlElements[static_cast<size_t>(control)];
if (elements.root != nullptr) {
elements.root->SetPseudoClass("hidden", true);
}
@@ -909,8 +909,8 @@ void TouchControls::sync_visual_state() noexcept {
}
const bool hideGameplayControls = game_controls_suppressed();
const auto& lTrigger = mControlElements[static_cast<std::size_t>(Control::L)];
const auto& rTrigger = mControlElements[static_cast<std::size_t>(Control::R)];
const auto& lTrigger = mControlElements[static_cast<size_t>(Control::L)];
const auto& rTrigger = mControlElements[static_cast<size_t>(Control::R)];
const bool lHidden = hideGameplayControls && !control_override_active(Control::L);
const bool rHidden = hideGameplayControls && !control_override_active(Control::R);
@@ -937,7 +937,7 @@ void TouchControls::sync_action_bar_state() noexcept {
if (mActionBar != nullptr) {
mActionBar->SetPseudoClass("hidden", true);
}
const auto& skip = mControlElements[static_cast<std::size_t>(Control::SKIP)];
const auto& skip = mControlElements[static_cast<size_t>(Control::SKIP)];
if (skip.root != nullptr) {
skip.root->SetPseudoClass("hidden", true);
}
@@ -956,7 +956,7 @@ void TouchControls::sync_action_bar_state() noexcept {
!skipVisible &&
(!controls_available(false) || dComIfGp_event_runCheck() ||
(dComIfGp_getMsgObjectClass() != nullptr && dMsgObject_isTalkNowCheck()));
const auto& skip = mControlElements[static_cast<std::size_t>(Control::SKIP)];
const auto& skip = mControlElements[static_cast<size_t>(Control::SKIP)];
if (mActionBar != nullptr) {
mActionBar->SetPseudoClass("hidden", hidden || skipVisible);
}
@@ -987,7 +987,7 @@ void TouchControls::sync_action_bar_state() noexcept {
void TouchControls::sync_control_displays() noexcept {
if (mWasSuppressed || !getSettings().game.enableTouchControls) {
for (const auto control : {Control::A, Control::B, Control::X, Control::Y, Control::Z}) {
const auto& elements = mControlElements[static_cast<std::size_t>(control)];
const auto& elements = mControlElements[static_cast<size_t>(control)];
if (elements.root != nullptr) {
elements.root->SetPseudoClass("hidden", true);
}
@@ -1002,11 +1002,11 @@ void TouchControls::sync_control_displays() noexcept {
const auto yState = xy_button_state(Control::Y);
const auto zState = z_button_state();
const auto& a = mControlElements[static_cast<std::size_t>(Control::A)];
const auto& b = mControlElements[static_cast<std::size_t>(Control::B)];
const auto& x = mControlElements[static_cast<std::size_t>(Control::X)];
const auto& y = mControlElements[static_cast<std::size_t>(Control::Y)];
const auto& z = mControlElements[static_cast<std::size_t>(Control::Z)];
const auto& a = mControlElements[static_cast<size_t>(Control::A)];
const auto& b = mControlElements[static_cast<size_t>(Control::B)];
const auto& x = mControlElements[static_cast<size_t>(Control::X)];
const auto& y = mControlElements[static_cast<size_t>(Control::Y)];
const auto& z = mControlElements[static_cast<size_t>(Control::Z)];
if (a.root != nullptr) {
a.root->SetPseudoClass("hidden", false);
@@ -1145,7 +1145,7 @@ void TouchControls::update() {
}
bool TouchControls::release_control_touch(SDL_FingerID id, bool cancelled) noexcept {
for (std::size_t i = 0; i < mControlTouches.size(); ++i) {
for (size_t i = 0; i < mControlTouches.size(); ++i) {
auto& touch = mControlTouches[i];
if (!touch.active || touch.id != id) {
continue;
@@ -1166,7 +1166,7 @@ bool TouchControls::release_control_touch(SDL_FingerID id, bool cancelled) noexc
void TouchControls::sync_control_long_presses() noexcept {
const auto now = clock::now();
for (std::size_t i = 0; i < mControlTouches.size(); ++i) {
for (size_t i = 0; i < mControlTouches.size(); ++i) {
auto& touch = mControlTouches[i];
if (!touch.active || touch.longPressFired || now - touch.startTime < kHoldActionDuration) {
continue;
@@ -1371,8 +1371,8 @@ void TouchControls::handle_mouse_move(Rml::Event& event) noexcept {
--mMenuPointerMouseSuppressions;
return;
}
if (!visible() || mWasSuppressed || !menu_pointer::active() ||
!menu_pointer::enabled() || event.GetTargetElement() != mRoot)
if (!visible() || mWasSuppressed || !menu_pointer::active() || !menu_pointer::enabled() ||
event.GetTargetElement() != mRoot)
{
return;
}
@@ -1388,8 +1388,8 @@ void TouchControls::handle_mouse_down(Rml::Event& event) noexcept {
--mMenuPointerMouseSuppressions;
return;
}
if (!visible() || mWasSuppressed || !menu_pointer::active() ||
!menu_pointer::enabled() || event.GetTargetElement() != mRoot)
if (!visible() || mWasSuppressed || !menu_pointer::active() || !menu_pointer::enabled() ||
event.GetTargetElement() != mRoot)
{
return;
}
@@ -1409,8 +1409,7 @@ void TouchControls::handle_mouse_up(Rml::Event& event) noexcept {
--mMenuPointerMouseSuppressions;
return;
}
if (!visible() || mWasSuppressed ||
!menu_pointer::enabled() ||
if (!visible() || mWasSuppressed || !menu_pointer::enabled() ||
(!menu_pointer::active() && !menu_pointer::mouse_capture_active()) ||
event.GetTargetElement() != mRoot)
{
+4 -4
View File
@@ -100,7 +100,7 @@ private:
Rml::Element* mControlStick = nullptr;
Rml::Element* mControlKnob = nullptr;
Rml::Element* mActionBar = nullptr;
std::array<ControlElements, static_cast<std::size_t>(Control::COUNT)> mControlElements{};
std::array<ControlElements, static_cast<size_t>(Control::COUNT)> mControlElements{};
std::string mButtonBIconSource;
std::string mButtonXIconSource;
std::string mButtonYIconSource;
@@ -112,9 +112,9 @@ private:
StickTouch mCameraTouch;
SDL_FingerID mMenuPointerTouch = 0;
int mMenuPointerMouseSuppressions = 0;
std::array<ControlTouch, static_cast<std::size_t>(Control::COUNT)> mControlTouches{};
std::array<bool, static_cast<std::size_t>(Control::COUNT)> mControlVisualPressed{};
std::bitset<static_cast<std::size_t>(ActionBinds::COUNT)> mQueuedActions;
std::array<ControlTouch, static_cast<size_t>(Control::COUNT)> mControlTouches{};
std::array<bool, static_cast<size_t>(Control::COUNT)> mControlVisualPressed{};
std::bitset<static_cast<size_t>(ActionBinds::COUNT)> mQueuedActions;
LayoutState mActionBarLayout;
Insets mSafeInsets;
u16 mButtonMask = 0;
+1 -1
View File
@@ -12,7 +12,7 @@
namespace dusk::ui {
constexpr std::size_t kTouchLayoutControlCount = 9;
constexpr size_t kTouchLayoutControlCount = 9;
struct TouchLayoutControlInfo {
std::string_view layoutId;
+11 -11
View File
@@ -88,7 +88,7 @@ bool is_vertical_edge(TouchControlsEditor::EditHandle handle) noexcept {
return handle == EditHandle::Top || handle == EditHandle::Bottom;
}
bool control_valid(std::size_t index) noexcept {
bool control_valid(size_t index) noexcept {
return index < touch_layout_controls().size();
}
@@ -111,7 +111,7 @@ TouchControlsEditor::TouchControlsEditor()
mWorkingLayout.version = ControlLayout::Version;
const auto controls = touch_layout_controls();
for (std::size_t i = 0; i < controls.size() && i < mElements.size(); ++i) {
for (size_t i = 0; i < controls.size() && i < mElements.size(); ++i) {
mElements[i].root =
mDocument != nullptr ? mDocument->GetElementById(controls[i].elementId) : nullptr;
}
@@ -197,7 +197,7 @@ bool TouchControlsEditor::focus() {
void TouchControlsEditor::bind_control_events() noexcept {
const auto controls = touch_layout_controls();
for (std::size_t i = 0; i < controls.size() && i < mElements.size(); ++i) {
for (size_t i = 0; i < controls.size() && i < mElements.size(); ++i) {
auto* element = mElements[i].root;
if (element == nullptr) {
continue;
@@ -285,7 +285,7 @@ void TouchControlsEditor::sync_control_layouts() noexcept {
}
const auto controls = touch_layout_controls();
for (std::size_t i = 0; i < controls.size() && i < mElements.size(); ++i) {
for (size_t i = 0; i < controls.size() && i < mElements.size(); ++i) {
const auto layout = resolve_control_layout(props_for(i), docSize);
auto& element = mElements[i];
element.layout.visualRect = layout.visual;
@@ -309,7 +309,7 @@ void TouchControlsEditor::sync_selection_frame() noexcept {
}
mSelectionFrame->SetClass("visible", hasSelection);
for (std::size_t i = 0; i < mElements.size(); ++i) {
for (size_t i = 0; i < mElements.size(); ++i) {
if (mElements[i].root != nullptr) {
mElements[i].root->SetClass("editor-selected", hasSelection && i == mSelectedIndex);
}
@@ -323,7 +323,7 @@ void TouchControlsEditor::sync_selection_frame() noexcept {
mSelectionFrame, mAppliedSelectionFrame, *mElements[mSelectedIndex].layout.visualRect);
}
void TouchControlsEditor::set_selected_control(std::size_t index) noexcept {
void TouchControlsEditor::set_selected_control(size_t index) noexcept {
if (!control_valid(index)) {
clear_selected_control();
return;
@@ -337,7 +337,7 @@ void TouchControlsEditor::clear_selected_control() noexcept {
sync_selection_frame();
}
ControlProps TouchControlsEditor::props_for(std::size_t index) const {
ControlProps TouchControlsEditor::props_for(size_t index) const {
const auto controls = touch_layout_controls();
if (!control_valid(index)) {
return {};
@@ -353,7 +353,7 @@ ControlProps TouchControlsEditor::props_for(std::size_t index) const {
}
void TouchControlsEditor::store_props(
std::size_t index, ControlRect visual, ControlProps props) noexcept {
size_t index, ControlRect visual, ControlProps props) noexcept {
if (!control_valid(index)) {
return;
}
@@ -391,7 +391,7 @@ void TouchControlsEditor::restore_active_control() noexcept {
}
bool TouchControlsEditor::begin_edit(
std::size_t index, EditHandle handle, Rml::Vector2f positionPx, bool touch,
size_t index, EditHandle handle, Rml::Vector2f positionPx, bool touch,
SDL_FingerID touchId) noexcept {
if (!control_valid(index) || mPointerEdit.active) {
return false;
@@ -543,7 +543,7 @@ ControlRect TouchControlsEditor::rect_for_edit(
return rect;
}
ControlRect TouchControlsEditor::clamp_visual_rect(std::size_t index, ControlRect rect) const noexcept {
ControlRect TouchControlsEditor::clamp_visual_rect(size_t index, ControlRect rect) const noexcept {
auto* context = mDocument != nullptr ? mDocument->GetContext() : nullptr;
const auto docSize = touch_document_size_dp(context);
if (docSize.w <= 0.f || docSize.h <= 0.f || !control_valid(index)) {
@@ -560,7 +560,7 @@ ControlRect TouchControlsEditor::clamp_visual_rect(std::size_t index, ControlRec
return rect;
}
Rml::Vector2f TouchControlsEditor::min_visual_size(std::size_t index) const noexcept {
Rml::Vector2f TouchControlsEditor::min_visual_size(size_t index) const noexcept {
if (!control_valid(index)) {
return {kMinControlDp, kMinControlDp};
}
+8 -8
View File
@@ -45,7 +45,7 @@ private:
};
struct PointerEdit {
std::size_t index = kTouchLayoutControlCount;
size_t index = kTouchLayoutControlCount;
SDL_FingerID touchId = 0;
Rml::Vector2f startPointerDp;
ControlRect startVisual;
@@ -64,19 +64,19 @@ private:
Rml::Element* element, void (TouchControlsEditor::*callback)()) noexcept;
void sync_control_layouts() noexcept;
void sync_selection_frame() noexcept;
void set_selected_control(std::size_t index) noexcept;
void set_selected_control(size_t index) noexcept;
void clear_selected_control() noexcept;
ControlProps props_for(std::size_t index) const;
void store_props(std::size_t index, ControlRect visual, ControlProps props) noexcept;
ControlProps props_for(size_t index) const;
void store_props(size_t index, ControlRect visual, ControlProps props) noexcept;
void restore_active_control() noexcept;
bool begin_edit(std::size_t index, EditHandle handle, Rml::Vector2f positionPx, bool touch,
bool begin_edit(size_t index, EditHandle handle, Rml::Vector2f positionPx, bool touch,
SDL_FingerID touchId = 0) noexcept;
bool continue_edit(Rml::Vector2f positionPx) noexcept;
bool end_edit(bool touch, SDL_FingerID touchId, bool cancelled) noexcept;
Rml::Vector2f pointer_position_dp(Rml::Vector2f positionPx) const noexcept;
ControlRect rect_for_edit(Rml::Vector2f pointerDp, ControlProps& props) const noexcept;
ControlRect clamp_visual_rect(std::size_t index, ControlRect rect) const noexcept;
Rml::Vector2f min_visual_size(std::size_t index) const noexcept;
ControlRect clamp_visual_rect(size_t index, ControlRect rect) const noexcept;
Rml::Vector2f min_visual_size(size_t index) const noexcept;
bool handle_nav_command(Rml::Event& event, NavCommand cmd) override;
void save_layout();
void request_reset();
@@ -92,7 +92,7 @@ private:
ControlLayout mWorkingLayout;
PointerEdit mPointerEdit;
std::optional<ControlRect> mAppliedSelectionFrame;
std::size_t mSelectedIndex = kTouchLayoutControlCount;
size_t mSelectedIndex = kTouchLayoutControlCount;
};
} // namespace dusk::ui
+65 -5
View File
@@ -13,12 +13,15 @@
#include <algorithm>
#include <filesystem>
#include <ranges>
#include <utility>
#include <borealis/io.hpp>
#include "aurora/lib/window.hpp"
#include "command_console.hpp"
#include "drop_install_modal.hpp"
#include "dusk/config.hpp"
#include "dusk/io.hpp"
#include "command_console.hpp"
#include "dusk/mods/queue.hpp"
#include "icon_provider.hpp"
#include "input.hpp"
#include "mod_texture_provider.hpp"
@@ -71,6 +74,12 @@ void restyle_scope(DocumentScope scope) {
std::deque<Toast> sToasts;
bool sMenuNotificationRequested = false;
bool sConsoleShortcutHeld = false;
std::vector<std::filesystem::path> sDroppedPackages;
struct PendingDrop {
borealis::Task<std::vector<DropPackage>> inspection;
};
std::vector<PendingDrop> sPendingDrops;
// Sometimes gamepads can connect and disconnect quickly, especially during
// connection negotiation. In this case, we'll receive an _ADDED event for a
@@ -105,6 +114,12 @@ bool initialize() noexcept {
}
void shutdown() noexcept {
mods::queue::shutdown();
for (auto& drop : sPendingDrops) {
drop.inspection.cancel();
}
sPendingDrops.clear();
sDroppedPackages.clear();
unregister_remote_texture_provider();
unregister_mod_texture_provider();
unregister_icon_texture_provider();
@@ -173,7 +188,27 @@ void handle_event(const SDL_Event& event) noexcept {
return;
}
if (event.type == SDL_EVENT_GAMEPAD_ADDED) {
if (event.type == SDL_EVENT_DROP_BEGIN) {
sDroppedPackages.clear();
} else if (event.type == SDL_EVENT_DROP_FILE && event.drop.data != nullptr) {
sDroppedPackages.push_back(borealis::io::fs_path_from_utf8(event.drop.data));
} else if (event.type == SDL_EVENT_DROP_COMPLETE) {
if (sDroppedPackages.empty()) {
push_toast({
.type = "warning",
.title = "No packages found",
.content = "Drop a Dusklight package to import it.",
.duration = std::chrono::seconds{4},
});
} else {
auto paths = std::exchange(sDroppedPackages, {});
sPendingDrops.push_back({
borealis::spawn([paths = std::move(paths)](borealis::TaskContext& context) {
return inspect_drop_packages(paths, context);
}),
});
}
} else if (event.type == SDL_EVENT_GAMEPAD_ADDED) {
auto* gamepad = SDL_GetGamepadFromID(event.gdevice.which);
if (SDL_GamepadConnected(gamepad)) {
if (getSettings().game.enableControllerToasts) {
@@ -360,15 +395,39 @@ Document* top_document() noexcept {
}
void update() noexcept {
mods::queue::update();
if (!aurora::rmlui::is_initialized()) {
return;
}
update_remote_texture_provider();
for (size_t index = 0; index < sPendingDrops.size();) {
auto& pending = sPendingDrops[index];
if (!pending.inspection.ready()) {
++index;
continue;
}
try {
if (auto packages = pending.inspection.try_take(); packages && !packages->empty()) {
if (auto* current = top_document()) {
current->cover();
}
push_document(std::make_unique<DropInstallModal>(std::move(*packages)));
}
} catch (const std::exception& exception) {
push_toast({
.type = "warning",
.title = "Could not inspect packages",
.content = exception.what(),
.duration = std::chrono::seconds{5},
});
}
sPendingDrops.erase(sPendingDrops.begin() + static_cast<std::ptrdiff_t>(index));
}
input::update_input();
const auto update_documents = [](auto& documents) {
const std::size_t count = documents.size();
for (std::size_t i = 0; i < count && i < documents.size(); ++i) {
const size_t count = documents.size();
for (size_t i = 0; i < count && i < documents.size(); ++i) {
Document* doc = documents[i].get();
if (doc != nullptr && !doc->closed()) {
doc->update();
@@ -561,7 +620,8 @@ void apply_scale() noexcept {
auto scale = 0.0f;
if (userScale != 0) {
const auto displayScale = aurora::window::get_window_size().scale;
scale = static_cast<float>(userScale) / 100.0f * (displayScale > 0.0f ? displayScale : 1.0f);
scale =
static_cast<float>(userScale) / 100.0f * (displayScale > 0.0f ? displayScale : 1.0f);
}
aurora::rmlui::set_ui_scale(scale);
}
+4 -7
View File
@@ -47,9 +47,9 @@
#include <borealis/aurora_log.h>
#include <borealis/cli.hpp>
#include <borealis/crash.hpp>
#include <borealis/http.hpp>
#include <borealis/io.hpp>
#include <borealis/sentry.hpp>
#include <borealis/task.hpp>
#include <borealis/version.h>
#include <filesystem>
#include <system_error>
@@ -758,6 +758,7 @@ int game_main(int argc, char* argv[]) {
// Run ImGui UI loop if Aurora couldn't initialize a backend
if (auroraInfo.backend == BACKEND_NULL) {
launchUILoop();
borealis::shutdown();
borealis::sentry::shutdown();
borealis::log::shutdown();
fflush(stdout);
@@ -770,10 +771,6 @@ int game_main(int argc, char* argv[]) {
return 0;
}
if (borealis::http::available() && !borealis::http::initialize()) {
DuskLog.warn("Failed to initialize the HTTP worker pool");
}
if (dusk::getSettings().game.enableHighQualityMinimapTextures.getValue()) {
dusk::hq_minimap::set_active(true);
}
@@ -890,7 +887,7 @@ int game_main(int argc, char* argv[]) {
// pre game launch ui main loop
if (!launchUILoop()) {
borealis::http::shutdown();
borealis::shutdown();
borealis::sentry::shutdown();
borealis::log::shutdown();
fflush(stdout);
@@ -981,7 +978,7 @@ int game_main(int argc, char* argv[]) {
OSReport("Starting main01 (Game Loop)...\n");
main01();
borealis::http::shutdown();
borealis::shutdown();
// We need to cleanly shut down the threads to avoid crashes on shutdown.
if (daMP_c::m_myObj) {