mirror of
https://github.com/TwilitRealm/dusklight
synced 2026-09-11 11:15:12 -04:00
Mod queue, install, drag-drop
This commit is contained in:
+9
-2
@@ -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 ()
|
||||
|
||||
Vendored
+1
-1
Submodule extern/aurora updated: f8573d34e6...f1189541e5
Vendored
+1
-1
Submodule extern/borealis updated: 4b76f9ea35...539fd17b3c
+11
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
namespace dusk::ui {
|
||||
|
||||
constexpr std::size_t kTouchLayoutControlCount = 9;
|
||||
constexpr size_t kTouchLayoutControlCount = 9;
|
||||
|
||||
struct TouchLayoutControlInfo {
|
||||
std::string_view layoutId;
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user