mirror of
https://github.com/TwilitRealm/dusklight
synced 2026-10-02 02:52:14 -04:00
Merge pull request #2434 from encounter/mod-updates
Mod update checks & queue revamp
This commit is contained in:
+9
-2
@@ -1497,10 +1497,13 @@ set(DUSK_FILES
|
||||
src/dusk/mods/loader/native_module.hpp
|
||||
src/dusk/mods/loader/prepatch.cpp
|
||||
src/dusk/mods/loader/prepatch.hpp
|
||||
src/dusk/mods/updates.cpp
|
||||
src/dusk/mods/updates.hpp
|
||||
src/dusk/mods/catalog.cpp
|
||||
src/dusk/mods/catalog.hpp
|
||||
src/dusk/mods/queue.cpp
|
||||
src/dusk/mods/queue.hpp
|
||||
src/dusk/mods/services.hpp
|
||||
src/dusk/mods/item.hpp
|
||||
src/dusk/mods/item_actor.cpp
|
||||
src/dusk/mods/item_checks.cpp
|
||||
@@ -1568,6 +1571,8 @@ set(DUSK_FILES
|
||||
src/dusk/ui/bool_button.hpp
|
||||
src/dusk/ui/button.cpp
|
||||
src/dusk/ui/button.hpp
|
||||
src/dusk/ui/clamped_text.cpp
|
||||
src/dusk/ui/clamped_text.hpp
|
||||
src/dusk/ui/color_input.cpp
|
||||
src/dusk/ui/color_input.hpp
|
||||
src/dusk/ui/component.cpp
|
||||
@@ -1601,8 +1606,8 @@ 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/online_mods.cpp
|
||||
src/dusk/ui/online_mods.hpp
|
||||
src/dusk/ui/package_row.cpp
|
||||
src/dusk/ui/package_row.hpp
|
||||
src/dusk/ui/mod_texture_provider.cpp
|
||||
@@ -1615,6 +1620,8 @@ set(DUSK_FILES
|
||||
src/dusk/ui/mod_window.hpp
|
||||
src/dusk/ui/modal.cpp
|
||||
src/dusk/ui/modal.hpp
|
||||
src/dusk/ui/mod_updates.cpp
|
||||
src/dusk/ui/mod_updates.hpp
|
||||
src/dusk/ui/mods_window.cpp
|
||||
src/dusk/ui/mods_window.hpp
|
||||
src/dusk/ui/nav_types.hpp
|
||||
|
||||
@@ -55,13 +55,6 @@ catalog-filters select-button input {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.catalog-library-link {
|
||||
padding: var(--space-sm) 10dp;
|
||||
border-radius: var(--radius-panel);
|
||||
text-align: left;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
catalog-results {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
@@ -228,7 +221,6 @@ catalog-card-body > footer {
|
||||
align-items: center;
|
||||
gap: 10dp;
|
||||
font-size: var(--font-size-3xs);
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
catalog-card-body > footer stat {
|
||||
@@ -237,6 +229,7 @@ catalog-card-body > footer stat {
|
||||
align-items: center;
|
||||
gap: 3dp;
|
||||
white-space: nowrap;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
catalog-card-body > footer icon {
|
||||
@@ -248,12 +241,19 @@ catalog-card-body > footer icon {
|
||||
catalog-card-body > footer .size {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
white-space: nowrap;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
catalog-card-body > footer .size.installed {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
catalog-card-body > footer .size.update-available {
|
||||
color: var(--color-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
catalog-pagination {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
update-badge {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
min-width: 20dp;
|
||||
height: 20dp;
|
||||
margin-left: 8dp;
|
||||
padding: 0 6dp;
|
||||
border-radius: 10dp;
|
||||
background-color: var(--color-accent);
|
||||
color: #19170f;
|
||||
font-family: var(--font-family-body);
|
||||
font-size: 12dp;
|
||||
font-weight: bold;
|
||||
line-height: 20dp;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
pointer-events: none;
|
||||
transform: translateY(0dp);
|
||||
}
|
||||
|
||||
.mod-update-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mod-update-link > update-label {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mod-update-link > update-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
+213
-29
@@ -15,37 +15,15 @@ window.mods content pane.mod-detail {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
mod-entry.browser,
|
||||
mod-entry.installs {
|
||||
mod-entry.online {
|
||||
min-height: 76dp;
|
||||
}
|
||||
|
||||
mod-entry.browser mod-icon {
|
||||
mod-entry.online mod-icon {
|
||||
color: var(--color-accent);
|
||||
decorator: text("" center center);
|
||||
}
|
||||
|
||||
mod-entry.installs mod-icon {
|
||||
color: var(--color-accent);
|
||||
decorator: text("" center center);
|
||||
}
|
||||
|
||||
mod-entry.installs {
|
||||
background-color: rgba(var(--color-control-rgb), 20%);
|
||||
box-shadow: rgba(var(--color-border-rgb), 25%) 0 0 0 1dp;
|
||||
}
|
||||
|
||||
mod-entry.installs:hover,
|
||||
mod-entry.installs:focus-visible {
|
||||
background-color: rgba(var(--color-interactive-rgb), 12%);
|
||||
box-shadow: var(--color-accent) 0 0 0 2dp;
|
||||
}
|
||||
|
||||
mod-entry.installs progress {
|
||||
height: 6dp;
|
||||
margin: var(--space-xs) 0 0 0;
|
||||
}
|
||||
|
||||
mod-list-separator {
|
||||
display: block;
|
||||
height: 1dp;
|
||||
@@ -79,6 +57,7 @@ mod-info-row > span {
|
||||
mod-entry {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-md);
|
||||
padding: 10dp;
|
||||
border-radius: 10dp;
|
||||
@@ -133,7 +112,7 @@ mod-info {
|
||||
mod-info header {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 6dp;
|
||||
}
|
||||
|
||||
@@ -142,14 +121,37 @@ mod-info header b {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
mod-info header small {
|
||||
flex: 0 0 auto;
|
||||
mod-info header update-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
mod-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 5dp;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: rgba(var(--color-text-rgb), 50%);
|
||||
}
|
||||
|
||||
mod-meta > * {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
mod-meta > mod-author {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: rgba(var(--color-text-rgb), 50%);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
mod-status.active {
|
||||
color: var(--color-success);
|
||||
}
|
||||
@@ -159,21 +161,22 @@ mod-status.failed {
|
||||
}
|
||||
|
||||
mod-network {
|
||||
margin-left: 6dp;
|
||||
flex: 0 0 auto;
|
||||
padding: 1dp 5dp;
|
||||
font-size: var(--font-size-xs);
|
||||
border-radius: 5dp;
|
||||
background-color: rgba(var(--color-info-rgb), 20%);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
mod-info > p {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.3;
|
||||
color: rgba(var(--color-text-rgb), 65%);
|
||||
max-height: 2.6em;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
mod-info > small {
|
||||
@@ -290,3 +293,184 @@ status-badge.network {
|
||||
color: var(--color-info);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
button.browse-mods-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
margin-right: 48dp;
|
||||
padding: 22dp;
|
||||
text-align: left;
|
||||
--button-background: rgba(var(--color-accent-rgb), 18%);
|
||||
--button-background-hover: rgba(var(--color-accent-rgb), 28%);
|
||||
--button-background-selected: rgba(var(--color-accent-rgb), 28%);
|
||||
--button-background-active: rgba(var(--color-accent-rgb), 38%);
|
||||
box-shadow: rgba(var(--color-accent-rgb), 65%) 0 0 0 1dp;
|
||||
}
|
||||
|
||||
browse-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
browse-copy h2 {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: var(--font-size-5xl);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
browse-copy p {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.4;
|
||||
color: rgba(var(--color-text-rgb), 70%);
|
||||
}
|
||||
|
||||
.browse-mods-action > icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-size-4xl);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
updates-header, update-card, update-changelog, queue-row {
|
||||
display: block;
|
||||
}
|
||||
|
||||
updates-header {
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
updates-header h2, online-section-heading h2 {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
updates-heading, online-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
online-section-heading {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-sm);
|
||||
}
|
||||
|
||||
online-section-heading button {
|
||||
flex: 0 0 auto;
|
||||
padding: 6dp 12dp;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
updates-heading button.icon-button {
|
||||
flex: 0 0 30dp;
|
||||
width: 30dp;
|
||||
height: 30dp;
|
||||
padding: 4dp;
|
||||
font-size: 22dp;
|
||||
}
|
||||
|
||||
updates-header p {
|
||||
display: block;
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
updates-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
updates-actions button.update-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8dp;
|
||||
padding: 8dp 14dp;
|
||||
font-size: var(--font-size-md);
|
||||
box-shadow: rgba(var(--color-accent-rgb), 40%) 0 0 0 1dp;
|
||||
}
|
||||
|
||||
updates-actions button.update-all icon {
|
||||
flex: 0 0 20dp;
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
updates-actions button.update-all > span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
queue-row {
|
||||
border-bottom-width: 1dp;
|
||||
border-bottom-color: rgba(var(--color-border-rgb), 30%);
|
||||
}
|
||||
|
||||
queue-row package-row {
|
||||
padding: 8dp 0 14dp;
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
window.mods package-row > section > header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
window.mods package-row h3 {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
window.mods package-row.failed footer > small {
|
||||
white-space: normal;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
update-card {
|
||||
padding: 12dp 0;
|
||||
border-bottom-width: 1dp;
|
||||
border-bottom-color: #ffffff22;
|
||||
}
|
||||
|
||||
update-card package-row {
|
||||
padding: 0;
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
update-changelog {
|
||||
padding: 8dp 12dp;
|
||||
margin-top: 8dp;
|
||||
background-color: #00000022;
|
||||
border-radius: var(--radius-small);
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
update-changelog p,
|
||||
update-changelog h1,
|
||||
update-changelog h2,
|
||||
update-changelog h3,
|
||||
update-changelog ul,
|
||||
update-changelog ol,
|
||||
update-changelog blockquote,
|
||||
update-changelog pre {
|
||||
display: block;
|
||||
margin: 0 0 var(--space-sm);
|
||||
}
|
||||
update-changelog h1,
|
||||
update-changelog h2,
|
||||
update-changelog h3 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -233,6 +233,14 @@ body.mirrored #menu-list button {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
body.mirrored #menu-list button.mod-update-link {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
#menu-list update-badge {
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
disc-info {
|
||||
position: absolute;
|
||||
left: 96dp;
|
||||
|
||||
@@ -44,6 +44,10 @@ tab-bar tab:active {
|
||||
decorator: vertical-gradient(rgba(var(--color-accent-rgb), 16) rgba(var(--color-accent-rgb), 64));
|
||||
}
|
||||
|
||||
tab-bar update-badge {
|
||||
font-effect: none;
|
||||
}
|
||||
|
||||
tab-bar[closable] tab-end-spacer {
|
||||
display: block;
|
||||
flex: 0 0 var(--toolbar-height);
|
||||
|
||||
@@ -634,10 +634,6 @@ modal-content pane > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
window.modal.install-queue {
|
||||
max-height: 768dp;
|
||||
}
|
||||
|
||||
window.modal.drop-install {
|
||||
max-height: 720dp;
|
||||
}
|
||||
@@ -646,16 +642,6 @@ window.modal.drop-install package-row {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
window.modal.install-queue modal-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
window.modal.install-queue modal-content pane {
|
||||
gap: 0;
|
||||
padding-right: 14dp;
|
||||
padding-bottom: 6dp;
|
||||
}
|
||||
|
||||
package-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -870,10 +856,6 @@ package-row.installed nav {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
package-row.installed nav > button.icon-action {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
verification-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -478,6 +478,11 @@ static void LoadFromPath(const char* path) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure mod update checks from the existing Dusklight updates cvar
|
||||
if (!j.contains("backend.checkForModUpdates") && j.contains("backend.checkForUpdates")) {
|
||||
j["backend.checkForModUpdates"] = j["backend.checkForUpdates"];
|
||||
}
|
||||
|
||||
UnregisteredConfigVars.clear();
|
||||
|
||||
for (const auto& el : j.items()) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "dusk/config.hpp"
|
||||
#include "dusk/config_var.hpp"
|
||||
#include "dusk/mods/updates.hpp"
|
||||
#include "mods/api.h"
|
||||
#include "mods/runtime.h"
|
||||
|
||||
@@ -277,7 +278,8 @@ public:
|
||||
void request_enable(std::string_view id);
|
||||
void request_disable(std::string_view id);
|
||||
ModOperationHandle request_reload(std::string_view id);
|
||||
ModOperationHandle request_install(std::filesystem::path path);
|
||||
ModOperationHandle request_install(
|
||||
std::filesystem::path path, std::optional<UpdatePrecondition> update = {});
|
||||
ModOperationHandle request_uninstall(std::string_view id);
|
||||
ModOperationHandle request_reactivate(std::string_view id);
|
||||
void notify_mod_failure(LoadedMod& mod, bool firstFailure);
|
||||
@@ -287,6 +289,8 @@ public:
|
||||
[[nodiscard]] bool can_update(const LoadedMod& mod) const;
|
||||
[[nodiscard]] LoadedMod* find_mod(std::string_view id);
|
||||
[[nodiscard]] const LoadedMod* find_mod(std::string_view id) const;
|
||||
|
||||
[[nodiscard]] bool initialized() const noexcept { return m_startupComplete; }
|
||||
[[nodiscard]] uint64_t generation() const noexcept { return m_generation; }
|
||||
|
||||
[[nodiscard]] auto mods() const {
|
||||
@@ -306,6 +310,7 @@ private:
|
||||
};
|
||||
struct InstallRequest {
|
||||
std::filesystem::path stagedPath;
|
||||
std::optional<UpdatePrecondition> update;
|
||||
std::shared_ptr<ModOperation> operation;
|
||||
};
|
||||
struct ReloadRequest {
|
||||
@@ -363,7 +368,8 @@ private:
|
||||
|
||||
void drain_retired_natives();
|
||||
void apply_pending_requests();
|
||||
[[nodiscard]] OperationResult install_staged(const std::filesystem::path& path);
|
||||
[[nodiscard]] OperationResult install_staged(
|
||||
const std::filesystem::path& path, const std::optional<UpdatePrecondition>& update);
|
||||
[[nodiscard]] OperationResult load_runtime_mod(const std::filesystem::path& path);
|
||||
[[nodiscard]] OperationResult reload_runtime_mod(
|
||||
LoadedMod& mod, const PackageCandidate* replacement = nullptr);
|
||||
|
||||
+211
-60
@@ -21,8 +21,8 @@ namespace {
|
||||
using json = nlohmann::json;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
constexpr std::string_view catalogUrl =
|
||||
"https://staging.twilitrealm.workers.dev/api/v1/games/dusklight/mods";
|
||||
constexpr std::string_view apiUrl =
|
||||
"https://staging.twilitrealm.workers.dev/api/v1/games/dusklight";
|
||||
|
||||
std::string_view sort_value(Sort sort) noexcept {
|
||||
switch (sort) {
|
||||
@@ -88,7 +88,7 @@ void append_query(std::string& url, std::string_view name, std::string_view valu
|
||||
}
|
||||
|
||||
std::string make_url(const Query& query) {
|
||||
std::string url{catalogUrl};
|
||||
std::string url{fmt::format("{}/mods", apiUrl)};
|
||||
if (!query.search.empty()) {
|
||||
append_query(url, "q", query.search);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ std::string make_url(const Query& query) {
|
||||
}
|
||||
|
||||
std::string make_detail_url(std::string_view id) {
|
||||
return fmt::format("{}/{}", catalogUrl, url_encode(id));
|
||||
return fmt::format("{}/{}", fmt::format("{}/mods", apiUrl), url_encode(id));
|
||||
}
|
||||
|
||||
const json& required_field(const json& object, const char* name) {
|
||||
@@ -121,6 +121,14 @@ const json& required_field(const json& object, const char* name) {
|
||||
return *iter;
|
||||
}
|
||||
|
||||
const json& required_array(const json& object, const char* name) {
|
||||
const auto& value = required_field(object, name);
|
||||
if (!value.is_array()) {
|
||||
throw std::runtime_error{fmt::format("field '{}' is not an array", name)};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string required_string(const json& object, const char* name) {
|
||||
const auto& value = required_field(object, name);
|
||||
if (!value.is_string()) {
|
||||
@@ -178,6 +186,40 @@ uint16_t required_u16(const json& object, const char* name) {
|
||||
return static_cast<uint16_t>(value);
|
||||
}
|
||||
|
||||
std::optional<uint32_t> optional_u32(const json& object, const char* name) {
|
||||
if (required_field(object, name).is_null()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const auto value = required_count(object, name);
|
||||
if (value > std::numeric_limits<uint32_t>::max()) {
|
||||
throw std::runtime_error{fmt::format("field '{}' is too large", name)};
|
||||
}
|
||||
return static_cast<uint32_t>(value);
|
||||
}
|
||||
|
||||
Download parse_download(const json& value) {
|
||||
return {
|
||||
.url = required_string(value, "url"),
|
||||
.sha256 = required_string(value, "sha256"),
|
||||
.size = required_count(value, "size"),
|
||||
};
|
||||
}
|
||||
|
||||
std::vector<ServiceImport> parse_service_imports(const json& object) {
|
||||
const auto& imports = required_array(object, "service_imports");
|
||||
std::vector<ServiceImport> result;
|
||||
result.reserve(imports.size());
|
||||
for (const auto& service : imports) {
|
||||
result.push_back({
|
||||
.id = required_string(service, "id"),
|
||||
.major = required_u16(service, "major"),
|
||||
.minMinor = required_u16(service, "min_minor"),
|
||||
.optional = required_bool(service, "optional"),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Image parse_image(const json& value) {
|
||||
const auto width = required_count(value, "width");
|
||||
const auto height = required_count(value, "height");
|
||||
@@ -190,10 +232,7 @@ Image parse_image(const json& value) {
|
||||
.width = static_cast<uint32_t>(width),
|
||||
.height = static_cast<uint32_t>(height),
|
||||
};
|
||||
const auto& sources = required_field(value, "sources");
|
||||
if (!sources.is_array()) {
|
||||
throw std::runtime_error{"field 'sources' is not an array"};
|
||||
}
|
||||
const auto& sources = required_array(value, "sources");
|
||||
image.sources.reserve(sources.size());
|
||||
for (const auto& source : sources) {
|
||||
const auto sourceWidth = required_count(source, "width");
|
||||
@@ -261,19 +300,13 @@ Mod parse_mod(const json& value) {
|
||||
mod.category = parse_mod_category(category);
|
||||
}
|
||||
|
||||
const auto& tags = required_field(value, "tags");
|
||||
if (!tags.is_array()) {
|
||||
throw std::runtime_error{"field 'tags' is not an array"};
|
||||
}
|
||||
const auto& tags = required_array(value, "tags");
|
||||
mod.tags.reserve(tags.size());
|
||||
for (const auto& tag : tags) {
|
||||
mod.tags.push_back(parse_tag(tag));
|
||||
}
|
||||
|
||||
const auto& platforms = required_field(value, "supported_platforms");
|
||||
if (!platforms.is_array()) {
|
||||
throw std::runtime_error{"field 'supported_platforms' is not an array"};
|
||||
}
|
||||
const auto& platforms = required_array(value, "supported_platforms");
|
||||
mod.supportedPlatforms.reserve(platforms.size());
|
||||
for (const auto& platform : platforms) {
|
||||
if (!platform.is_string()) {
|
||||
@@ -302,31 +335,11 @@ Detail parse_detail(std::string_view body) {
|
||||
.license = optional_string(root, "license"),
|
||||
.descriptionHtml = required_string(root, "description_html"),
|
||||
.changelogHtml = required_string(root, "changelog_html"),
|
||||
.download = parse_download(required_field(root, "download")),
|
||||
.modAbi = optional_u32(root, "mod_abi"),
|
||||
};
|
||||
|
||||
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<uint32_t>::max()) {
|
||||
throw std::runtime_error{"field 'mod_abi' is too large"};
|
||||
}
|
||||
detail.modAbi = static_cast<uint32_t>(value);
|
||||
}
|
||||
|
||||
const auto& screenshots = required_field(root, "screenshots");
|
||||
if (!screenshots.is_array()) {
|
||||
throw std::runtime_error{"field 'screenshots' is not an array"};
|
||||
}
|
||||
const auto& screenshots = required_array(root, "screenshots");
|
||||
detail.screenshots.reserve(screenshots.size());
|
||||
for (const auto& screenshot : screenshots) {
|
||||
detail.screenshots.push_back({
|
||||
@@ -335,19 +348,7 @@ Detail parse_detail(std::string_view body) {
|
||||
});
|
||||
}
|
||||
|
||||
const auto& imports = required_field(root, "service_imports");
|
||||
if (!imports.is_array()) {
|
||||
throw std::runtime_error{"field 'service_imports' is not an array"};
|
||||
}
|
||||
detail.serviceImports.reserve(imports.size());
|
||||
for (const auto& import : imports) {
|
||||
detail.serviceImports.push_back({
|
||||
.id = required_string(import, "id"),
|
||||
.major = required_u16(import, "major"),
|
||||
.minMinor = required_u16(import, "min_minor"),
|
||||
.optional = required_bool(import, "optional"),
|
||||
});
|
||||
}
|
||||
detail.serviceImports = parse_service_imports(root);
|
||||
return detail;
|
||||
}
|
||||
|
||||
@@ -359,19 +360,13 @@ Page parse_page(std::string_view body) {
|
||||
}
|
||||
|
||||
Page page;
|
||||
const auto& categories = required_field(root, "categories");
|
||||
if (!categories.is_array()) {
|
||||
throw std::runtime_error{"field 'categories' is not an array"};
|
||||
}
|
||||
const auto& categories = required_array(root, "categories");
|
||||
page.categories.reserve(categories.size());
|
||||
for (const auto& category : categories) {
|
||||
page.categories.push_back(parse_category(category));
|
||||
}
|
||||
|
||||
const auto& mods = required_field(root, "mods");
|
||||
if (!mods.is_array()) {
|
||||
throw std::runtime_error{"field 'mods' is not an array"};
|
||||
}
|
||||
const auto& mods = required_array(root, "mods");
|
||||
page.mods.reserve(mods.size());
|
||||
for (const auto& mod : mods) {
|
||||
page.mods.push_back(parse_mod(mod));
|
||||
@@ -455,4 +450,160 @@ borealis::Task<DetailFetchResult> fetch_detail(std::string id) {
|
||||
return borealis::http::start(make_request(make_detail_url(id))).map(finish_detail_request);
|
||||
}
|
||||
|
||||
std::string_view platform() noexcept {
|
||||
return catalog_platform();
|
||||
}
|
||||
|
||||
borealis::Task<UpdateFetchResult> fetch_updates(
|
||||
UpdateEnvironment environment, std::vector<std::string> targets) {
|
||||
json platformValue = environment.platform;
|
||||
if (environment.platform.empty()) {
|
||||
platformValue = nullptr;
|
||||
}
|
||||
json body{
|
||||
{"app_version", BOREALIS_APP_VERSION},
|
||||
{"platform", platformValue},
|
||||
{"mod_abi", environment.abi},
|
||||
{"targets", targets},
|
||||
{"services", json::array()},
|
||||
{"mods", json::array()},
|
||||
};
|
||||
for (const auto& service : environment.services) {
|
||||
json provider = service.providerId;
|
||||
if (service.providerId.empty()) {
|
||||
provider = nullptr;
|
||||
}
|
||||
body["services"].push_back({
|
||||
{"id", service.id},
|
||||
{"major", service.major},
|
||||
{"minor", service.minor},
|
||||
{"provider_mod_id", provider},
|
||||
});
|
||||
}
|
||||
for (const auto& mod : environment.mods) {
|
||||
json imports = json::array();
|
||||
for (const auto& service : mod.imports) {
|
||||
imports.push_back({
|
||||
{"id", service.id},
|
||||
{"major", service.major},
|
||||
{"min_minor", service.minMinor},
|
||||
});
|
||||
}
|
||||
body["mods"].push_back({
|
||||
{"id", mod.id},
|
||||
{"version", mod.version},
|
||||
{"enabled", mod.enabled},
|
||||
{"required_imports", std::move(imports)},
|
||||
});
|
||||
}
|
||||
auto request = make_request(fmt::format("{}/update-check", apiUrl));
|
||||
request.method = borealis::http::Method::Post;
|
||||
request.headers.push_back({"Content-Type", "application/json"});
|
||||
request.body = body.dump();
|
||||
request.maxBodyBytes = 8 * 1024 * 1024;
|
||||
return borealis::http::start(std::move(request))
|
||||
.map([environment = std::move(environment), targets = std::move(targets)](
|
||||
borealis::http::Result result) -> UpdateFetchResult {
|
||||
if (result.error != borealis::http::Error::None) {
|
||||
return {
|
||||
.error =
|
||||
result.message.empty() ? "Could not check mod updates." : result.message,
|
||||
.retryable = result.error == borealis::http::Error::Network ||
|
||||
result.error == borealis::http::Error::Timeout,
|
||||
};
|
||||
}
|
||||
if (result.response.statusCode != 200) {
|
||||
int retryAfter = 0;
|
||||
for (const auto& header : result.response.headers) {
|
||||
if (header.name == "Retry-After" || header.name == "retry-after") {
|
||||
try {
|
||||
retryAfter = std::clamp(std::stoi(header.value), 0, 3600);
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
.error = api_error(result.response),
|
||||
.retryable =
|
||||
result.response.statusCode == 429 || result.response.statusCode >= 500,
|
||||
.retryAfter = retryAfter,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const auto root = json::parse(result.response.body);
|
||||
const auto& rows = required_field(root, "mods");
|
||||
if (!rows.is_array() || rows.size() != targets.size()) {
|
||||
throw std::runtime_error{"Incomplete update response"};
|
||||
}
|
||||
std::vector<ModUpdate> updates;
|
||||
for (const auto& row : rows) {
|
||||
ModUpdate update{
|
||||
.id = required_string(row, "id"),
|
||||
.installedVersion = required_string(row, "installed_version"),
|
||||
.published = required_bool(row, "published"),
|
||||
.yankedInstalled = required_bool(row, "yanked_installed"),
|
||||
};
|
||||
const auto installed =
|
||||
std::ranges::find(environment.mods, update.id, &InstalledPackage::id);
|
||||
if (std::ranges::find(targets, update.id) == targets.end() ||
|
||||
std::ranges::find(updates, update.id, &ModUpdate::id) != updates.end() ||
|
||||
installed == environment.mods.end() ||
|
||||
installed->version != update.installedVersion)
|
||||
{
|
||||
throw std::runtime_error{
|
||||
"Update response does not match the installed inventory"};
|
||||
}
|
||||
const auto& latest = required_field(row, "latest");
|
||||
if (!latest.is_null()) {
|
||||
update.latestVersion = required_string(latest, "version");
|
||||
const auto& blockers = required_field(latest, "blockers");
|
||||
if (!blockers.is_array()) {
|
||||
throw std::runtime_error{"Invalid blockers"};
|
||||
}
|
||||
for (const auto& blocker : blockers) {
|
||||
update.blockers.push_back(required_string(blocker, "message"));
|
||||
}
|
||||
}
|
||||
const auto& target = required_field(row, "latest_compatible");
|
||||
if (!target.is_null()) {
|
||||
UpdateTarget parsed;
|
||||
parsed.version = required_string(target, "version");
|
||||
parsed.changelogHtml = required_string(target, "changelog_html");
|
||||
parsed.download = parse_download(required_field(target, "download"));
|
||||
auto& compatibility = parsed.compatibility;
|
||||
compatibility.containsNativeCode =
|
||||
required_bool(target, "contains_native_code");
|
||||
compatibility.platforms = required_field(target, "supported_platforms")
|
||||
.get<std::vector<std::string>>();
|
||||
compatibility.abi = optional_u32(target, "mod_abi");
|
||||
compatibility.imports = parse_service_imports(target);
|
||||
const auto& exports = required_array(target, "service_exports");
|
||||
for (const auto& service : exports) {
|
||||
compatibility.exports.push_back({
|
||||
required_string(service, "id"),
|
||||
required_u16(service, "major"),
|
||||
required_u16(service, "minor"),
|
||||
{},
|
||||
});
|
||||
}
|
||||
if (!parsed.download.url.starts_with("https://") ||
|
||||
parsed.download.size == 0 || parsed.download.sha256.size() != 64 ||
|
||||
!std::ranges::all_of(parsed.download.sha256,
|
||||
[](char c) {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||
}))
|
||||
{
|
||||
throw std::runtime_error{"Invalid update download"};
|
||||
}
|
||||
update.target = std::move(parsed);
|
||||
}
|
||||
updates.push_back(std::move(update));
|
||||
}
|
||||
return {.updates = std::move(updates)};
|
||||
} catch (const std::exception& error) {
|
||||
return {.error = fmt::format("Invalid mod update response: {}", error.what())};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace dusk::mods::catalog
|
||||
|
||||
+13
-13
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "updates.hpp"
|
||||
|
||||
#include <borealis/http.hpp>
|
||||
|
||||
#include <cstdint>
|
||||
@@ -77,19 +79,6 @@ struct Screenshot {
|
||||
Image image;
|
||||
};
|
||||
|
||||
struct ServiceImport {
|
||||
std::string id;
|
||||
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 siteUrl;
|
||||
@@ -126,6 +115,17 @@ struct DetailFetchResult {
|
||||
std::string error;
|
||||
};
|
||||
|
||||
struct UpdateFetchResult {
|
||||
std::optional<std::vector<ModUpdate>> updates;
|
||||
std::string error;
|
||||
bool retryable = false;
|
||||
int retryAfter = 0;
|
||||
};
|
||||
|
||||
std::string_view platform() noexcept;
|
||||
borealis::Task<UpdateFetchResult> fetch_updates(
|
||||
UpdateEnvironment environment, std::vector<std::string> targets);
|
||||
|
||||
/** Fetches one filtered page from the Dusklight catalog. */
|
||||
borealis::Task<FetchResult> fetch_page(Query query);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "dusk/mods/svc/config.hpp"
|
||||
#include "dusk/mods/svc/hook.hpp"
|
||||
#include "dusk/mods/svc/registry.hpp"
|
||||
#include "dusk/mods/updates.hpp"
|
||||
#include "dusk/ui/mod_texture_provider.hpp"
|
||||
#include "dusk/ui/mods_window.hpp"
|
||||
#include "dusk/ui/ui.hpp"
|
||||
@@ -487,10 +488,12 @@ ModOperationHandle ModLoader::request_reload(std::string_view id) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
ModOperationHandle ModLoader::request_install(fs::path path) {
|
||||
ModOperationHandle ModLoader::request_install(
|
||||
fs::path path, std::optional<UpdatePrecondition> update) {
|
||||
auto operation = std::make_shared<ModOperation>();
|
||||
m_pendingRequests.push_back(InstallRequest{
|
||||
.stagedPath = std::move(path),
|
||||
.update = std::move(update),
|
||||
.operation = operation,
|
||||
});
|
||||
return operation;
|
||||
@@ -979,7 +982,8 @@ ModLoader::OperationResult ModLoader::runtime_result(LoadedMod& mod) {
|
||||
return {.mod = &mod};
|
||||
}
|
||||
|
||||
ModLoader::OperationResult ModLoader::install_staged(const fs::path& requestedPath) {
|
||||
ModLoader::OperationResult ModLoader::install_staged(
|
||||
const fs::path& requestedPath, const std::optional<UpdatePrecondition>& update) {
|
||||
if (m_searchDirs.empty()) {
|
||||
return {
|
||||
.success = false,
|
||||
@@ -1017,6 +1021,15 @@ ModLoader::OperationResult ModLoader::install_staged(const fs::path& requestedPa
|
||||
};
|
||||
}
|
||||
|
||||
if (update) {
|
||||
if (metadata.id != update->modId || metadata.version != update->targetVersion) {
|
||||
return {.success = false, .message = "The update package identity changed"};
|
||||
}
|
||||
if (auto reason = updates::validate(*update); !reason.empty()) {
|
||||
return {.success = false, .message = std::move(reason)};
|
||||
}
|
||||
}
|
||||
|
||||
const auto destination = userDir / fmt::format("{}.dusk", safe_filename(metadata.id));
|
||||
auto* installed = find_mod(metadata.id);
|
||||
if (installed != nullptr && !can_update(*installed)) {
|
||||
@@ -1096,7 +1109,7 @@ void ModLoader::apply_pending_requests() {
|
||||
std::vector<LifecycleRequest> coalesced;
|
||||
for (const auto& request : requests) {
|
||||
if (const auto* install = std::get_if<InstallRequest>(&request)) {
|
||||
auto result = install_staged(install->stagedPath);
|
||||
auto result = install_staged(install->stagedPath, install->update);
|
||||
if (result.success && result.mod != nullptr) {
|
||||
const auto& metadata = result.mod->metadata;
|
||||
const std::string iconRml =
|
||||
|
||||
+34
-21
@@ -1,4 +1,5 @@
|
||||
#include "queue.hpp"
|
||||
#include "updates.hpp"
|
||||
|
||||
#include "dusk/hash.hpp"
|
||||
#include "dusk/mod_loader.hpp"
|
||||
@@ -41,6 +42,7 @@ enum class PendingIntent { None, Pause, Cancel };
|
||||
struct QueueItem {
|
||||
std::string key;
|
||||
Request request;
|
||||
std::string previousVersion;
|
||||
State state = State::Queued;
|
||||
std::filesystem::path partialPath;
|
||||
uint64_t completed = 0;
|
||||
@@ -69,8 +71,8 @@ QueueItem* find_queue_item_by_mod_id(std::string_view id) {
|
||||
return item == queueItems.end() ? nullptr : &*item;
|
||||
}
|
||||
|
||||
const Url* url_source(const QueueItem& item) {
|
||||
return std::get_if<Url>(&item.request.source);
|
||||
const Download* url_source(const QueueItem& item) {
|
||||
return std::get_if<Download>(&item.request.source);
|
||||
}
|
||||
|
||||
const LocalFile* local_source(const QueueItem& item) {
|
||||
@@ -171,7 +173,7 @@ bool copy_to_staging(const std::filesystem::path& source, const std::filesystem:
|
||||
}
|
||||
|
||||
VerifyResult verify_url_package(const std::filesystem::path& path, const Request& request,
|
||||
const Url& source, const std::filesystem::path& stagingDir, std::string key,
|
||||
const Download& 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);
|
||||
@@ -292,6 +294,12 @@ void schedule_retry(QueueItem& item, std::string message) {
|
||||
}
|
||||
|
||||
void start_download(QueueItem& item) {
|
||||
if (item.request.update) {
|
||||
if (auto error = updates::validate(*item.request.update); !error.empty()) {
|
||||
fail(item, std::move(error), false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const auto* source = url_source(item);
|
||||
if (source == nullptr) {
|
||||
fail(item, "The install source is not a URL", false);
|
||||
@@ -462,12 +470,19 @@ void finish_verification(QueueItem& item) {
|
||||
fail(item, "The local package changed after confirmation", true);
|
||||
return;
|
||||
}
|
||||
if (item.request.update) {
|
||||
if (auto error = updates::validate(*item.request.update); !error.empty()) {
|
||||
fail(item, std::move(error), true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
item.request.id = result.metadata.id;
|
||||
item.request.name = result.metadata.name;
|
||||
item.request.version = result.metadata.version;
|
||||
item.completed = item.total;
|
||||
item.state = State::Handoff;
|
||||
item.operation = ModLoader::instance().request_install(std::move(result.stagedPath));
|
||||
item.operation =
|
||||
ModLoader::instance().request_install(std::move(result.stagedPath), item.request.update);
|
||||
}
|
||||
|
||||
Item snapshot(const QueueItem& item) {
|
||||
@@ -476,6 +491,7 @@ Item snapshot(const QueueItem& item) {
|
||||
.modId = item.request.id,
|
||||
.name = item.request.name,
|
||||
.version = item.request.version,
|
||||
.previousVersion = item.previousVersion,
|
||||
.state = item.state,
|
||||
.completed = item.completed,
|
||||
.total = item.total,
|
||||
@@ -504,7 +520,7 @@ Item snapshot(const QueueItem& item) {
|
||||
} // namespace
|
||||
|
||||
bool enqueue(Request request, std::string* keyOut) {
|
||||
const auto* source = std::get_if<Url>(&request.source);
|
||||
const auto* source = std::get_if<Download>(&request.source);
|
||||
const auto* local = std::get_if<LocalFile>(&request.source);
|
||||
if (source != nullptr) {
|
||||
if (request.id.empty() || request.version.empty() || source->size == 0 ||
|
||||
@@ -520,11 +536,18 @@ bool enqueue(Request request, std::string* keyOut) {
|
||||
request.name = request.id;
|
||||
}
|
||||
|
||||
std::string previousVersion;
|
||||
if (const auto* installed = ModLoader::instance().find_mod(request.id);
|
||||
installed && installed->metadata.version != request.version)
|
||||
{
|
||||
previousVersion = installed->metadata.version;
|
||||
}
|
||||
if (auto* existing = find_queue_item_by_mod_id(request.id)) {
|
||||
if (!is_terminal(existing->state)) {
|
||||
return false;
|
||||
}
|
||||
existing->request = std::move(request);
|
||||
existing->previousVersion = std::move(previousVersion);
|
||||
existing->state = State::Queued;
|
||||
existing->completed = 0;
|
||||
existing->total = total;
|
||||
@@ -543,7 +566,12 @@ bool enqueue(Request request, std::string* keyOut) {
|
||||
if (keyOut != nullptr) {
|
||||
*keyOut = key;
|
||||
}
|
||||
queueItems.push_back({.key = key, .request = std::move(request), .total = total});
|
||||
queueItems.push_back({
|
||||
.key = key,
|
||||
.request = std::move(request),
|
||||
.previousVersion = std::move(previousVersion),
|
||||
.total = total,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -631,17 +659,6 @@ size_t item_count() noexcept {
|
||||
return queueItems.size();
|
||||
}
|
||||
|
||||
size_t active_count() noexcept {
|
||||
return static_cast<size_t>(std::ranges::count_if(
|
||||
queueItems, [](const QueueItem& item) { return !is_terminal(item.state); }));
|
||||
}
|
||||
|
||||
std::optional<Item> first_active() {
|
||||
const auto item = std::ranges::find_if(
|
||||
queueItems, [](const QueueItem& candidate) { return !is_terminal(candidate.state); });
|
||||
return item == queueItems.end() ? std::nullopt : std::optional<Item>{snapshot(*item)};
|
||||
}
|
||||
|
||||
size_t active_items_ahead(std::string_view id) noexcept {
|
||||
size_t result = 0;
|
||||
for (const auto& item : queueItems) {
|
||||
@@ -762,8 +779,4 @@ void pause_all() {
|
||||
}
|
||||
}
|
||||
|
||||
void clear_finished() {
|
||||
std::erase_if(queueItems, [](const QueueItem& item) { return is_terminal(item.state); });
|
||||
}
|
||||
|
||||
} // namespace dusk::mods::queue
|
||||
|
||||
+14
-12
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "updates.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
@@ -24,26 +26,27 @@ enum class State {
|
||||
Canceled,
|
||||
};
|
||||
|
||||
[[nodiscard]] constexpr bool is_failed(State state) noexcept {
|
||||
return state == State::InstallFailed || state == State::Failed;
|
||||
}
|
||||
|
||||
[[nodiscard]] constexpr bool is_completed(State state) noexcept {
|
||||
return state == State::Installed || state == State::Canceled;
|
||||
}
|
||||
|
||||
[[nodiscard]] constexpr bool is_terminal(State state) noexcept {
|
||||
return state == State::Installed || state == State::InstallFailed || state == State::Failed ||
|
||||
state == State::Canceled;
|
||||
return is_completed(state) || is_failed(state);
|
||||
}
|
||||
|
||||
[[nodiscard]] constexpr bool is_install_result(State state) noexcept {
|
||||
return state == State::Installed || state == State::InstallFailed;
|
||||
}
|
||||
|
||||
struct Url {
|
||||
std::string url;
|
||||
std::string sha256;
|
||||
uint64_t size = 0;
|
||||
};
|
||||
|
||||
struct LocalFile {
|
||||
std::filesystem::path path;
|
||||
};
|
||||
|
||||
using Source = std::variant<Url, LocalFile>;
|
||||
using Source = std::variant<Download, LocalFile>;
|
||||
|
||||
struct Icon {
|
||||
std::string url;
|
||||
@@ -57,6 +60,7 @@ struct Request {
|
||||
std::string version;
|
||||
Source source;
|
||||
std::optional<Icon> icon;
|
||||
std::optional<UpdatePrecondition> update;
|
||||
};
|
||||
|
||||
struct Item {
|
||||
@@ -65,6 +69,7 @@ struct Item {
|
||||
std::string modId;
|
||||
std::string name;
|
||||
std::string version;
|
||||
std::string previousVersion;
|
||||
State state = State::Queued;
|
||||
uint64_t completed = 0;
|
||||
uint64_t total = 0;
|
||||
@@ -85,8 +90,6 @@ void shutdown() noexcept;
|
||||
[[nodiscard]] std::optional<Item> find_by_mod_id(std::string_view id);
|
||||
[[nodiscard]] bool has_active_items();
|
||||
[[nodiscard]] size_t item_count() noexcept;
|
||||
[[nodiscard]] size_t active_count() noexcept;
|
||||
[[nodiscard]] std::optional<Item> first_active();
|
||||
[[nodiscard]] size_t active_items_ahead(std::string_view id) noexcept;
|
||||
|
||||
void pause(std::string_view id);
|
||||
@@ -96,6 +99,5 @@ void cancel(std::string_view id);
|
||||
void clear(std::string_view id);
|
||||
void remove_by_mod_id(std::string_view id);
|
||||
void pause_all();
|
||||
void clear_finished();
|
||||
|
||||
} // namespace dusk::mods::queue
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace dusk::mods {
|
||||
|
||||
struct ServiceImport {
|
||||
std::string id;
|
||||
uint16_t major = 0;
|
||||
uint16_t minMinor = 0;
|
||||
bool optional = false;
|
||||
bool operator==(const ServiceImport&) const = default;
|
||||
};
|
||||
|
||||
struct ServiceExport {
|
||||
std::string id;
|
||||
uint16_t major = 0;
|
||||
uint16_t minor = 0;
|
||||
std::string providerId;
|
||||
bool operator==(const ServiceExport&) const = default;
|
||||
};
|
||||
|
||||
inline std::string service_key(std::string_view id, uint16_t major) {
|
||||
return std::string{id} + '\x1f' + std::to_string(major);
|
||||
}
|
||||
|
||||
} // namespace dusk::mods
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <optional>
|
||||
#include <ranges>
|
||||
#include <string_view>
|
||||
#include <tuple>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
@@ -17,14 +18,11 @@
|
||||
namespace dusk::mods::svc {
|
||||
namespace {
|
||||
|
||||
uint64_t s_serviceGeneration = 0;
|
||||
std::unordered_map<std::string, ServiceRecord> s_services;
|
||||
std::unordered_set<std::string> s_unavailableServices;
|
||||
std::vector<const ServiceModule*> s_modules;
|
||||
|
||||
std::string service_key(std::string_view id, const uint16_t majorVersion) {
|
||||
return fmt::format("{}\x1f{}", id, majorVersion);
|
||||
}
|
||||
|
||||
const char* mod_id(const LoadedMod* mod) {
|
||||
return mod != nullptr ? mod->metadata.id.c_str() : AppName;
|
||||
}
|
||||
@@ -50,6 +48,7 @@ bool validate_service_header(const ServiceHeader* header, const char* serviceId,
|
||||
}
|
||||
|
||||
void clear_services() {
|
||||
++s_serviceGeneration;
|
||||
s_services.clear();
|
||||
s_unavailableServices.clear();
|
||||
s_modules.clear();
|
||||
@@ -57,6 +56,26 @@ void clear_services() {
|
||||
|
||||
} // namespace
|
||||
|
||||
uint64_t services_generation() noexcept {
|
||||
return s_serviceGeneration;
|
||||
}
|
||||
|
||||
std::vector<ServiceExport> list_services() {
|
||||
std::vector<ServiceExport> services;
|
||||
for (const auto& [key, record] : s_services) {
|
||||
if (record.service == nullptr ||
|
||||
(record.provider != nullptr && !record.provider->is_enabled()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
services.push_back({record.id, record.majorVersion, record.minorVersion,
|
||||
record.provider != nullptr ? record.provider->metadata.id : std::string{}});
|
||||
}
|
||||
std::ranges::sort(
|
||||
services, {}, [](const auto& service) { return std::tie(service.id, service.major); });
|
||||
return services;
|
||||
}
|
||||
|
||||
bool valid_service_id(const char* serviceId) {
|
||||
return serviceId != nullptr && serviceId[0] != '\0';
|
||||
}
|
||||
@@ -80,6 +99,7 @@ ModResult register_service(const char* serviceId, const uint16_t majorVersion,
|
||||
return MOD_CONFLICT;
|
||||
}
|
||||
|
||||
++s_serviceGeneration;
|
||||
s_services.emplace(key, ServiceRecord{
|
||||
serviceId,
|
||||
majorVersion,
|
||||
@@ -113,12 +133,14 @@ ModResult publish_deferred_service(
|
||||
return MOD_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
++s_serviceGeneration;
|
||||
record.service = service;
|
||||
record.minorVersion = header->minor_version;
|
||||
return MOD_OK;
|
||||
}
|
||||
|
||||
void remove_services_for_provider(const LoadedMod& provider) {
|
||||
++s_serviceGeneration;
|
||||
std::erase_if(
|
||||
s_services, [&](const auto& entry) { return entry.second.provider == &provider; });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "dusk/mod_loader.hpp"
|
||||
#include "dusk/mods/services.hpp"
|
||||
#include "mods/svc/host.h"
|
||||
#include "mods/svc/log.h"
|
||||
|
||||
@@ -48,6 +49,9 @@ struct ServiceModule {
|
||||
void (*shutdown)() = nullptr;
|
||||
};
|
||||
|
||||
std::vector<ServiceExport> list_services();
|
||||
uint64_t services_generation() noexcept;
|
||||
|
||||
bool valid_service_id(const char* serviceId);
|
||||
ModResult register_service(const char* serviceId, uint16_t majorVersion, uint16_t minorVersion,
|
||||
const void* service, LoadedMod* provider, bool deferred);
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
#include "updates.hpp"
|
||||
|
||||
#include "catalog.hpp"
|
||||
#include "dusk/mod_loader.hpp"
|
||||
#include "dusk/mods/queue.hpp"
|
||||
#include "dusk/mods/svc/registry.hpp"
|
||||
#include "dusk/settings.h"
|
||||
|
||||
#include <borealis/log.hpp>
|
||||
#include <borealis/update.hpp>
|
||||
#include <fmt/format.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <iterator>
|
||||
#include <tuple>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
|
||||
namespace dusk::mods::updates {
|
||||
namespace {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
constexpr borealis::Log Log{"dusk::mods::updates"};
|
||||
constexpr size_t batchSize = 64;
|
||||
|
||||
struct Store {
|
||||
State state = State::Idle;
|
||||
bool initialized = false;
|
||||
bool automatic = false;
|
||||
bool requested = false;
|
||||
bool tracking = false;
|
||||
bool fresh = false;
|
||||
uint64_t generation = 0;
|
||||
uint64_t loaderGeneration = 0;
|
||||
uint64_t serviceGeneration = 0;
|
||||
UpdateEnvironment environment;
|
||||
UpdateEnvironment requestEnvironment;
|
||||
borealis::Task<catalog::UpdateFetchResult> task;
|
||||
std::vector<std::string> targets;
|
||||
size_t nextTarget = 0;
|
||||
std::vector<ModUpdate> pending;
|
||||
std::vector<Entry> entries;
|
||||
std::string error;
|
||||
int retries = 0;
|
||||
Clock::time_point checkAt{};
|
||||
};
|
||||
|
||||
Store store;
|
||||
|
||||
bool hosted_id(std::string_view id) {
|
||||
if (id.empty() || id.size() > 128 || id.front() == '.' || id.back() == '.' ||
|
||||
id.find("..") != id.npos || id.find('.') == id.npos)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return std::ranges::all_of(id, [](char c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '.';
|
||||
});
|
||||
}
|
||||
|
||||
UpdatePrecondition precondition(const Entry& entry) {
|
||||
return {
|
||||
.modId = entry.result.id,
|
||||
.installedVersion = entry.result.installedVersion,
|
||||
.targetVersion = entry.result.target->version,
|
||||
.compatibility = entry.result.target->compatibility,
|
||||
};
|
||||
}
|
||||
|
||||
void refresh_entries() {
|
||||
for (auto& entry : store.entries) {
|
||||
std::string reason;
|
||||
std::string queueKey;
|
||||
if (const auto queued = queue::find_by_mod_id(entry.result.id);
|
||||
queued && !queue::is_completed(queued->state))
|
||||
{
|
||||
queueKey = queued->id;
|
||||
}
|
||||
const auto* local = ModLoader::instance().find_mod(entry.result.id);
|
||||
if (local == nullptr || local->metadata.version != entry.result.installedVersion) {
|
||||
reason = "The installed version changed.";
|
||||
} else if (!store.fresh) {
|
||||
reason = "Check for updates again.";
|
||||
} else if (entry.result.target) {
|
||||
if (!ModLoader::instance().can_update(*local)) {
|
||||
reason = "Development directories cannot be updated in-game.";
|
||||
} else {
|
||||
reason = validate(store.environment, precondition(entry));
|
||||
}
|
||||
} else if (!entry.result.blockers.empty()) {
|
||||
reason = entry.result.blockers.front();
|
||||
}
|
||||
const bool actionable = entry.result.target && reason.empty() && queueKey.empty();
|
||||
if (entry.reason != reason || entry.queueKey != queueKey || entry.actionable != actionable)
|
||||
{
|
||||
entry.reason = std::move(reason);
|
||||
entry.queueKey = std::move(queueKey);
|
||||
entry.actionable = actionable;
|
||||
++store.generation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void start_batch() {
|
||||
const auto end = std::min(store.nextTarget + batchSize, store.targets.size());
|
||||
std::vector<std::string> targets{
|
||||
store.targets.begin() + store.nextTarget, store.targets.begin() + end};
|
||||
store.nextTarget = end;
|
||||
store.task = catalog::fetch_updates(store.requestEnvironment, std::move(targets));
|
||||
}
|
||||
|
||||
void begin_check() {
|
||||
store.requested = false;
|
||||
store.requestEnvironment = store.environment;
|
||||
store.targets.clear();
|
||||
store.pending.clear();
|
||||
store.nextTarget = 0;
|
||||
for (const auto& mod : store.environment.mods) {
|
||||
if (hosted_id(mod.id) && borealis::update::parse_version(mod.version)) {
|
||||
store.targets.push_back(mod.id);
|
||||
}
|
||||
}
|
||||
store.error.clear();
|
||||
store.state = State::Checking;
|
||||
++store.generation;
|
||||
if (store.environment.mods.size() > 512 || store.environment.services.size() > 4096) {
|
||||
store.state = State::Failed;
|
||||
store.error = "The installed mod inventory exceeds the catalog limit.";
|
||||
} else if (store.targets.empty()) {
|
||||
store.entries.clear();
|
||||
store.state = State::Ready;
|
||||
store.fresh = true;
|
||||
} else {
|
||||
start_batch();
|
||||
}
|
||||
}
|
||||
|
||||
UpdateEnvironment capture_environment() {
|
||||
UpdateEnvironment result;
|
||||
result.platform = catalog::platform();
|
||||
result.abi = MOD_ABI_VERSION;
|
||||
#if defined(__APPLE__) && (TARGET_OS_IOS || TARGET_OS_TV)
|
||||
result.nativeUpdates = false;
|
||||
#endif
|
||||
result.services = svc::list_services();
|
||||
for (const auto& mod : ModLoader::instance().mods()) {
|
||||
InstalledPackage installed{
|
||||
.id = mod.metadata.id, .version = mod.metadata.version, .enabled = mod.is_enabled()};
|
||||
for (const auto& service : mod.manifestInfo.imports) {
|
||||
if (service.required) {
|
||||
installed.imports.push_back({service.id, service.major, service.minMinor, false});
|
||||
}
|
||||
}
|
||||
if (mod.runtime) {
|
||||
installed.imports.push_back(
|
||||
{mod.runtime->id, mod.runtime->major, mod.runtime->minMinor, false});
|
||||
}
|
||||
std::ranges::sort(installed.imports, {}, [](const auto& service) {
|
||||
return std::tie(service.id, service.major, service.minMinor);
|
||||
});
|
||||
// Runtime pins can also be present in the native manifest's required imports.
|
||||
std::vector<ServiceImport> imports;
|
||||
for (const auto& service : installed.imports) {
|
||||
if (!imports.empty() && imports.back().id == service.id &&
|
||||
imports.back().major == service.major)
|
||||
{
|
||||
imports.back().minMinor = std::max(imports.back().minMinor, service.minMinor);
|
||||
} else {
|
||||
imports.push_back(service);
|
||||
}
|
||||
}
|
||||
installed.imports = std::move(imports);
|
||||
result.mods.push_back(std::move(installed));
|
||||
}
|
||||
std::ranges::sort(result.mods, {}, &InstalledPackage::id);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string validate(const UpdateEnvironment& environment, const UpdatePrecondition& update) {
|
||||
const auto mod = std::ranges::find(environment.mods, update.modId, &InstalledPackage::id);
|
||||
if (mod == environment.mods.end()) {
|
||||
return "The mod is no longer installed.";
|
||||
}
|
||||
if (mod->version != update.installedVersion) {
|
||||
return "The installed version changed. Check for updates again.";
|
||||
}
|
||||
const auto installedVersion = borealis::update::parse_version(mod->version);
|
||||
const auto targetVersion = borealis::update::parse_version(update.targetVersion);
|
||||
if (!installedVersion || !targetVersion) {
|
||||
return "The mod version cannot be compared.";
|
||||
}
|
||||
if (borealis::update::compare_version(*targetVersion, *installedVersion) <= 0) {
|
||||
return "This version is already installed or older.";
|
||||
}
|
||||
const auto& candidate = update.compatibility;
|
||||
if (candidate.containsNativeCode) {
|
||||
if (!environment.nativeUpdates) {
|
||||
return "Native mod updates are not supported on this device.";
|
||||
}
|
||||
if (environment.platform.empty() || std::ranges::find(candidate.platforms,
|
||||
environment.platform) == candidate.platforms.end())
|
||||
{
|
||||
return "This release does not support this device.";
|
||||
}
|
||||
if (candidate.abi != environment.abi) {
|
||||
return "This release requires a different Dusklight ABI.";
|
||||
}
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, ServiceExport> services;
|
||||
for (const auto& service : environment.services) {
|
||||
if (service.providerId != update.modId) {
|
||||
services.emplace(service_key(service.id, service.major), service);
|
||||
}
|
||||
}
|
||||
if (mod->enabled) {
|
||||
for (auto service : candidate.exports) {
|
||||
service.providerId = update.modId;
|
||||
if (!services.emplace(service_key(service.id, service.major), service).second) {
|
||||
return fmt::format(
|
||||
"Another provider already supplies {}@{}.", service.id, service.major);
|
||||
}
|
||||
}
|
||||
}
|
||||
const auto available = [&](const std::string& id, uint16_t major, uint16_t minor) {
|
||||
const auto service = services.find(service_key(id, major));
|
||||
return service != services.end() && service->second.minor >= minor;
|
||||
};
|
||||
for (const auto& service : environment.services) {
|
||||
if (service.providerId == update.modId &&
|
||||
!available(service.id, service.major, service.minor))
|
||||
{
|
||||
return fmt::format("This release removes or lowers {}@{}.{}.", service.id,
|
||||
service.major, service.minor);
|
||||
}
|
||||
}
|
||||
for (const auto& required : candidate.imports) {
|
||||
if (!required.optional && !available(required.id, required.major, required.minMinor)) {
|
||||
return fmt::format(
|
||||
"Requires {}@{}.{} or newer.", required.id, required.major, required.minMinor);
|
||||
}
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::vector<std::string>> graph;
|
||||
for (const auto& installed : environment.mods) {
|
||||
if (!installed.enabled) {
|
||||
continue;
|
||||
}
|
||||
const auto& imports = installed.id == update.modId ? candidate.imports : installed.imports;
|
||||
for (const auto& required : imports) {
|
||||
const auto service = services.find(service_key(required.id, required.major));
|
||||
if (!required.optional && service != services.end() &&
|
||||
!service->second.providerId.empty() && service->second.providerId != installed.id)
|
||||
{
|
||||
graph[installed.id].push_back(service->second.providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::unordered_set<std::string> visited;
|
||||
const std::function<bool(const std::string&)> reaches_target = [&](const std::string& id) {
|
||||
if (id == update.modId) {
|
||||
return true;
|
||||
}
|
||||
if (!visited.insert(id).second) {
|
||||
return false;
|
||||
}
|
||||
const auto edges = graph.find(id);
|
||||
return edges != graph.end() && std::ranges::any_of(edges->second, reaches_target);
|
||||
};
|
||||
if (std::ranges::any_of(graph[update.modId], reaches_target)) {
|
||||
return "This release introduces a required dependency cycle.";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string validate(const UpdatePrecondition& value) {
|
||||
const auto* mod = ModLoader::instance().find_mod(value.modId);
|
||||
if (mod != nullptr && !ModLoader::instance().can_update(*mod)) {
|
||||
return "Development directories cannot be updated in-game.";
|
||||
}
|
||||
return validate(capture_environment(), value);
|
||||
}
|
||||
|
||||
void update() {
|
||||
auto& loader = ModLoader::instance();
|
||||
if (!loader.initialized()) {
|
||||
return;
|
||||
}
|
||||
if (!borealis::http::available()) {
|
||||
if (store.state != State::Unavailable) {
|
||||
store.state = State::Unavailable;
|
||||
++store.generation;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const bool automatic = getSettings().backend.checkForModUpdates.getValue();
|
||||
if (automatic && !store.automatic) {
|
||||
store.requested = true;
|
||||
store.checkAt = Clock::now() + std::chrono::seconds{1};
|
||||
} else if (!automatic && !store.tracking) {
|
||||
store.requested = false;
|
||||
}
|
||||
store.automatic = automatic;
|
||||
bool changed = !store.initialized || store.loaderGeneration != loader.generation() ||
|
||||
store.serviceGeneration != svc::services_generation();
|
||||
if (!changed) {
|
||||
for (const auto& mod : store.environment.mods) {
|
||||
const auto* current = loader.find_mod(mod.id);
|
||||
if (current == nullptr || current->is_enabled() != mod.enabled) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
auto environment = capture_environment();
|
||||
if (!store.initialized || environment != store.environment) {
|
||||
store.environment = std::move(environment);
|
||||
store.fresh = false;
|
||||
if (store.automatic || store.tracking) {
|
||||
store.requested = true;
|
||||
store.checkAt = Clock::now() + std::chrono::seconds{1};
|
||||
}
|
||||
++store.generation;
|
||||
}
|
||||
store.initialized = true;
|
||||
store.loaderGeneration = loader.generation();
|
||||
store.serviceGeneration = svc::services_generation();
|
||||
}
|
||||
if (store.task && store.task.ready()) {
|
||||
auto result = store.task.try_take();
|
||||
store.task = {};
|
||||
if (store.requestEnvironment != store.environment) {
|
||||
store.requested = store.automatic || store.tracking;
|
||||
store.checkAt = Clock::now() + std::chrono::seconds{1};
|
||||
} else if (result && result->updates) {
|
||||
store.pending.insert(store.pending.end(),
|
||||
std::make_move_iterator(result->updates->begin()),
|
||||
std::make_move_iterator(result->updates->end()));
|
||||
if (store.nextTarget < store.targets.size()) {
|
||||
start_batch();
|
||||
} else {
|
||||
store.entries.clear();
|
||||
for (auto& entry : store.pending) {
|
||||
store.entries.push_back({.result = std::move(entry)});
|
||||
}
|
||||
store.pending.clear();
|
||||
store.fresh = true;
|
||||
store.state = State::Ready;
|
||||
store.retries = 0;
|
||||
store.requested = false;
|
||||
++store.generation;
|
||||
Log.info("Checked {} installed mods for updates", store.entries.size());
|
||||
}
|
||||
} else {
|
||||
store.state = State::Failed;
|
||||
store.error = result ? result->error : "The update check did not return a result.";
|
||||
if (result && result->retryable && store.retries < 3 &&
|
||||
(store.automatic || store.tracking))
|
||||
{
|
||||
store.requested = true;
|
||||
store.checkAt = Clock::now() + std::chrono::seconds{std::max(
|
||||
result->retryAfter, 5 << store.retries++)};
|
||||
}
|
||||
Log.warn("Mod update check failed: {}", store.error);
|
||||
++store.generation;
|
||||
}
|
||||
}
|
||||
if (!store.task && store.requested && Clock::now() >= store.checkAt) {
|
||||
begin_check();
|
||||
}
|
||||
refresh_entries();
|
||||
}
|
||||
|
||||
void shutdown() noexcept {
|
||||
store = {};
|
||||
}
|
||||
|
||||
void request_check() {
|
||||
store.tracking = true;
|
||||
if (store.task) {
|
||||
return;
|
||||
}
|
||||
store.requested = true;
|
||||
store.retries = 0;
|
||||
store.checkAt = Clock::now();
|
||||
}
|
||||
|
||||
State state() noexcept {
|
||||
return store.state;
|
||||
}
|
||||
|
||||
uint64_t generation() noexcept {
|
||||
return store.generation;
|
||||
}
|
||||
|
||||
const std::vector<Entry>& entries() noexcept {
|
||||
return store.entries;
|
||||
}
|
||||
|
||||
const Entry* find(std::string_view id) {
|
||||
const auto entry = std::ranges::find(
|
||||
store.entries, id, [](const Entry& entry) { return std::string_view{entry.result.id}; });
|
||||
return entry == store.entries.end() ? nullptr : &*entry;
|
||||
}
|
||||
|
||||
size_t actionable_count() noexcept {
|
||||
return std::ranges::count_if(store.entries, &Entry::actionable);
|
||||
}
|
||||
|
||||
uint64_t download_size() noexcept {
|
||||
uint64_t total = 0;
|
||||
for (const auto& entry : store.entries) {
|
||||
if (entry.actionable) {
|
||||
total += entry.result.target->download.size;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
std::string status_text() {
|
||||
if (!store.initialized) {
|
||||
return "Waiting for mods to initialize.";
|
||||
}
|
||||
if (store.state == State::Unavailable) {
|
||||
return "Online mod updates are unavailable.";
|
||||
}
|
||||
if (store.state == State::Checking) {
|
||||
return "Checking for mod updates…";
|
||||
}
|
||||
if (store.state == State::Idle) {
|
||||
return store.requested ? "Checking for mod updates…" : "Automatic update checks are off.";
|
||||
}
|
||||
if (!store.error.empty()) {
|
||||
return fmt::format("Could not check: {}", store.error);
|
||||
}
|
||||
if (!store.fresh) {
|
||||
return store.requested || store.task ? "Installed mods changed. Checking again…" :
|
||||
"Installed mods changed. Check again.";
|
||||
}
|
||||
if (const auto count = actionable_count()) {
|
||||
return fmt::format("{} update{} available", count, count == 1 ? "" : "s");
|
||||
}
|
||||
if (std::ranges::any_of(
|
||||
store.entries, [](const Entry& entry) { return !entry.queueKey.empty(); }))
|
||||
{
|
||||
return "No other updates available.";
|
||||
}
|
||||
if (std::ranges::any_of(store.entries,
|
||||
[](const Entry& entry) {
|
||||
return entry.result.target || !entry.result.blockers.empty();
|
||||
}))
|
||||
{
|
||||
return "No updates ready to install.";
|
||||
}
|
||||
return "Your mods are up to date.";
|
||||
}
|
||||
|
||||
EnqueueResult enqueue_update(std::string_view id) {
|
||||
const auto* entry = find(id);
|
||||
if (!entry || !entry->result.target) {
|
||||
return {.skipped = 1, .error = "Check for updates first."};
|
||||
}
|
||||
if (const auto existing = queue::find_by_mod_id(id);
|
||||
existing && !queue::is_terminal(existing->state))
|
||||
{
|
||||
return {.skipped = 1, .queueKey = existing->id};
|
||||
}
|
||||
if (!store.fresh) {
|
||||
return {.skipped = 1, .error = "Check for updates again."};
|
||||
}
|
||||
auto expected = precondition(*entry);
|
||||
if (auto error = validate(expected); !error.empty()) {
|
||||
return {.skipped = 1, .error = std::move(error)};
|
||||
}
|
||||
const auto* local = ModLoader::instance().find_mod(id);
|
||||
const auto& target = *entry->result.target;
|
||||
std::string key;
|
||||
if (!queue::enqueue(
|
||||
{
|
||||
.id = std::string{id},
|
||||
.name = local->metadata.name,
|
||||
.version = target.version,
|
||||
.source = target.download,
|
||||
.update = std::move(expected),
|
||||
},
|
||||
&key))
|
||||
{
|
||||
return {.skipped = 1, .error = "Could not enqueue this update."};
|
||||
}
|
||||
refresh_entries();
|
||||
return {.accepted = 1, .queueKey = std::move(key)};
|
||||
}
|
||||
|
||||
EnqueueResult enqueue_all() {
|
||||
std::vector<std::string> ids;
|
||||
for (const auto& entry : store.entries) {
|
||||
if (entry.actionable) {
|
||||
ids.push_back(entry.result.id);
|
||||
}
|
||||
}
|
||||
EnqueueResult result;
|
||||
for (const auto& id : ids) {
|
||||
auto item = enqueue_update(id);
|
||||
result.accepted += item.accepted;
|
||||
result.skipped += item.skipped;
|
||||
if (!item.error.empty()) {
|
||||
result.error = std::move(item.error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace dusk::mods::updates
|
||||
@@ -0,0 +1,108 @@
|
||||
#pragma once
|
||||
|
||||
#include "services.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace dusk::mods {
|
||||
|
||||
struct Download {
|
||||
std::string url;
|
||||
std::string sha256;
|
||||
uint64_t size = 0;
|
||||
};
|
||||
|
||||
struct InstalledPackage {
|
||||
std::string id;
|
||||
std::string version;
|
||||
bool enabled = false;
|
||||
std::vector<ServiceImport> imports;
|
||||
bool operator==(const InstalledPackage&) const = default;
|
||||
};
|
||||
|
||||
struct UpdateEnvironment {
|
||||
std::string platform;
|
||||
uint32_t abi = 0;
|
||||
bool nativeUpdates = true;
|
||||
std::vector<ServiceExport> services;
|
||||
std::vector<InstalledPackage> mods;
|
||||
bool operator==(const UpdateEnvironment&) const = default;
|
||||
};
|
||||
|
||||
struct UpdateCompatibility {
|
||||
bool containsNativeCode = false;
|
||||
std::vector<std::string> platforms;
|
||||
std::optional<uint32_t> abi;
|
||||
std::vector<ServiceImport> imports;
|
||||
std::vector<ServiceExport> exports;
|
||||
};
|
||||
|
||||
struct UpdatePrecondition {
|
||||
std::string modId;
|
||||
std::string installedVersion;
|
||||
std::string targetVersion;
|
||||
UpdateCompatibility compatibility;
|
||||
};
|
||||
|
||||
struct UpdateTarget {
|
||||
std::string version;
|
||||
Download download;
|
||||
UpdateCompatibility compatibility;
|
||||
std::string changelogHtml;
|
||||
};
|
||||
|
||||
struct ModUpdate {
|
||||
std::string id;
|
||||
std::string installedVersion;
|
||||
bool published = false;
|
||||
bool yankedInstalled = false;
|
||||
std::string latestVersion;
|
||||
std::vector<std::string> blockers;
|
||||
std::optional<UpdateTarget> target;
|
||||
};
|
||||
|
||||
} // namespace dusk::mods
|
||||
|
||||
namespace dusk::mods::updates {
|
||||
|
||||
enum class State { Idle, Checking, Ready, Failed, Unavailable };
|
||||
|
||||
struct Entry {
|
||||
ModUpdate result;
|
||||
std::string reason;
|
||||
std::string queueKey;
|
||||
bool actionable = false;
|
||||
};
|
||||
|
||||
struct EnqueueResult {
|
||||
size_t accepted = 0;
|
||||
size_t skipped = 0;
|
||||
std::string error;
|
||||
std::string queueKey;
|
||||
};
|
||||
|
||||
void update();
|
||||
void shutdown() noexcept;
|
||||
void request_check();
|
||||
State state() noexcept;
|
||||
std::string status_text();
|
||||
uint64_t generation() noexcept;
|
||||
const std::vector<Entry>& entries() noexcept;
|
||||
const Entry* find(std::string_view id);
|
||||
size_t actionable_count() noexcept;
|
||||
uint64_t download_size() noexcept;
|
||||
EnqueueResult enqueue_update(std::string_view id);
|
||||
EnqueueResult enqueue_all();
|
||||
|
||||
/** Returns the reason an update cannot be applied, or an empty string. */
|
||||
std::string validate(const UpdateEnvironment& environment, const UpdatePrecondition& update);
|
||||
|
||||
/** Rechecks an update against the current loader and callable services. */
|
||||
std::string validate(const UpdatePrecondition& precondition);
|
||||
|
||||
} // namespace dusk::mods::updates
|
||||
@@ -176,6 +176,7 @@ UserSettings g_userSettings = {
|
||||
.skipPreLaunchUI {"backend.skipPreLaunchUI", false},
|
||||
.wasPresetChosen {"backend.wasPresetChosen", false},
|
||||
.checkForUpdates {"backend.checkForUpdates", true},
|
||||
.checkForModUpdates {"backend.checkForModUpdates", true},
|
||||
.cardFileType {"backend.cardFileType", static_cast<int>(CARD_GCIFOLDER)},
|
||||
.enableAdvancedSettings {"backend.enableAdvancedSettings", false},
|
||||
},
|
||||
@@ -388,6 +389,7 @@ void registerSettings() {
|
||||
Register(g_userSettings.backend.skipPreLaunchUI);
|
||||
Register(g_userSettings.backend.wasPresetChosen);
|
||||
Register(g_userSettings.backend.checkForUpdates);
|
||||
Register(g_userSettings.backend.checkForModUpdates);
|
||||
Register(g_userSettings.backend.cardFileType);
|
||||
Register(g_userSettings.backend.enableAdvancedSettings);
|
||||
|
||||
|
||||
@@ -312,6 +312,7 @@ struct UserSettings {
|
||||
ConfigVar<bool> skipPreLaunchUI;
|
||||
ConfigVar<bool> wasPresetChosen;
|
||||
ConfigVar<bool> checkForUpdates;
|
||||
ConfigVar<bool> checkForModUpdates;
|
||||
ConfigVar<int> cardFileType;
|
||||
ConfigVar<bool> enableAdvancedSettings;
|
||||
} backend;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "clamped_text.hpp"
|
||||
|
||||
#include <RmlUi/Core/ElementText.h>
|
||||
#include <RmlUi/Core/ElementUtilities.h>
|
||||
#include <RmlUi/Core/StringUtilities.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace dusk::ui {
|
||||
|
||||
ClampedText::ClampedText(Rml::Element* root, Rml::String text, int maxLines)
|
||||
: Component{root}, mSource{std::move(text)},
|
||||
mText{static_cast<Rml::ElementText*>(append_text(root, mSource))},
|
||||
mMaxLines{std::max(maxLines, 1)} {
|
||||
mRoot->SetProperty("white-space", "pre-line");
|
||||
mRoot->SetProperty("word-break", "break-word");
|
||||
}
|
||||
|
||||
void ClampedText::update() {
|
||||
const float width = mRoot->GetBox().GetSize(Rml::BoxArea::Content).x;
|
||||
const auto font = mText->GetFontFaceHandle();
|
||||
if (width > 0 && font && (width != mWidth || font != mFont)) {
|
||||
mWidth = width;
|
||||
mFont = font;
|
||||
mText->SetText(mSource);
|
||||
Rml::String result;
|
||||
int offset = 0;
|
||||
for (int index = 0; index < mMaxLines && offset < mSource.size(); ++index) {
|
||||
Rml::String line;
|
||||
int consumed = 0;
|
||||
float lineWidth = 0;
|
||||
mText->GenerateLine(line, consumed, lineWidth, offset, width, 0, true, false, false);
|
||||
offset += consumed;
|
||||
line = Rml::StringUtilities::StripWhitespace(line);
|
||||
if (index == mMaxLines - 1 && offset < mSource.size()) {
|
||||
while (!line.empty() && static_cast<float>(Rml::ElementUtilities::GetStringWidth(
|
||||
mText, line + "…")) > width)
|
||||
{
|
||||
const auto* end =
|
||||
Rml::StringUtilities::SeekBackwardUTF8(&line.back(), line.data());
|
||||
line.resize(end - line.data());
|
||||
}
|
||||
line = Rml::StringUtilities::StripWhitespace(line) + "…";
|
||||
}
|
||||
if (index > 0) {
|
||||
result += '\n';
|
||||
}
|
||||
result += line;
|
||||
}
|
||||
mText->SetText(result);
|
||||
}
|
||||
Component::update();
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "component.hpp"
|
||||
|
||||
namespace dusk::ui {
|
||||
|
||||
class ClampedText final : public Component {
|
||||
public:
|
||||
ClampedText(Rml::Element* root, Rml::String text, int maxLines);
|
||||
void update() override;
|
||||
|
||||
private:
|
||||
Rml::String mSource;
|
||||
Rml::ElementText* mText;
|
||||
int mMaxLines;
|
||||
float mWidth = 0;
|
||||
Rml::FontFaceHandle mFont = 0;
|
||||
};
|
||||
|
||||
} // namespace dusk::ui
|
||||
@@ -4,8 +4,8 @@
|
||||
#include "dusk/mods/loader/packages.hpp"
|
||||
#include "dusk/mods/queue.hpp"
|
||||
#include "format.hpp"
|
||||
#include "mods_window.hpp"
|
||||
#include "package_row.hpp"
|
||||
#include "queue_window.hpp"
|
||||
|
||||
#include <borealis/io.hpp>
|
||||
#include <borealis/update.hpp>
|
||||
@@ -148,10 +148,7 @@ void DropInstallModal::install() {
|
||||
}
|
||||
pop();
|
||||
if (!firstKey.empty()) {
|
||||
if (auto* current = top_document()) {
|
||||
current->cover();
|
||||
}
|
||||
push_document(std::make_unique<QueueWindow>(std::move(firstKey)));
|
||||
show_online_mods(std::move(firstKey));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ const char* material_icon(std::string_view name) {
|
||||
{"check_circle", "\uE86C"},
|
||||
{"favorite", "\uE87D"},
|
||||
{"arrow_back", "\uE5C4"},
|
||||
{"arrow_forward", "\uE5C8"},
|
||||
{"expand_less", "\uE5CE"},
|
||||
{"expand_more", "\uE5CF"},
|
||||
{"open_in_new", "\uE89E"},
|
||||
{"settings", "\uE8B8"},
|
||||
{"folder_open", "\uE2C8"},
|
||||
@@ -57,9 +60,25 @@ IconButton::IconButton(Rml::Element* parent, Props props)
|
||||
mTooltip{mRoot, props.label} {
|
||||
mRoot->SetClass("icon-button", true);
|
||||
mRoot->SetAttribute("aria-label", props.label);
|
||||
auto* icon = append(mRoot, "icon");
|
||||
icon->SetAttribute("aria-hidden", "true");
|
||||
append_text(icon, material_icon(props.icon));
|
||||
mIcon = append(mRoot, "icon");
|
||||
mIcon->SetAttribute("aria-hidden", "true");
|
||||
set_icon(props.icon);
|
||||
}
|
||||
|
||||
void IconButton::set_icon(std::string_view icon) {
|
||||
if (mIconName == icon) {
|
||||
return;
|
||||
}
|
||||
mIconName = icon;
|
||||
set_text_content(mIcon, material_icon(icon));
|
||||
}
|
||||
|
||||
void IconButton::set_label(const Rml::String& label) {
|
||||
if (mRoot->GetAttribute<Rml::String>("aria-label", "") == label) {
|
||||
return;
|
||||
}
|
||||
mRoot->SetAttribute("aria-label", label);
|
||||
mTooltip.set_label(label);
|
||||
}
|
||||
|
||||
void IconButton::update() {
|
||||
|
||||
@@ -19,9 +19,13 @@ public:
|
||||
};
|
||||
|
||||
IconButton(Rml::Element* parent, Props props);
|
||||
void set_icon(std::string_view icon);
|
||||
void set_label(const Rml::String& label);
|
||||
void update() override;
|
||||
|
||||
private:
|
||||
Rml::Element* mIcon;
|
||||
Rml::String mIconName;
|
||||
Tooltip mTooltip;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "achievements.hpp"
|
||||
#include "editor.hpp"
|
||||
#include "mod_updates.hpp"
|
||||
#include "modal.hpp"
|
||||
#include "mods_window.hpp"
|
||||
#include "prelaunch.hpp"
|
||||
@@ -34,6 +35,7 @@ const Rml::String kDocumentSource = R"RML(
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="res/rml/theme.rcss" />
|
||||
<link type="text/rcss" href="res/rml/mod_common.rcss" />
|
||||
<link type="text/rcss" href="res/rml/tabbing.rcss" />
|
||||
<link type="text/rcss" href="res/rml/popup.rcss" />
|
||||
</head>
|
||||
@@ -42,7 +44,6 @@ const Rml::String kDocumentSource = R"RML(
|
||||
</body>
|
||||
</rml>
|
||||
)RML";
|
||||
|
||||
}
|
||||
|
||||
MenuBar::MenuBar()
|
||||
@@ -83,7 +84,7 @@ void MenuBar::build_tabs() {
|
||||
{
|
||||
mTabBar->add_tab("Achievements", [this] { push(std::make_unique<AchievementsWindow>()); });
|
||||
}
|
||||
mTabBar->add_tab("Mods", [this] { push(std::make_unique<ModsWindow>()); });
|
||||
mModsButton = &mTabBar->add_tab("Mods", [this] { push(std::make_unique<ModsWindow>()); });
|
||||
for (auto& tab : mods::svc::ui_mod_menu_tabs()) {
|
||||
mTabBar->add_tab(tab.label, std::move(tab.onSelected));
|
||||
}
|
||||
@@ -190,6 +191,9 @@ void MenuBar::hide(bool close) {
|
||||
}
|
||||
|
||||
void MenuBar::update() {
|
||||
if (mModsButton) {
|
||||
set_mod_update_badge(*mModsButton);
|
||||
}
|
||||
update_safe_area();
|
||||
Document::update();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ private:
|
||||
void update_safe_area() noexcept;
|
||||
|
||||
Rml::Element* mRoot;
|
||||
Button* mModsButton = nullptr;
|
||||
std::unique_ptr<TabBar> mTabBar;
|
||||
std::unique_ptr<Button> mCloseButton;
|
||||
Insets mTabBarPadding;
|
||||
|
||||
+126
-44
@@ -1,23 +1,26 @@
|
||||
#include "mod_browser.hpp"
|
||||
|
||||
#include <borealis/update.hpp>
|
||||
#include "bool_button.hpp"
|
||||
#include "button.hpp"
|
||||
#include "dusk/mod_loader.hpp"
|
||||
#include "dusk/mods/loader/packages.hpp"
|
||||
#include "dusk/mods/queue.hpp"
|
||||
#include "dusk/mods/svc/registry.hpp"
|
||||
#include "dusk/mods/updates.hpp"
|
||||
#include "fmt/format.h"
|
||||
#include "format.hpp"
|
||||
#include "icon_button.hpp"
|
||||
#include "mod_updates.hpp"
|
||||
#include "mods_window.hpp"
|
||||
#include "nav_group.hpp"
|
||||
#include "package_row.hpp"
|
||||
#include "queue_window.hpp"
|
||||
#include "remote_texture_provider.hpp"
|
||||
#include "string_button.hpp"
|
||||
|
||||
#include <SDL3/SDL_misc.h>
|
||||
#include <borealis/http.hpp>
|
||||
#include <tracy/Tracy.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
@@ -158,11 +161,10 @@ class ModBrowserDetail;
|
||||
class CatalogCard final : public Button {
|
||||
public:
|
||||
CatalogCard(Rml::Element* parent, const mods::catalog::Mod& mod, std::function<void()> onOpen)
|
||||
: Button{parent, Props{}} {
|
||||
: Button{parent, Props{}}, mId{mod.id}, mPackageSize{format_bytes(mod.packageSize)} {
|
||||
mRoot->SetClass("catalog-card", true);
|
||||
mRoot->SetAttribute("mod-id", mod.id);
|
||||
const auto category = mod.category ? mod.category->name : "Uncategorized";
|
||||
const auto isInstalled = mods::ModLoader::instance().find_mod(mod.id) != nullptr;
|
||||
const auto installedLabel = isInstalled ? "Installed" : format_bytes(mod.packageSize);
|
||||
|
||||
auto* art = append(mRoot, "catalog-card-art");
|
||||
auto* artImage = append(art, "catalog-card-art-image");
|
||||
@@ -183,11 +185,8 @@ public:
|
||||
auto* endorsements = append(meta, "stat");
|
||||
append_text(append(endorsements, "icon"), material_icon("favorite"));
|
||||
append_text_element(endorsements, "span", format_count(mod.endorsements));
|
||||
auto* size = append_text_element(meta, "small", installedLabel);
|
||||
size->SetClass("size", true);
|
||||
if (isInstalled) {
|
||||
size->SetClass("installed", true);
|
||||
}
|
||||
mStatus = append(meta, "small");
|
||||
mStatus->SetClass("size", true);
|
||||
|
||||
if (mod.banner) {
|
||||
set_image(artImage, *mod.banner, 640);
|
||||
@@ -198,7 +197,28 @@ public:
|
||||
set_image(iconImage, *mod.icon, 128);
|
||||
}
|
||||
on_pressed(std::move(onOpen));
|
||||
update();
|
||||
}
|
||||
|
||||
void update() override {
|
||||
const bool installed = mods::ModLoader::instance().find_mod(mId) != nullptr;
|
||||
const auto* update = mods::updates::find(mId);
|
||||
const bool hasUpdate = installed && update && update->actionable;
|
||||
const auto label = hasUpdate ? "Update available" : installed ? "Installed" : mPackageSize;
|
||||
if (mLabel != label) {
|
||||
set_text_content(mStatus, label);
|
||||
mStatus->SetClass("installed", installed && !hasUpdate);
|
||||
mStatus->SetClass("update-available", hasUpdate);
|
||||
mLabel = label;
|
||||
}
|
||||
Button::update();
|
||||
}
|
||||
|
||||
private:
|
||||
std::string mId;
|
||||
std::string mPackageSize;
|
||||
std::string mLabel;
|
||||
Rml::Element* mStatus = nullptr;
|
||||
};
|
||||
|
||||
class ScreenshotViewer final : public Window {
|
||||
@@ -305,6 +325,7 @@ public:
|
||||
}
|
||||
|
||||
void update() override {
|
||||
ZoneScopedN("Mod browser detail update");
|
||||
if (mFetch && mFetch.ready()) {
|
||||
try {
|
||||
if (auto result = mFetch.try_take()) {
|
||||
@@ -337,7 +358,7 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
void show_downloads(const std::string& id) { push(std::make_unique<QueueWindow>(id)); }
|
||||
void show_downloads(const std::string& id) { show_online_mods(id); }
|
||||
|
||||
private:
|
||||
void begin_fetch() {
|
||||
@@ -381,12 +402,7 @@ public:
|
||||
.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,
|
||||
},
|
||||
.source = detail.download,
|
||||
.icon = queue_icon(detail.mod.icon),
|
||||
} {
|
||||
mRoot->SetClass("catalog-install-action", true);
|
||||
@@ -426,17 +442,18 @@ public:
|
||||
icon = "schedule";
|
||||
label = "Queued";
|
||||
if (const auto ahead = mods::queue::active_items_ahead(mRequest.id); ahead != 0) {
|
||||
caption = fmt::format("{} ahead · opens the queue", ahead);
|
||||
caption = fmt::format("{} ahead · view downloads", ahead);
|
||||
} else {
|
||||
caption = "Next · opens the queue";
|
||||
caption = "Next · view downloads";
|
||||
}
|
||||
break;
|
||||
case Downloading:
|
||||
label = fmt::format(
|
||||
"{} / {}", format_bytes(queued->completed), format_bytes(queued->total));
|
||||
caption = "Tap to open the queue";
|
||||
caption = "View downloads & installs";
|
||||
break;
|
||||
case Paused:
|
||||
mAction = Action::Resume;
|
||||
icon = "play_arrow";
|
||||
label = "Resume";
|
||||
caption = fmt::format("{} kept on disk", format_bytes(queued->completed));
|
||||
@@ -462,6 +479,7 @@ public:
|
||||
case InstallFailed:
|
||||
break;
|
||||
case Failed:
|
||||
mAction = Action::RetryDownload;
|
||||
icon = "refresh";
|
||||
label = queued->local ? "Retry package" : "Retry download";
|
||||
caption = queued->message.empty() ? "Package preparation failed" : queued->message;
|
||||
@@ -474,12 +492,13 @@ public:
|
||||
|
||||
if (label.empty()) {
|
||||
mAction = Action::Install;
|
||||
const int versionOrder = local != nullptr ?
|
||||
mods::compare_package_versions(mRequest.version, local->metadata.version) : 1;
|
||||
const int versionOrder =
|
||||
local != nullptr ?
|
||||
mods::compare_package_versions(mRequest.version, local->metadata.version) :
|
||||
1;
|
||||
const bool current = local != nullptr && versionOrder == 0;
|
||||
const bool updateable =
|
||||
local != nullptr && versionOrder > 0 &&
|
||||
mods::ModLoader::instance().can_update(*local);
|
||||
const bool updateable = local != nullptr && versionOrder > 0 &&
|
||||
mods::ModLoader::instance().can_update(*local);
|
||||
if (activationPending) {
|
||||
icon = "schedule";
|
||||
label = "Activating…";
|
||||
@@ -503,7 +522,35 @@ public:
|
||||
state = "installed";
|
||||
progress = 1.0f;
|
||||
} else {
|
||||
label = updateable ? "Update" : "Install";
|
||||
label = "Install";
|
||||
if (updateable) {
|
||||
const auto* update = mods::updates::find(mRequest.id);
|
||||
if (!borealis::update::parse_version(local->metadata.version)) {
|
||||
label = "Unavailable";
|
||||
caption = "The installed version cannot be compared.";
|
||||
disabled = true;
|
||||
} else if (update && !update->queueKey.empty()) {
|
||||
label = "View download";
|
||||
mAction = Action::OpenQueue;
|
||||
mQueueId = update->queueKey;
|
||||
} else if (update && update->result.target && update->actionable) {
|
||||
const bool sameVersion = update->result.target->version == mRequest.version;
|
||||
label = sameVersion ? "Update" : "View compatible update";
|
||||
mAction = sameVersion ? Action::Update : Action::OpenUpdates;
|
||||
} else if (update && !update->reason.empty() &&
|
||||
mods::updates::state() == mods::updates::State::Ready)
|
||||
{
|
||||
label = "Unavailable";
|
||||
caption = update->reason;
|
||||
disabled = true;
|
||||
} else {
|
||||
const bool checking =
|
||||
mods::updates::state() == mods::updates::State::Checking;
|
||||
label = checking ? "Checking…" : "Check for updates";
|
||||
mAction = Action::CheckUpdates;
|
||||
disabled = checking;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (disabled) {
|
||||
@@ -527,15 +574,27 @@ public:
|
||||
}
|
||||
if (mProgress != nullptr) {
|
||||
mProgress->SetAttribute("value", progress);
|
||||
mProgress->SetProperty(
|
||||
"display", state == "idle" || state == "installed" ? "none" : "block");
|
||||
set_display(mProgress, state == "idle" || state == "installed" ?
|
||||
Rml::Style::Display::None :
|
||||
Rml::Style::Display::Block);
|
||||
}
|
||||
set_disabled(disabled);
|
||||
Button::update();
|
||||
}
|
||||
|
||||
private:
|
||||
enum class Action { Install, OpenQueue, RetryActivation, OpenManager, None };
|
||||
enum class Action {
|
||||
Update,
|
||||
CheckUpdates,
|
||||
OpenUpdates,
|
||||
Install,
|
||||
OpenQueue,
|
||||
Resume,
|
||||
RetryDownload,
|
||||
RetryActivation,
|
||||
OpenManager,
|
||||
None
|
||||
};
|
||||
|
||||
std::optional<mods::queue::Item> matching_queue_item() const {
|
||||
auto item = mods::queue::find_by_mod_id(mRequest.id);
|
||||
@@ -550,16 +609,30 @@ private:
|
||||
void press() {
|
||||
update();
|
||||
switch (mAction) {
|
||||
case Action::Update:
|
||||
enqueue_mod_update(mRequest.id);
|
||||
return;
|
||||
case Action::CheckUpdates:
|
||||
mods::updates::request_check();
|
||||
return;
|
||||
case Action::OpenUpdates:
|
||||
show_online_mods();
|
||||
return;
|
||||
case Action::OpenQueue:
|
||||
mWindow.show_downloads(mQueueId);
|
||||
return;
|
||||
case Action::Resume:
|
||||
mods::queue::resume(mQueueId);
|
||||
return;
|
||||
case Action::RetryDownload:
|
||||
mods::queue::retry(mQueueId);
|
||||
return;
|
||||
case Action::RetryActivation:
|
||||
mActivationOperation = mods::ModLoader::instance().request_reactivate(mRequest.id);
|
||||
return;
|
||||
case Action::OpenManager:
|
||||
pop_to_or_push<ModsWindow>([id = mRequest.id](ModsWindow& window) {
|
||||
window.select_mod(id);
|
||||
});
|
||||
pop_to_or_push<ModsWindow>(
|
||||
[id = mRequest.id](ModsWindow& window) { window.select_mod(id); });
|
||||
return;
|
||||
case Action::None:
|
||||
return;
|
||||
@@ -586,7 +659,7 @@ private:
|
||||
Action mAction = Action::None;
|
||||
mods::ModOperationHandle mActivationOperation;
|
||||
|
||||
uint64_t package_size() const { return std::get<mods::queue::Url>(mRequest.source).size; }
|
||||
uint64_t package_size() const { return std::get<mods::Download>(mRequest.source).size; }
|
||||
};
|
||||
|
||||
DetailContent::DetailContent(
|
||||
@@ -684,13 +757,16 @@ DetailContent::DetailContent(
|
||||
set_image(image, detail.screenshots[index].image, index == 0 ? 1280 : 640);
|
||||
if (index == 2 && detail.screenshots.size() > shown) {
|
||||
auto* more = append(screenshot.root(), "catalog-screenshot-more");
|
||||
append_text_element(more, "span", fmt::format("+{}", detail.screenshots.size() - shown));
|
||||
append_text_element(
|
||||
more, "span", fmt::format("+{}", detail.screenshots.size() - shown));
|
||||
}
|
||||
screenshot.on_pressed([&window, index] { window.show_screenshot(index); });
|
||||
}
|
||||
}
|
||||
|
||||
if (std::ranges::any_of(detail.serviceImports, [](const auto& import) { return !import.optional; })) {
|
||||
if (std::ranges::any_of(
|
||||
detail.serviceImports, [](const auto& import) { return !import.optional; }))
|
||||
{
|
||||
auto* dependencies = append(main, "section");
|
||||
dependencies->SetClass("catalog-scroll-anchor", true);
|
||||
add_existing_item<ScrollAnchor>(dependencies);
|
||||
@@ -702,8 +778,8 @@ DetailContent::DetailContent(
|
||||
if (import.optional) {
|
||||
continue;
|
||||
}
|
||||
const bool available =
|
||||
mods::svc::find_service(import.id.c_str(), import.major, import.minMinor) != nullptr;
|
||||
const bool available = mods::svc::find_service(
|
||||
import.id.c_str(), import.major, import.minMinor) != nullptr;
|
||||
if (import.id.starts_with(DUSKLIGHT_SERVICE_ID_PREFIX)) {
|
||||
++requiredDusklight;
|
||||
if (!available) {
|
||||
@@ -723,8 +799,9 @@ DetailContent::DetailContent(
|
||||
auto* row = append(dependencyList, "catalog-dependency");
|
||||
append_text_element(row, "catalog-dependency-name", "Dusklight services");
|
||||
append_text_element(row, "catalog-dependency-status",
|
||||
dusklightProblems.empty() ? fmt::format("{} required · Available", requiredDusklight) :
|
||||
fmt::format("{} required", requiredDusklight));
|
||||
dusklightProblems.empty() ?
|
||||
fmt::format("{} required · Available", requiredDusklight) :
|
||||
fmt::format("{} required", requiredDusklight));
|
||||
for (const auto& problem : dusklightProblems) {
|
||||
append_text_element(row, "catalog-dependency-status", problem);
|
||||
}
|
||||
@@ -813,7 +890,7 @@ void ModBrowser::build_content(Rml::Element* content) {
|
||||
});
|
||||
sort.on_pressed([this] { cycle_sort(); });
|
||||
auto& device = filters.add_item<BoolButton>(BoolButton::Props{
|
||||
.key = "This device",
|
||||
.key = "Compatible only",
|
||||
.getValue = [this] { return mQuery.thisDevice; },
|
||||
.setValue =
|
||||
[this](bool value) {
|
||||
@@ -824,11 +901,6 @@ void ModBrowser::build_content(Rml::Element* content) {
|
||||
}
|
||||
},
|
||||
});
|
||||
append_text(append(filtersRoot, "h2"), "Library");
|
||||
auto& library = filters.add_item<Button>(
|
||||
fmt::format("Installed mods ({})", mods::ModLoader::instance().mods().size()));
|
||||
library.root()->SetClass("catalog-library-link", true);
|
||||
library.on_pressed([this] { pop(); });
|
||||
|
||||
auto* resultsRoot = append(content, "catalog-results");
|
||||
auto& results =
|
||||
@@ -1023,6 +1095,7 @@ void ModBrowser::cycle_sort() {
|
||||
}
|
||||
|
||||
void ModBrowser::update() {
|
||||
ZoneScopedN("Mod browser update");
|
||||
const auto loaderGeneration = mods::ModLoader::instance().generation();
|
||||
if (loaderGeneration != mLoaderGeneration) {
|
||||
mLoaderGeneration = loaderGeneration;
|
||||
@@ -1042,8 +1115,17 @@ void ModBrowser::update() {
|
||||
mFetch = {};
|
||||
}
|
||||
if (mRebuildRequested) {
|
||||
ZoneScopedN("Mod browser rebuild");
|
||||
mRebuildRequested = false;
|
||||
auto* viewport = mContentRoot->QuerySelector("catalog-viewport");
|
||||
const float scrollTop = viewport ? viewport->GetScrollTop() : 0;
|
||||
rebuild_content();
|
||||
if (mState == State::Ready) {
|
||||
mDocument->UpdateDocument();
|
||||
if (auto* restored = mContentRoot->QuerySelector("catalog-viewport")) {
|
||||
restored->SetScrollTop(scrollTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
Window::update();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
#include "mod_updates.hpp"
|
||||
|
||||
#include "button.hpp"
|
||||
#include "dusk/mod_loader.hpp"
|
||||
#include "dusk/mods/updates.hpp"
|
||||
#include "format.hpp"
|
||||
#include "icon_button.hpp"
|
||||
#include "mod_texture_provider.hpp"
|
||||
#include "nav_group.hpp"
|
||||
#include "package_row.hpp"
|
||||
#include "pane.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <borealis/update.hpp>
|
||||
#include <fmt/format.h>
|
||||
|
||||
namespace dusk::ui {
|
||||
namespace {
|
||||
|
||||
class UpdateHeader final : public NavGroup {
|
||||
public:
|
||||
explicit UpdateHeader(Rml::Element* parent)
|
||||
: NavGroup{append(parent, "updates-header"), {.layout = Layout::Horizontal}} {
|
||||
auto* heading = append(mRoot, "updates-heading");
|
||||
append_text(append(heading, "h2"), "Updates");
|
||||
mStatus = append(mRoot, "p");
|
||||
auto* actions = append(mRoot, "updates-actions");
|
||||
auto& all = add_existing_item<Button>(actions, "Update all");
|
||||
all.root()->SetClass("update-all", true);
|
||||
all.root()->SetAttribute("focus-key", "updates-all");
|
||||
all.on_pressed([] {
|
||||
const auto result = mods::updates::enqueue_all();
|
||||
if (result.skipped) {
|
||||
push_toast({.type = "warning",
|
||||
.title = "Some updates could not be queued",
|
||||
.content =
|
||||
fmt::format("{} skipped. {}", result.skipped, escape(result.error))});
|
||||
}
|
||||
});
|
||||
mAll = &all;
|
||||
auto& check = add_existing_item<IconButton>(
|
||||
heading, IconButton::Props{.icon = "refresh", .label = "Check for updates"});
|
||||
check.root()->SetAttribute("focus-key", "updates-check");
|
||||
check.on_pressed([] { mods::updates::request_check(); });
|
||||
mCheck = ✓
|
||||
update();
|
||||
}
|
||||
|
||||
void update() override {
|
||||
const auto count = mods::updates::actionable_count();
|
||||
set_text_content(mStatus, mods::updates::status_text());
|
||||
const auto label = count ? fmt::format("Update all ({}) · {}", count,
|
||||
format_bytes(mods::updates::download_size())) :
|
||||
"Update all";
|
||||
if (mAllLabel != label) {
|
||||
ui::clear_children(mAll->root());
|
||||
auto* icon = append(mAll->root(), "icon");
|
||||
icon->SetAttribute("aria-hidden", "true");
|
||||
append_text(icon, material_icon("file_download"));
|
||||
append_text(append(mAll->root(), "span"), label);
|
||||
mAllLabel = label;
|
||||
}
|
||||
mAll->set_disabled(count == 0);
|
||||
set_display(mAll->root(), count ? Rml::Style::Display::Flex : Rml::Style::Display::None);
|
||||
mCheck->set_disabled(mods::updates::state() == mods::updates::State::Checking);
|
||||
Component::update();
|
||||
}
|
||||
|
||||
private:
|
||||
Rml::Element* mStatus = nullptr;
|
||||
Button* mAll = nullptr;
|
||||
Button* mCheck = nullptr;
|
||||
Rml::String mAllLabel;
|
||||
};
|
||||
|
||||
class UpdateCard final : public NavGroup {
|
||||
public:
|
||||
UpdateCard(Rml::Element* parent, std::string id, std::unordered_set<std::string>& expanded)
|
||||
: NavGroup{append(parent, "update-card"), {.layout = Layout::Horizontal}},
|
||||
mId{std::move(id)} {
|
||||
auto& row = add_child<PackageRow>();
|
||||
mRow = &row;
|
||||
auto* actions = row.actions_root();
|
||||
auto& action = add_existing_item<IconButton>(
|
||||
actions, IconButton::Props{.icon = "file_download", .label = "Update"});
|
||||
action.root()->SetClass("icon-action", true);
|
||||
action.root()->SetAttribute("focus-key", "mod-action-" + mId);
|
||||
action.on_pressed([this] { enqueue_mod_update(mId); });
|
||||
mAction = &action;
|
||||
const auto* entry = mods::updates::find(mId);
|
||||
if (entry && entry->result.target && !entry->result.target->changelogHtml.empty()) {
|
||||
const bool isExpanded = expanded.contains(mId);
|
||||
auto& changelog = add_existing_item<IconButton>(
|
||||
actions, IconButton::Props{.icon = "description",
|
||||
.label = isExpanded ? "Hide changelog" : "Show changelog"});
|
||||
changelog.root()->SetClass("icon-action", true);
|
||||
changelog.root()->SetAttribute("focus-key", "changelog-" + mId);
|
||||
mChangelog = append(mRoot, "update-changelog");
|
||||
mChangelog->SetInnerRML(entry->result.target->changelogHtml);
|
||||
mChangelog->SetProperty("display", isExpanded ? "block" : "none");
|
||||
changelog.on_pressed([this, &changelog, &expanded] {
|
||||
const bool isExpanded = !expanded.contains(mId);
|
||||
if (isExpanded) {
|
||||
expanded.insert(mId);
|
||||
} else {
|
||||
expanded.erase(mId);
|
||||
}
|
||||
mChangelog->SetProperty("display", isExpanded ? "block" : "none");
|
||||
changelog.set_label(isExpanded ? "Hide changelog" : "Show changelog");
|
||||
changelog.set_selected(isExpanded);
|
||||
});
|
||||
changelog.set_selected(isExpanded);
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
void update() override {
|
||||
const auto* entry = mods::updates::find(mId);
|
||||
const auto* local = mods::ModLoader::instance().find_mod(mId);
|
||||
if (!entry || !local) {
|
||||
return;
|
||||
}
|
||||
std::string detail = entry->reason;
|
||||
std::string stateClass = entry->actionable ? "" : "unavailable";
|
||||
if (entry->result.target) {
|
||||
if (detail.empty()) {
|
||||
detail = format_bytes(entry->result.target->download.size);
|
||||
}
|
||||
if (entry->result.target->version != entry->result.latestVersion &&
|
||||
!entry->result.blockers.empty())
|
||||
{
|
||||
detail += fmt::format(" · Latest {}: {}", entry->result.latestVersion,
|
||||
entry->result.blockers.front());
|
||||
}
|
||||
}
|
||||
if (entry->result.yankedInstalled) {
|
||||
detail += " · The installed release was withdrawn.";
|
||||
}
|
||||
const auto& version =
|
||||
entry->result.target ? entry->result.target->version : entry->result.latestVersion;
|
||||
mRow->set_package(local->metadata.name,
|
||||
fmt::format("{} → {}", local->metadata.version, version), "", detail, stateClass);
|
||||
mRow->set_icon(local->metadata.iconPath.empty() ?
|
||||
"" :
|
||||
mod_image_source(*local, local->metadata.iconPath));
|
||||
mAction->set_label(entry->actionable ? "Update" : "Unavailable");
|
||||
mAction->set_disabled(!entry->actionable);
|
||||
Component::update();
|
||||
}
|
||||
|
||||
private:
|
||||
std::string mId;
|
||||
PackageRow* mRow = nullptr;
|
||||
IconButton* mAction = nullptr;
|
||||
Rml::Element* mChangelog = nullptr;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
void enqueue_mod_update(std::string_view id) {
|
||||
const auto result = mods::updates::enqueue_update(id);
|
||||
if (!result.error.empty()) {
|
||||
push_toast({.type = "warning",
|
||||
.title = "Could not queue update",
|
||||
.content = escape(result.error)});
|
||||
}
|
||||
}
|
||||
|
||||
void set_mod_update_badge(Component& component, std::string_view label) {
|
||||
auto* badge = component.root()->QuerySelector("update-badge");
|
||||
if (badge == nullptr) {
|
||||
auto* root = component.root();
|
||||
if (root->GetTagName() == "button" || root->GetTagName() == "tab") {
|
||||
root->SetClass("mod-update-link", true);
|
||||
auto labelElement = root->GetOwnerDocument()->CreateElement("update-label");
|
||||
while (root->GetNumChildren() > 0) {
|
||||
labelElement->AppendChild(root->RemoveChild(root->GetFirstChild()));
|
||||
}
|
||||
root->AppendChild(std::move(labelElement));
|
||||
}
|
||||
badge = append(component.root(), "update-badge");
|
||||
}
|
||||
const auto count = mods::updates::actionable_count();
|
||||
if (badge->GetAttribute<int>("count", -1) == static_cast<int>(count)) {
|
||||
return;
|
||||
}
|
||||
badge->SetAttribute("count", static_cast<int>(count));
|
||||
badge->SetProperty("display", count ? "inline-block" : "none");
|
||||
set_text_content(badge, fmt::format("{}", count));
|
||||
const auto description =
|
||||
count ? fmt::format("{} · {} mod updates available", label, count) : std::string{label};
|
||||
component.root()->SetAttribute("aria-label", description);
|
||||
component.root()->SetAttribute("title", description);
|
||||
}
|
||||
|
||||
void build_mod_updates(Pane& pane, std::unordered_set<std::string>& expanded) {
|
||||
pane.add_child<UpdateHeader>();
|
||||
for (bool blocked : {false, true}) {
|
||||
bool heading = false;
|
||||
for (const auto& entry : mods::updates::entries()) {
|
||||
const auto* mod = mods::ModLoader::instance().find_mod(entry.result.id);
|
||||
if (!mod || !entry.queueKey.empty() ||
|
||||
mod->metadata.version != entry.result.installedVersion)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const auto latest = borealis::update::parse_version(entry.result.latestVersion);
|
||||
const auto current = borealis::update::parse_version(mod->metadata.version);
|
||||
const bool newer =
|
||||
latest && current && borealis::update::compare_version(*latest, *current) > 0;
|
||||
if (!newer && !entry.result.yankedInstalled) {
|
||||
continue;
|
||||
}
|
||||
if (blocked != !entry.actionable) {
|
||||
continue;
|
||||
}
|
||||
if (blocked && !heading) {
|
||||
pane.add_section("Unavailable updates");
|
||||
heading = true;
|
||||
}
|
||||
pane.add_child<UpdateCard>(entry.result.id, expanded);
|
||||
}
|
||||
}
|
||||
for (const auto& mod : mods::ModLoader::instance().mods()) {
|
||||
if (!borealis::update::parse_version(mod.metadata.version)) {
|
||||
pane.add_text(fmt::format(
|
||||
"{}: version '{}' cannot be compared.", mod.metadata.name, mod.metadata.version));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace dusk::ui {
|
||||
class Component;
|
||||
class Pane;
|
||||
|
||||
void set_mod_update_badge(Component& component, std::string_view label = "Mods");
|
||||
void build_mod_updates(Pane& pane, std::unordered_set<std::string>& expanded);
|
||||
void enqueue_mod_update(std::string_view id);
|
||||
} // namespace dusk::ui
|
||||
+162
-129
@@ -1,14 +1,17 @@
|
||||
#include "mods_window.hpp"
|
||||
|
||||
#include "clamped_text.hpp"
|
||||
#include "dusk/mods/updates.hpp"
|
||||
#include "format.hpp"
|
||||
#include "icon_button.hpp"
|
||||
#include "logs_window.hpp"
|
||||
#include "mod_browser.hpp"
|
||||
#include "mod_texture_provider.hpp"
|
||||
#include "mod_updates.hpp"
|
||||
#include "modal.hpp"
|
||||
#include "mods/svc/http.h"
|
||||
#include "online_mods.hpp"
|
||||
#include "package_row.hpp"
|
||||
#include "pane.hpp"
|
||||
#include "queue_window.hpp"
|
||||
|
||||
#include <borealis/http.hpp>
|
||||
|
||||
@@ -22,6 +25,7 @@
|
||||
|
||||
#include <fmt/format.h>
|
||||
#include <fmt/ranges.h>
|
||||
#include <tracy/Tracy.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
@@ -62,6 +66,7 @@ bool mod_uses_network(const mods::LoadedMod& mod) {
|
||||
}
|
||||
|
||||
enum class ModAction {
|
||||
Update,
|
||||
Retry,
|
||||
Reload,
|
||||
Enable,
|
||||
@@ -79,6 +84,9 @@ struct ModActionInfo {
|
||||
|
||||
std::vector<ModActionInfo> available_mod_actions(const mods::LoadedMod& mod) {
|
||||
std::vector<ModActionInfo> actions;
|
||||
if (const auto* update = mods::updates::find(mod.metadata.id); update && update->actionable) {
|
||||
actions.push_back({ModAction::Update, "Update", "download"});
|
||||
}
|
||||
if (mod.activation_failed()) {
|
||||
actions.push_back({ModAction::Retry, "Retry", "replay"});
|
||||
actions.push_back({ModAction::Disable, "Disable", "pause"});
|
||||
@@ -119,18 +127,25 @@ public:
|
||||
auto* info = append(mRoot, "mod-info");
|
||||
auto* heading = append(info, "header");
|
||||
append_text(append(heading, "b"), mod.metadata.name);
|
||||
append_text(append(heading, "small"), fmt::format("v{}", mod.metadata.version));
|
||||
auto* sub = append(info, "small");
|
||||
append_text(sub, fmt::format("{} - ", mod.metadata.author));
|
||||
if (const auto* update = mods::updates::find(mod.metadata.id); update && update->actionable)
|
||||
{
|
||||
append_text(append(heading, "update-badge"), "Update");
|
||||
}
|
||||
if (mod_uses_network(mod)) {
|
||||
append_text(append(heading, "mod-network"), "Network");
|
||||
}
|
||||
auto* sub = append(info, "mod-meta");
|
||||
append_text(append(sub, "mod-author"), mod.metadata.author);
|
||||
append_text(append(sub, "span"), "·");
|
||||
append_text(append(sub, "mod-version"), fmt::format("v{}", mod.metadata.version));
|
||||
append_text(append(sub, "span"), "·");
|
||||
auto* statusElement = append(sub, "mod-status");
|
||||
if (status.badgeClass[0] != '\0') {
|
||||
statusElement->SetClass(status.badgeClass, true);
|
||||
}
|
||||
append_text(statusElement, status.text);
|
||||
if (mod_uses_network(mod)) {
|
||||
append_text(append(sub, "mod-network"), "Network");
|
||||
}
|
||||
append_text(append(info, "p"), snippet(mod.metadata.description, 90));
|
||||
mChildren.emplace_back(
|
||||
std::make_unique<ClampedText>(append(info, "p"), mod.metadata.description, 2));
|
||||
mRoot->SetClass("inactive", !mod.active);
|
||||
mRoot->SetClass("failed", mod.loadFailed);
|
||||
|
||||
@@ -144,76 +159,31 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
class BrowseModsEntry final : public FluentComponent<BrowseModsEntry> {
|
||||
class OnlineModsEntry final : public FluentComponent<OnlineModsEntry> {
|
||||
public:
|
||||
BrowseModsEntry(Rml::Element* parent, std::function<void()> onOpen)
|
||||
: FluentComponent{append(parent, "mod-entry")} {
|
||||
mRoot->SetClass("browser", true);
|
||||
explicit OnlineModsEntry(Rml::Element* parent) : FluentComponent{append(parent, "mod-entry")} {
|
||||
mRoot->SetClass("online", true);
|
||||
mRoot->SetAttribute("focus-key", "online-entry");
|
||||
append(mRoot, "mod-icon");
|
||||
|
||||
auto* info = append(mRoot, "mod-info");
|
||||
auto* heading = append(info, "header");
|
||||
append_text(append(heading, "b"), "Browse online mods");
|
||||
append_text(append(info, "p"), "Discover and install mods from the community.");
|
||||
|
||||
on_nav_command([callback = std::move(onOpen)](Rml::Event&, NavCommand cmd) {
|
||||
if (cmd != NavCommand::Confirm) {
|
||||
append_text(append(heading, "b"), "Online mods");
|
||||
append(heading, "update-badge");
|
||||
append_text(append(info, "small"), "Download community mods and check for updates.");
|
||||
on_nav_command([this](Rml::Event&, NavCommand command) {
|
||||
if (command != 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("installs", true);
|
||||
append(mRoot, "mod-icon");
|
||||
|
||||
auto* info = append(mRoot, "mod-info");
|
||||
auto* heading = append(info, "header");
|
||||
append_text(append(heading, "b"), "Installs");
|
||||
mSummary = append(info, "small");
|
||||
mProgress = append(info, "progress");
|
||||
|
||||
on_nav_command([callback = std::move(onOpen)](Rml::Event&, NavCommand cmd) {
|
||||
if (cmd != NavCommand::Confirm) {
|
||||
return false;
|
||||
}
|
||||
callback();
|
||||
mRoot->DispatchEvent(Rml::EventId::Submit, {});
|
||||
return true;
|
||||
});
|
||||
update();
|
||||
}
|
||||
|
||||
void update() override {
|
||||
const auto current = mods::queue::first_active();
|
||||
const auto activeCount = mods::queue::active_count();
|
||||
const auto totalCount = mods::queue::item_count();
|
||||
|
||||
if (!current) {
|
||||
set_text_content(mSummary, fmt::format("0 active · {} total", totalCount));
|
||||
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}%", activeCount, progress * 100.0f));
|
||||
mProgress->SetAttribute("value", progress);
|
||||
mProgress->SetProperty("display", "block");
|
||||
}
|
||||
set_mod_update_badge(*this, "Online mods");
|
||||
Component::update();
|
||||
}
|
||||
|
||||
private:
|
||||
Rml::Element* mSummary = nullptr;
|
||||
Rml::Element* mProgress = nullptr;
|
||||
};
|
||||
|
||||
class ModDetailHeader : public FluentComponent<ModDetailHeader> {
|
||||
@@ -285,7 +255,10 @@ private:
|
||||
} // namespace
|
||||
|
||||
ModsWindow::ModsWindow()
|
||||
: Window{Props{.tabBar = false, .styleSheets = {"res/rml/mods.rcss"}}},
|
||||
: Window{Props{
|
||||
.tabBar = false,
|
||||
.styleSheets = {"res/rml/mod_common.rcss", "res/rml/mods.rcss"},
|
||||
}},
|
||||
mContextMenu{*this, mRoot, "mod-entry, mod-header", [this](Rml::Element* target) {
|
||||
const auto id = target->GetAttribute<Rml::String>("mod-id", "");
|
||||
auto* mod = mods::ModLoader::instance().find_mod(id);
|
||||
@@ -295,7 +268,7 @@ ModsWindow::ModsWindow()
|
||||
mRoot->SetClass("mods", true);
|
||||
|
||||
refresh_snapshot();
|
||||
mQueueItemCount = mods::queue::item_count();
|
||||
mUpdateGeneration = mods::updates::generation();
|
||||
|
||||
set_content([this](Rml::Element* content) { build_content(content); });
|
||||
}
|
||||
@@ -312,15 +285,50 @@ bool ModsWindow::select_mod(std::string_view id) {
|
||||
mContextMenu.dismiss();
|
||||
mSelectedModId = id;
|
||||
mSelectedMod = nullptr;
|
||||
mBrowserSelected = false;
|
||||
mSelection = Selection::Mod;
|
||||
mFocusSelectedMod = true;
|
||||
refresh_snapshot();
|
||||
mQueueItemCount = mods::queue::item_count();
|
||||
rebuild_content();
|
||||
return true;
|
||||
}
|
||||
|
||||
void ModsWindow::select_online(std::string queueId) {
|
||||
mSelection = Selection::Online;
|
||||
mSelectedMod = nullptr;
|
||||
mSelectedModId.clear();
|
||||
mFocusQueueId = std::move(queueId);
|
||||
rebuild_content();
|
||||
}
|
||||
|
||||
void show_online_mods(std::string queueId) {
|
||||
pop_to_or_push<ModsWindow>([&queueId](ModsWindow& window) { window.select_online(queueId); });
|
||||
}
|
||||
|
||||
bool ModsWindow::focus() {
|
||||
if (mSelection == Selection::Online && mOnlineEntry) {
|
||||
mOnlineEntry->set_selected(true);
|
||||
}
|
||||
if (!mFocusQueueId.empty()) {
|
||||
mDocument->UpdateDocument();
|
||||
auto* row =
|
||||
mContentRoot->QuerySelector(fmt::format("queue-row[queue-id=\"{}\"]", mFocusQueueId));
|
||||
mFocusQueueId.clear();
|
||||
if (row) {
|
||||
Rml::ElementList actions;
|
||||
row->QuerySelectorAll(actions, "button");
|
||||
for (auto* action : actions) {
|
||||
if (action->IsVisible() && action->Focus()) {
|
||||
row->ScrollIntoView();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (auto* utility = selected_utility(); utility != nullptr) {
|
||||
const bool focused = utility->focus();
|
||||
utility->set_selected(true);
|
||||
return focused;
|
||||
}
|
||||
if (mFocusSelectedMod) {
|
||||
mDocument->UpdateDocument();
|
||||
for (size_t i = 0; i < mEntryMods.size(); ++i) {
|
||||
@@ -358,6 +366,9 @@ std::vector<ContextMenu::Item> ModsWindow::mod_actions(
|
||||
return;
|
||||
}
|
||||
switch (action) {
|
||||
case ModAction::Update:
|
||||
enqueue_mod_update(id);
|
||||
break;
|
||||
case ModAction::Retry:
|
||||
loader.request_reactivate(id);
|
||||
break;
|
||||
@@ -398,82 +409,67 @@ std::vector<ContextMenu::Item> ModsWindow::mod_actions(
|
||||
return items;
|
||||
}
|
||||
|
||||
Component* ModsWindow::selected_utility() const {
|
||||
return mSelection == Selection::Online ? mOnlineEntry : nullptr;
|
||||
}
|
||||
|
||||
void ModsWindow::build_online(Pane& pane) {
|
||||
mSelection = Selection::Online;
|
||||
mSelectedMod = nullptr;
|
||||
mSelectedModId.clear();
|
||||
pane.root()->RemoveAttribute("mod-id");
|
||||
build_online_mods(pane, *this, mExpandedChangelogs);
|
||||
mark_current_entry();
|
||||
}
|
||||
|
||||
void ModsWindow::build_content(Rml::Element* content) {
|
||||
mEntries.clear();
|
||||
mEntryMods.clear();
|
||||
mBrowserEntry = nullptr;
|
||||
|
||||
mOnlineEntry = nullptr;
|
||||
mQueueItems.clear();
|
||||
for (const auto& item : mods::queue::items()) {
|
||||
mQueueItems.emplace_back(item.id, mods::queue::is_completed(item.state));
|
||||
}
|
||||
auto& listPane = add_child<Pane>(content, Pane::Type::Controlled);
|
||||
listPane.root()->SetClass("mod-list", true);
|
||||
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_child<BrowseModsEntry>([this] { push(std::make_unique<ModBrowser>()); });
|
||||
mBrowserEntry = &browse;
|
||||
hasUtilityEntries = true;
|
||||
listPane.register_control(browse, detailPane, [this](Pane& pane) {
|
||||
mBrowserSelected = true;
|
||||
mSelectedMod = nullptr;
|
||||
mSelectedModId.clear();
|
||||
mark_current_entry();
|
||||
});
|
||||
}
|
||||
|
||||
if (mQueueItemCount != 0) {
|
||||
listPane.add_child<InstallQueueEntry>([this] { push(std::make_unique<QueueWindow>()); });
|
||||
hasUtilityEntries = true;
|
||||
}
|
||||
|
||||
auto& online = listPane.add_child<OnlineModsEntry>();
|
||||
mOnlineEntry = &online;
|
||||
listPane.register_control(online, detailPane, [this](Pane& pane) { build_online(pane); });
|
||||
const bool hasInstalledMods = !mods::ModLoader::instance().mods().empty();
|
||||
if (hasUtilityEntries && hasInstalledMods) {
|
||||
if (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;
|
||||
}
|
||||
mark_current_entry();
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto& trackedMod : mods::ModLoader::instance().mods()) {
|
||||
auto& entry = listPane.add_child<ModListEntry>(trackedMod);
|
||||
mEntries.push_back(&entry);
|
||||
mEntryMods.push_back(&trackedMod);
|
||||
listPane.register_control(entry, detailPane, [this, tracked = &trackedMod](Pane& pane) {
|
||||
mBrowserSelected = false;
|
||||
mSelection = Selection::Mod;
|
||||
mSelectedMod = tracked;
|
||||
mSelectedModId = tracked->metadata.id;
|
||||
pane.clear();
|
||||
build_detail(pane, *tracked);
|
||||
mark_current_entry();
|
||||
});
|
||||
}
|
||||
|
||||
if (mBrowserSelected && mBrowserEntry != nullptr) {
|
||||
mSelectedMod = nullptr;
|
||||
mSelectedModId.clear();
|
||||
if (!hasInstalledMods) {
|
||||
listPane.add_text("No mods installed.");
|
||||
mSelection = Selection::Online;
|
||||
}
|
||||
if (selected_utility()) {
|
||||
build_online(detailPane);
|
||||
mOnlineEntry->set_selected(true);
|
||||
} else if (hasInstalledMods) {
|
||||
mSelection = Selection::Mod;
|
||||
const auto selected = std::ranges::find_if(
|
||||
mEntryMods, [this](const auto* mod) { return mod->metadata.id == mSelectedModId; });
|
||||
mSelectedMod = selected != mEntryMods.end() ? *selected : mEntryMods.front();
|
||||
mSelectedModId = mSelectedMod->metadata.id;
|
||||
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);
|
||||
mSelectedModId.clear();
|
||||
}
|
||||
mark_current_entry();
|
||||
}
|
||||
@@ -593,8 +589,8 @@ void ModsWindow::refresh_snapshot() {
|
||||
}
|
||||
|
||||
void ModsWindow::mark_current_entry() {
|
||||
if (mBrowserEntry != nullptr) {
|
||||
mBrowserEntry->root()->SetClass("current", mBrowserSelected);
|
||||
if (mOnlineEntry) {
|
||||
mOnlineEntry->root()->SetClass("current", mSelection == Selection::Online);
|
||||
}
|
||||
for (size_t i = 0; i < mEntries.size(); ++i) {
|
||||
mEntries[i]->root()->SetClass("current", mEntryMods[i] == mSelectedMod);
|
||||
@@ -602,6 +598,7 @@ void ModsWindow::mark_current_entry() {
|
||||
}
|
||||
|
||||
void ModsWindow::update() {
|
||||
ZoneScopedN("Mod manager update");
|
||||
auto& loader = mods::ModLoader::instance();
|
||||
bool dirty = loader.generation() != mLoaderGeneration;
|
||||
if (dirty) {
|
||||
@@ -624,13 +621,18 @@ void ModsWindow::update() {
|
||||
}
|
||||
}
|
||||
}
|
||||
const auto queueItemCount = mods::queue::item_count();
|
||||
if (queueItemCount != mQueueItemCount) {
|
||||
mQueueItemCount = queueItemCount;
|
||||
if (mUpdateGeneration != mods::updates::generation()) {
|
||||
mUpdateGeneration = mods::updates::generation();
|
||||
dirty = true;
|
||||
}
|
||||
std::vector<std::pair<std::string, bool>> queueItems;
|
||||
for (const auto& item : mods::queue::items()) {
|
||||
queueItems.emplace_back(item.id, mods::queue::is_completed(item.state));
|
||||
}
|
||||
dirty |= queueItems != mQueueItems;
|
||||
if (dirty) {
|
||||
mContextMenu.dismiss();
|
||||
ZoneScopedN("Mod manager rebuild");
|
||||
const auto previousModId = mSelectedModId;
|
||||
std::optional<Rml::Property> previousBannerFilter;
|
||||
if (auto* image = mContentRoot->QuerySelector("mod-header-image")) {
|
||||
@@ -639,6 +641,17 @@ void ModsWindow::update() {
|
||||
auto* list = mContentRoot->QuerySelector("pane.mod-list");
|
||||
const float listScrollTop = list != nullptr ? list->GetScrollTop() : 0.0f;
|
||||
auto* focused = mDocument != nullptr ? mDocument->GetFocusLeafNode() : nullptr;
|
||||
std::string focusKey;
|
||||
for (auto* node = focused; node != nullptr && node != mContentRoot;
|
||||
node = node->GetParentNode())
|
||||
{
|
||||
if (node->HasAttribute("focus-key")) {
|
||||
focusKey = node->GetAttribute<Rml::String>("focus-key", "");
|
||||
break;
|
||||
}
|
||||
}
|
||||
auto* detail = mContentRoot->QuerySelector("pane.mod-detail");
|
||||
const float detailScrollTop = detail ? detail->GetScrollTop() : 0.0f;
|
||||
bool hadContentFocus = false;
|
||||
for (auto* node = focused; node != nullptr; node = node->GetParentNode()) {
|
||||
if (node == mContentRoot) {
|
||||
@@ -649,8 +662,25 @@ void ModsWindow::update() {
|
||||
rebuild_content();
|
||||
mDocument->UpdateDocument();
|
||||
if (hadContentFocus) {
|
||||
if (mBrowserSelected && mBrowserEntry != nullptr) {
|
||||
mBrowserEntry->root()->Focus(true);
|
||||
auto* restored = focusKey.empty() ? nullptr :
|
||||
mContentRoot->QuerySelector(
|
||||
fmt::format("[focus-key=\"{}\"]", focusKey));
|
||||
if (restored && restored->IsVisible() && !restored->IsPseudoClassSet("disabled") &&
|
||||
restored->Focus(true))
|
||||
{
|
||||
} else if (auto* utility = selected_utility(); utility != nullptr) {
|
||||
auto* fallback = mContentRoot->QuerySelector("queue-row button");
|
||||
if (!fallback || !fallback->IsVisible()) {
|
||||
fallback = mContentRoot->QuerySelector("[focus-key=completed-clear]");
|
||||
}
|
||||
if (!fallback || !fallback->IsVisible()) {
|
||||
fallback = mContentRoot->QuerySelector("[focus-key=online-browse]");
|
||||
}
|
||||
if (!focusKey.empty() && focusKey != "online-entry" && fallback) {
|
||||
fallback->Focus(true);
|
||||
} else {
|
||||
utility->root()->Focus(true);
|
||||
}
|
||||
} else {
|
||||
for (size_t i = 0; i < mEntryMods.size(); ++i) {
|
||||
if (mEntryMods[i] == mSelectedMod) {
|
||||
@@ -671,6 +701,9 @@ void ModsWindow::update() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (auto* refreshedDetail = mContentRoot->QuerySelector("pane.mod-detail")) {
|
||||
refreshedDetail->SetScrollTop(detailScrollTop);
|
||||
}
|
||||
if (auto* refreshedList = mContentRoot->QuerySelector("pane.mod-list")) {
|
||||
refreshedList->SetScrollTop(listScrollTop);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include "window.hpp"
|
||||
#include "context_menu.hpp"
|
||||
#include "window.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "dusk/mod_loader.hpp"
|
||||
@@ -21,8 +22,11 @@ public:
|
||||
void update() override;
|
||||
bool focus() override;
|
||||
bool select_mod(std::string_view id);
|
||||
void select_online(std::string queueId = {});
|
||||
|
||||
private:
|
||||
enum class Selection { Online, Mod };
|
||||
|
||||
struct ModSnapshot {
|
||||
mods::LoadedMod* mod = nullptr;
|
||||
bool active = false;
|
||||
@@ -33,6 +37,8 @@ private:
|
||||
};
|
||||
|
||||
void build_content(Rml::Element* content);
|
||||
void build_online(Pane& pane);
|
||||
Component* selected_utility() const;
|
||||
void build_detail(Pane& pane, mods::LoadedMod& mod);
|
||||
void confirm_uninstall(const mods::LoadedMod& mod);
|
||||
std::vector<ContextMenu::Item> mod_actions(const mods::LoadedMod& mod, bool contextMenu);
|
||||
@@ -42,14 +48,19 @@ private:
|
||||
std::vector<ModSnapshot> mSnapshot;
|
||||
std::vector<Component*> mEntries;
|
||||
std::vector<mods::LoadedMod*> mEntryMods;
|
||||
Component* mBrowserEntry = nullptr;
|
||||
Component* mOnlineEntry = nullptr;
|
||||
mods::LoadedMod* mSelectedMod = nullptr;
|
||||
std::string mSelectedModId;
|
||||
uint64_t mLoaderGeneration = 0;
|
||||
size_t mQueueItemCount = 0;
|
||||
bool mBrowserSelected = false;
|
||||
std::vector<std::pair<std::string, bool>> mQueueItems;
|
||||
std::string mFocusQueueId;
|
||||
Selection mSelection = Selection::Online;
|
||||
uint64_t mUpdateGeneration = 0;
|
||||
std::unordered_set<std::string> mExpandedChangelogs;
|
||||
bool mFocusSelectedMod = false;
|
||||
ContextMenu::Binding mContextMenu;
|
||||
};
|
||||
|
||||
void show_online_mods(std::string queueId = {});
|
||||
|
||||
} // namespace dusk::ui
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
#include "online_mods.hpp"
|
||||
|
||||
#include "bool_button.hpp"
|
||||
#include "document.hpp"
|
||||
#include "dusk/mod_loader.hpp"
|
||||
#include "dusk/mods/queue.hpp"
|
||||
#include "dusk/settings.h"
|
||||
#include "format.hpp"
|
||||
#include "icon_button.hpp"
|
||||
#include "mod_browser.hpp"
|
||||
#include "mod_texture_provider.hpp"
|
||||
#include "mod_updates.hpp"
|
||||
#include "nav_group.hpp"
|
||||
#include "package_row.hpp"
|
||||
#include "pane.hpp"
|
||||
#include "remote_texture_provider.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <borealis/http.hpp>
|
||||
#include <fmt/format.h>
|
||||
|
||||
namespace dusk::ui {
|
||||
namespace {
|
||||
using mods::queue::is_completed;
|
||||
using mods::queue::is_failed;
|
||||
using mods::queue::State;
|
||||
|
||||
class QueueRow final : public NavGroup {
|
||||
public:
|
||||
QueueRow(Rml::Element* parent, const mods::queue::Item& item)
|
||||
: NavGroup{append(parent, "queue-row"), {.layout = Layout::Horizontal}}, mId{item.id} {
|
||||
mRoot->SetAttribute("queue-id", mId);
|
||||
auto& row = add_child<PackageRow>();
|
||||
mRow = &row;
|
||||
auto& pause = add_existing_item<IconButton>(
|
||||
row.actions_root(), IconButton::Props{.icon = "pause", .label = "Pause"});
|
||||
mPause = &pause;
|
||||
pause.root()->SetClass("icon-action", true);
|
||||
// The update action keeps focus when its package becomes a download.
|
||||
pause.root()->SetAttribute("focus-key", "mod-action-" + item.modId);
|
||||
pause.on_pressed([this] {
|
||||
if (const auto item = mods::queue::find(mId)) {
|
||||
if (item->state == State::Paused) {
|
||||
mods::queue::resume(mId);
|
||||
} else if (is_failed(item->state)) {
|
||||
mods::queue::retry(mId);
|
||||
} else {
|
||||
mods::queue::pause(mId);
|
||||
}
|
||||
}
|
||||
});
|
||||
auto& cancel = add_existing_item<IconButton>(
|
||||
row.actions_root(), IconButton::Props{.icon = "close", .label = "Cancel"});
|
||||
mCancel = &cancel;
|
||||
cancel.root()->SetClass("icon-action", true);
|
||||
cancel.root()->SetAttribute("focus-key", "queue-clear-" + item.modId);
|
||||
cancel.on_pressed([this] {
|
||||
if (const auto item = mods::queue::find(mId)) {
|
||||
if (mods::queue::is_terminal(item->state)) {
|
||||
mods::queue::clear(mId);
|
||||
} else {
|
||||
mods::queue::cancel(mId);
|
||||
}
|
||||
}
|
||||
});
|
||||
update();
|
||||
}
|
||||
|
||||
void update() override {
|
||||
const auto item = mods::queue::find(mId);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const bool failed = is_failed(item->state);
|
||||
std::string detail = format_bytes(item->total);
|
||||
if (failed) {
|
||||
detail = item->message;
|
||||
} else if (item->completed || item->state == State::Downloading ||
|
||||
item->state == State::Paused)
|
||||
{
|
||||
detail =
|
||||
fmt::format("{} / {}", format_bytes(item->completed), format_bytes(item->total));
|
||||
} else if (!item->message.empty()) {
|
||||
detail += " · " + item->message;
|
||||
}
|
||||
std::optional<float> progress;
|
||||
if (!mods::queue::is_terminal(item->state) && item->total) {
|
||||
progress = std::clamp(static_cast<float>(item->completed) / item->total, 0.0f, 1.0f);
|
||||
}
|
||||
auto status = state_label(*item);
|
||||
if (item->state == State::Installed) {
|
||||
status.clear();
|
||||
detail = "Installed · " + format_bytes(item->total);
|
||||
}
|
||||
const auto version = item->previousVersion.empty() ?
|
||||
item->version :
|
||||
fmt::format("{} → {}", item->previousVersion, item->version);
|
||||
mRow->set_package(
|
||||
item->name, version, status, detail, queue_state_class(item->state), progress);
|
||||
std::string iconSource;
|
||||
if (item->icon && !item->icon->url.empty()) {
|
||||
iconSource =
|
||||
remote_image_source(item->icon->url, item->icon->width, item->icon->height);
|
||||
} else if (const auto* mod = mods::ModLoader::instance().find_mod(item->modId);
|
||||
mod && !mod->metadata.iconPath.empty())
|
||||
{
|
||||
iconSource = mod_image_source(*mod, mod->metadata.iconPath);
|
||||
}
|
||||
mRow->set_icon(std::move(iconSource));
|
||||
|
||||
const bool canPause = (!item->local && item->state == State::Queued) ||
|
||||
item->state == State::Downloading || item->state == State::Paused ||
|
||||
item->state == State::Retrying || failed;
|
||||
set_display(
|
||||
mPause->root(), canPause ? Rml::Style::Display::Flex : Rml::Style::Display::None);
|
||||
mPause->set_icon(failed ? "refresh" :
|
||||
item->state == State::Paused ? "play_arrow" :
|
||||
"pause");
|
||||
mPause->set_label(failed ? "Retry" : item->state == State::Paused ? "Resume" : "Pause");
|
||||
set_display(mCancel->root(),
|
||||
item->state == State::Handoff ? Rml::Style::Display::None : Rml::Style::Display::Flex);
|
||||
mCancel->set_label(failed ? "Dismiss" : is_completed(item->state) ? "Clear" : "Cancel");
|
||||
Component::update();
|
||||
}
|
||||
|
||||
private:
|
||||
std::string mId;
|
||||
PackageRow* mRow = nullptr;
|
||||
IconButton* mPause = nullptr;
|
||||
IconButton* mCancel = nullptr;
|
||||
};
|
||||
|
||||
class DownloadsHeader final : public NavGroup {
|
||||
public:
|
||||
explicit DownloadsHeader(Rml::Element* parent)
|
||||
: NavGroup{append(parent, "online-section-heading"), {.layout = Layout::Horizontal}} {
|
||||
append_text(append(mRoot, "h2"), "Downloads & installs");
|
||||
auto& pause = add_item<Button>("Pause all");
|
||||
mPause = &pause;
|
||||
pause.root()->SetAttribute("focus-key", "downloads-pause-all");
|
||||
pause.on_pressed([this] {
|
||||
if (mResume) {
|
||||
for (const auto& item : mods::queue::items()) {
|
||||
if (item.state == State::Paused) {
|
||||
mods::queue::resume(item.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mods::queue::pause_all();
|
||||
}
|
||||
});
|
||||
update();
|
||||
}
|
||||
|
||||
void update() override {
|
||||
bool canPause = false;
|
||||
bool canResume = false;
|
||||
for (const auto& item : mods::queue::items()) {
|
||||
canPause |=
|
||||
!item.local && (item.state == State::Queued || item.state == State::Downloading ||
|
||||
item.state == State::Retrying);
|
||||
canResume |= item.state == State::Paused;
|
||||
}
|
||||
mResume = !canPause && canResume;
|
||||
mPause->set_text(mResume ? "Resume all" : "Pause all");
|
||||
set_display(mPause->root(),
|
||||
canPause || canResume ? Rml::Style::Display::Block : Rml::Style::Display::None);
|
||||
Component::update();
|
||||
}
|
||||
|
||||
private:
|
||||
Button* mPause = nullptr;
|
||||
bool mResume = false;
|
||||
};
|
||||
|
||||
class CompletedHeader final : public NavGroup {
|
||||
public:
|
||||
explicit CompletedHeader(Rml::Element* parent)
|
||||
: NavGroup{append(parent, "online-section-heading"), {.layout = Layout::Horizontal}} {
|
||||
mRoot->SetClass("completed", true);
|
||||
append_text(append(mRoot, "h2"), "Completed");
|
||||
auto& clear = add_item<Button>("Clear completed");
|
||||
clear.root()->SetAttribute("focus-key", "completed-clear");
|
||||
clear.on_pressed([] {
|
||||
for (const auto& item : mods::queue::items()) {
|
||||
if (is_completed(item.state)) {
|
||||
mods::queue::clear(item.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
void build_online_mods(
|
||||
Pane& pane, Document& document, std::unordered_set<std::string>& expandedChangelogs) {
|
||||
auto& browse = pane.add_button("");
|
||||
browse.root()->SetClass("browse-mods-action", true);
|
||||
browse.root()->SetAttribute("focus-key", "online-browse");
|
||||
auto* copy = append(browse.root(), "browse-copy");
|
||||
append_text(append(copy, "h2"), "Browse online mods");
|
||||
append_text(append(copy, "p"),
|
||||
"Discover texture packs, gameplay mods, custom models, and more from the community.");
|
||||
append_text(append(browse.root(), "icon"), material_icon("arrow_forward"));
|
||||
browse.set_disabled(!borealis::http::available());
|
||||
browse.on_pressed([&document] { document.push(std::make_unique<ModBrowser>()); });
|
||||
|
||||
const auto items = mods::queue::items();
|
||||
const auto count =
|
||||
std::ranges::count_if(items, [](const auto& item) { return is_completed(item.state); });
|
||||
if (items.size() > count) {
|
||||
pane.add_child<DownloadsHeader>();
|
||||
for (const auto& item : items) {
|
||||
if (!is_completed(item.state)) {
|
||||
pane.add_child<QueueRow>(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
build_mod_updates(pane, expandedChangelogs);
|
||||
if (count > 0) {
|
||||
pane.add_child<CompletedHeader>();
|
||||
for (const auto& item : items) {
|
||||
if (is_completed(item.state)) {
|
||||
pane.add_child<QueueRow>(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
auto& automatic = pane.add_child<BoolButton>(BoolButton::Props{
|
||||
.key = "Check for updates",
|
||||
.getValue = [] { return getSettings().backend.checkForModUpdates.getValue(); },
|
||||
.setValue =
|
||||
[](bool value) {
|
||||
if (value == getSettings().backend.checkForModUpdates.getValue()) {
|
||||
return;
|
||||
}
|
||||
getSettings().backend.checkForModUpdates.setValue(value);
|
||||
config::save();
|
||||
},
|
||||
.isModified =
|
||||
[] {
|
||||
return getSettings().backend.checkForModUpdates.getValue() !=
|
||||
getSettings().backend.checkForModUpdates.getDefaultValue();
|
||||
},
|
||||
});
|
||||
automatic.root()->SetAttribute("focus-key", "online-auto-check");
|
||||
}
|
||||
} // namespace dusk::ui
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace dusk::ui {
|
||||
class Document;
|
||||
class Pane;
|
||||
|
||||
void build_online_mods(
|
||||
Pane& pane, Document& document, std::unordered_set<std::string>& expandedChangelogs);
|
||||
} // namespace dusk::ui
|
||||
@@ -84,14 +84,14 @@ void PackageRow::set_package(std::string name, std::string version, std::string
|
||||
mRoot->SetClassNames(stateClass);
|
||||
set_text_content(mName, name);
|
||||
set_text_content(mVersion, fmt::format("v{}", version));
|
||||
mVersion->SetProperty("display", version.empty() ? "none" : "block");
|
||||
set_display(mVersion, version.empty() ? Rml::Style::Display::None : Rml::Style::Display::Block);
|
||||
set_text_content(mState, status);
|
||||
set_text_content(mDetail, detail);
|
||||
if (progress) {
|
||||
mProgress->SetProperty("display", "block");
|
||||
set_display(mProgress, Rml::Style::Display::Block);
|
||||
mProgress->SetAttribute("value", *progress);
|
||||
} else {
|
||||
mProgress->SetProperty("display", "none");
|
||||
set_display(mProgress, Rml::Style::Display::None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "dusk/settings.h"
|
||||
#include "dusk/ui/format.hpp"
|
||||
#include "dusk/ui/menu_bar.hpp"
|
||||
#include "mod_updates.hpp"
|
||||
#include "modal.hpp"
|
||||
#include "mods_window.hpp"
|
||||
#include "preset.hpp"
|
||||
@@ -46,6 +47,7 @@ const Rml::String kDocumentSource = R"RML(
|
||||
<rml>
|
||||
<head>
|
||||
<link type="text/rcss" href="res/rml/theme.rcss" />
|
||||
<link type="text/rcss" href="res/rml/mod_common.rcss" />
|
||||
<link type="text/rcss" href="res/rml/prelaunch.rcss" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -887,6 +889,7 @@ Prelaunch::Prelaunch() : Document(kDocumentSource, false, DocumentScope::Prelaun
|
||||
}
|
||||
|
||||
void Prelaunch::build_menu_buttons() {
|
||||
mModsButton = nullptr;
|
||||
if (auto* menuList = mDocument->GetElementById("menu-list")) {
|
||||
// Restore the previously selected game mode before creating the play control.
|
||||
gamemode::getGameModeManager().setGameModeToPrevious();
|
||||
@@ -936,6 +939,7 @@ void Prelaunch::build_menu_buttons() {
|
||||
apply_intro_animation(mMenuButtons.back()->root(), "delay-2");
|
||||
|
||||
mMenuButtons.push_back(std::make_unique<Button>(menuList, "Mods"));
|
||||
mModsButton = mMenuButtons.back().get();
|
||||
mMenuButtons.back()->on_pressed([this] {
|
||||
mRestartSuppressed = false;
|
||||
push(std::make_unique<ModsWindow>());
|
||||
@@ -1003,6 +1007,9 @@ void Prelaunch::hide(bool close) {
|
||||
}
|
||||
|
||||
void Prelaunch::update() {
|
||||
if (mModsButton) {
|
||||
set_mod_update_badge(*mModsButton);
|
||||
}
|
||||
ensure_initialized();
|
||||
try_apply_mirrored_layout(mDocument);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ private:
|
||||
bool mEntranceAnimationStarted = false;
|
||||
bool mRestartSuppressed = false;
|
||||
std::vector<std::unique_ptr<Button>> mMenuButtons;
|
||||
Button* mModsButton = nullptr;
|
||||
Rml::Element* mRoot = nullptr;
|
||||
Rml::Element* mDiscStatus = nullptr;
|
||||
Rml::Element* mDiscDetail = nullptr;
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
#include "queue_window.hpp"
|
||||
|
||||
#include "button.hpp"
|
||||
#include "dusk/mod_loader.hpp"
|
||||
#include "dusk/mods/queue.hpp"
|
||||
#include "fmt/format.h"
|
||||
#include "format.hpp"
|
||||
#include "mod_texture_provider.hpp"
|
||||
#include "package_row.hpp"
|
||||
#include "pane.hpp"
|
||||
#include "remote_texture_provider.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace dusk::ui {
|
||||
namespace {
|
||||
|
||||
void set_icon_button(Button& button, const Rml::String& glyph, const Rml::String& label,
|
||||
Rml::String& currentGlyph, Rml::String& currentLabel) {
|
||||
if (currentGlyph != glyph) {
|
||||
clear_children(button.root());
|
||||
append_text(append(button.root(), "icon"), glyph);
|
||||
currentGlyph = glyph;
|
||||
}
|
||||
if (currentLabel != label) {
|
||||
button.root()->SetAttribute("aria-label", label);
|
||||
button.root()->SetAttribute("title", label);
|
||||
currentLabel = label;
|
||||
}
|
||||
}
|
||||
|
||||
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, "");
|
||||
mPause = pause.get();
|
||||
mPause->root()->SetClass("icon-action", true);
|
||||
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:
|
||||
mods::queue::retry(mId);
|
||||
break;
|
||||
default:
|
||||
mods::queue::pause(mId);
|
||||
break;
|
||||
}
|
||||
});
|
||||
mChildren.push_back(std::move(pause));
|
||||
|
||||
auto cancel = std::make_unique<Button>(actions, "");
|
||||
mCancel = cancel.get();
|
||||
mCancel->root()->SetClass("icon-action", true);
|
||||
mCancel->on_pressed([this] {
|
||||
const auto item = mods::queue::find(mId);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
if (mods::queue::is_terminal(item->state)) {
|
||||
if (item->state == mods::queue::State::InstallFailed) {
|
||||
auto& loader = mods::ModLoader::instance();
|
||||
if (const auto* mod = loader.find_mod(item->modId);
|
||||
mod != nullptr && loader.can_uninstall(*mod))
|
||||
{
|
||||
loader.request_uninstall(item->modId);
|
||||
}
|
||||
}
|
||||
mods::queue::clear(mId);
|
||||
return;
|
||||
}
|
||||
mods::queue::cancel(mId);
|
||||
});
|
||||
set_icon_button(*mCancel, "\uE5CD", "Cancel", mCancelGlyph, mCancelLabel);
|
||||
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() && (item->state == mods::queue::State::Failed ||
|
||||
item->state == mods::queue::State::InstallFailed))
|
||||
{
|
||||
detail = item->message;
|
||||
} else if (!item->message.empty()) {
|
||||
detail = fmt::format("{} · {}", detail, item->message);
|
||||
}
|
||||
auto status = state_label(*item);
|
||||
if (item->state == mods::queue::State::Installed) {
|
||||
status.clear();
|
||||
detail = fmt::format("Installed · {}", format_bytes(item->total));
|
||||
}
|
||||
const std::optional<float> progressValue =
|
||||
item->state == mods::queue::State::Installed ||
|
||||
item->state == mods::queue::State::Canceled ?
|
||||
std::nullopt :
|
||||
std::optional{progress};
|
||||
set_package(item->name, item->version, std::move(status), detail,
|
||||
queue_state_class(item->state), progressValue);
|
||||
|
||||
std::string iconSource;
|
||||
if (item->icon && !item->icon->url.empty()) {
|
||||
iconSource =
|
||||
remote_image_source(item->icon->url, item->icon->width, item->icon->height);
|
||||
} else if (const auto* mod = mods::ModLoader::instance().find_mod(item->modId);
|
||||
mod != nullptr && !mod->metadata.iconPath.empty())
|
||||
{
|
||||
iconSource = mod_image_source(*mod, mod->metadata.iconPath);
|
||||
}
|
||||
set_icon(std::move(iconSource));
|
||||
|
||||
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;
|
||||
mPause->root()->SetProperty("display", pauseVisible ? "flex" : "none");
|
||||
if (item->state == mods::queue::State::Paused) {
|
||||
set_icon_button(*mPause, "\uE037", "Resume", mPauseGlyph, mPauseLabel);
|
||||
} else if (item->state == mods::queue::State::Failed ||
|
||||
item->state == mods::queue::State::InstallFailed)
|
||||
{
|
||||
set_icon_button(*mPause, "\uE5D5", "Retry", mPauseGlyph, mPauseLabel);
|
||||
} else {
|
||||
set_icon_button(*mPause, "\uE034", "Pause", mPauseGlyph, mPauseLabel);
|
||||
}
|
||||
|
||||
mCancel->root()->SetProperty(
|
||||
"display", item->state == mods::queue::State::Handoff ? "none" : "flex");
|
||||
set_icon_button(*mCancel, "\uE5CD",
|
||||
mods::queue::is_terminal(item->state) ? "Clear" : "Cancel", mCancelGlyph, mCancelLabel);
|
||||
Component::update();
|
||||
}
|
||||
|
||||
private:
|
||||
std::string mId;
|
||||
Button* mPause = nullptr;
|
||||
Button* mCancel = nullptr;
|
||||
Rml::String mPauseGlyph;
|
||||
Rml::String mPauseLabel;
|
||||
Rml::String mCancelGlyph;
|
||||
Rml::String mCancelLabel;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
QueueWindow::QueueWindow(std::string focusId)
|
||||
: Modal{Props{
|
||||
.title = "Download queue",
|
||||
.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(); },
|
||||
[] { return mods::queue::item_count() == mods::queue::active_count(); }},
|
||||
},
|
||||
.variant = "install-queue",
|
||||
.icon = "download",
|
||||
}},
|
||||
mFocusId{std::move(focusId)} {
|
||||
content_pane();
|
||||
refresh_queue();
|
||||
}
|
||||
|
||||
void QueueWindow::update() {
|
||||
if (!mItemIds.empty() && mods::queue::item_count() == 0) {
|
||||
pop();
|
||||
return;
|
||||
}
|
||||
refresh_queue();
|
||||
Modal::update();
|
||||
}
|
||||
|
||||
void QueueWindow::refresh_queue() {
|
||||
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 (!mods::queue::is_terminal(item.state)) {
|
||||
++active;
|
||||
}
|
||||
}
|
||||
if (ids != mItemIds) {
|
||||
auto& pane = content_pane();
|
||||
pane.clear();
|
||||
mItemIds = std::move(ids);
|
||||
if (queueItems.empty()) {
|
||||
pane.add_text("No installs.");
|
||||
} else {
|
||||
for (const auto& item : queueItems) {
|
||||
auto& row = pane.add_child<QueueRow>(item.id);
|
||||
if (!mFocusId.empty() && item.id == mFocusId) {
|
||||
row.focus();
|
||||
mFocusId.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
set_body_text(fmt::format("{} active · {} total", active, queueItems.size()));
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
@@ -1,23 +0,0 @@
|
||||
#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 refresh_queue();
|
||||
|
||||
std::string mFocusId;
|
||||
std::vector<std::string> mItemIds;
|
||||
};
|
||||
|
||||
} // namespace dusk::ui
|
||||
@@ -1472,7 +1472,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
|
||||
});
|
||||
config_bool_select(leftPane, rightPane, getSettings().backend.checkForUpdates,
|
||||
{
|
||||
.key = "Check for Updates",
|
||||
.key = "Check for Dusklight Updates",
|
||||
.helpText = "Checks GitHub releases for a new Dusklight version on startup.<br/><br/>"
|
||||
"No personal information is transmitted or collected.",
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ bool TabBar::focus() {
|
||||
return false;
|
||||
}
|
||||
|
||||
void TabBar::add_tab(const Rml::String& title, TabCallback callback) {
|
||||
Button& TabBar::add_tab(const Rml::String& title, TabCallback callback) {
|
||||
const int index = static_cast<int>(mTabs.size());
|
||||
const bool selected = index == mProps.selectedTabIndex;
|
||||
if (selected && callback) {
|
||||
@@ -138,6 +138,7 @@ void TabBar::add_tab(const Rml::String& title, TabCallback callback) {
|
||||
.button = std::move(button),
|
||||
.callback = std::move(callback),
|
||||
});
|
||||
return *mTabs.back().button;
|
||||
}
|
||||
|
||||
void TabBar::clear_tabs() {
|
||||
|
||||
@@ -26,7 +26,7 @@ public:
|
||||
|
||||
bool focus() override;
|
||||
|
||||
void add_tab(const Rml::String& title, TabCallback callback);
|
||||
Button& add_tab(const Rml::String& title, TabCallback callback);
|
||||
void clear_tabs();
|
||||
bool set_active_tab(int index);
|
||||
void refresh_active_tab();
|
||||
|
||||
@@ -28,6 +28,10 @@ Tooltip::~Tooltip() {
|
||||
mRoot->GetParentNode()->RemoveChild(mRoot);
|
||||
}
|
||||
|
||||
void Tooltip::set_label(const Rml::String& label) {
|
||||
set_text_content(mRoot, label);
|
||||
}
|
||||
|
||||
void Tooltip::update() {
|
||||
auto* context = mAnchor->GetContext();
|
||||
const bool active = mFollowsFocus ? mAnchor->Contains(context->GetFocusElement()) :
|
||||
|
||||
@@ -12,6 +12,7 @@ public:
|
||||
Tooltip(const Tooltip&) = delete;
|
||||
Tooltip& operator=(const Tooltip&) = delete;
|
||||
|
||||
void set_label(const Rml::String& label);
|
||||
void update();
|
||||
|
||||
private:
|
||||
|
||||
+29
-5
@@ -11,18 +11,21 @@
|
||||
|
||||
#include "dusk/config.hpp"
|
||||
#include "dusk/mods/queue.hpp"
|
||||
#include "dusk/mods/updates.hpp"
|
||||
|
||||
#include <absl/container/flat_hash_set.h>
|
||||
#include <aurora/lib/window.hpp>
|
||||
#include <aurora/rmlui.hpp>
|
||||
#include <borealis/io.hpp>
|
||||
#include <fmt/format.h>
|
||||
#include <RmlUi/Core.h>
|
||||
#include <RmlUi/Core/ElementText.h>
|
||||
#include <SDL3/SDL_events.h>
|
||||
#include <SDL3/SDL_gamepad.h>
|
||||
#include <SDL3/SDL_joystick.h>
|
||||
#include <SDL3/SDL_power.h>
|
||||
#include <SDL3/SDL_video.h>
|
||||
#include <absl/container/flat_hash_set.h>
|
||||
#include <aurora/lib/window.hpp>
|
||||
#include <aurora/rmlui.hpp>
|
||||
#include <borealis/io.hpp>
|
||||
#include <fmt/format.h>
|
||||
#include <tracy/Tracy.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
@@ -114,6 +117,7 @@ bool initialize() noexcept {
|
||||
}
|
||||
|
||||
void shutdown() noexcept {
|
||||
mods::updates::shutdown();
|
||||
mods::queue::shutdown();
|
||||
for (auto& drop : sPendingDrops) {
|
||||
drop.inspection.cancel();
|
||||
@@ -439,7 +443,9 @@ Document* top_document() noexcept {
|
||||
}
|
||||
|
||||
void update() noexcept {
|
||||
ZoneScopedN("Dusk UI update");
|
||||
mods::queue::update();
|
||||
mods::updates::update();
|
||||
if (!aurora::rmlui::is_initialized()) {
|
||||
return;
|
||||
}
|
||||
@@ -573,12 +579,30 @@ void clear_children(Rml::Element* parent) noexcept {
|
||||
}
|
||||
|
||||
void set_text_content(Rml::Element* parent, const Rml::String& text) noexcept {
|
||||
if (parent == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (!text.empty() && parent->GetNumChildren() == 1) {
|
||||
if (auto* element = dynamic_cast<Rml::ElementText*>(parent->GetFirstChild())) {
|
||||
// RmlUi only dirties layout when the node's text changes.
|
||||
element->SetText(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
clear_children(parent);
|
||||
if (!text.empty()) {
|
||||
append_text(parent, text);
|
||||
}
|
||||
}
|
||||
|
||||
void set_display(Rml::Element* element, Rml::Style::Display display) noexcept {
|
||||
const Rml::Property value{display};
|
||||
const auto* current = element->GetLocalProperty(Rml::PropertyId::Display);
|
||||
if (current == nullptr || *current != value) {
|
||||
element->SetProperty(Rml::PropertyId::Display, value);
|
||||
}
|
||||
}
|
||||
|
||||
NavCommand map_nav_event(const Rml::Event& event) noexcept {
|
||||
const auto key = static_cast<Rml::Input::KeyIdentifier>(
|
||||
event.GetParameter<int>("key_identifier", Rml::Input::KI_UNKNOWN));
|
||||
|
||||
@@ -129,6 +129,7 @@ Rml::Element* append_text_element(
|
||||
Rml::Element* parent, const Rml::String& tag, const Rml::String& text) noexcept;
|
||||
void clear_children(Rml::Element* parent) noexcept;
|
||||
void set_text_content(Rml::Element* parent, const Rml::String& text) noexcept;
|
||||
void set_display(Rml::Element* element, Rml::Style::Display display) noexcept;
|
||||
|
||||
NavCommand map_nav_event(const Rml::Event& event) noexcept;
|
||||
Insets safe_area_insets(Rml::Context* context) noexcept;
|
||||
|
||||
Reference in New Issue
Block a user