Merge pull request #2434 from encounter/mod-updates

Mod update checks & queue revamp
This commit is contained in:
TakaRikka
2026-09-14 23:10:07 -07:00
committed by GitHub
47 changed files with 2240 additions and 630 deletions
+9 -2
View File
@@ -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
+8 -8
View File
@@ -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;
+36
View File
@@ -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
View File
@@ -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;
}
+8
View File
@@ -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;
+4
View File
@@ -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);
-18
View File
@@ -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;
+5
View File
@@ -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()) {
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+16 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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
+26 -4
View File
@@ -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; });
}
+4
View File
@@ -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);
+522
View File
@@ -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
+108
View File
@@ -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
+2
View File
@@ -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);
+1
View File
@@ -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;
+56
View File
@@ -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
+20
View File
@@ -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
+2 -5
View File
@@ -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));
}
}
+22 -3
View File
@@ -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() {
+4
View File
@@ -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;
};
+6 -2
View File
@@ -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();
}
+1
View File
@@ -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
View File
@@ -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();
}
+232
View File
@@ -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 = &check;
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
+14
View File
@@ -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
View File
@@ -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);
}
+15 -4
View File
@@ -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
+247
View File
@@ -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
+12
View File
@@ -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
+3 -3
View File
@@ -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);
}
}
+7
View File
@@ -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);
+1
View File
@@ -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;
-237
View File
@@ -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
-23
View File
@@ -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
+1 -1
View File
@@ -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.",
});
+2 -1
View File
@@ -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() {
+1 -1
View File
@@ -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();
+4
View File
@@ -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()) :
+1
View File
@@ -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
View File
@@ -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));
+1
View File
@@ -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;