diff --git a/files.cmake b/files.cmake index 8962ee2cb8..afc2622e6e 100644 --- a/files.cmake +++ b/files.cmake @@ -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 diff --git a/res/rml/mod_browser.rcss b/res/rml/mod_browser.rcss index e88285b019..defe48448c 100644 --- a/res/rml/mod_browser.rcss +++ b/res/rml/mod_browser.rcss @@ -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; diff --git a/res/rml/mod_common.rcss b/res/rml/mod_common.rcss new file mode 100644 index 0000000000..f221049b6e --- /dev/null +++ b/res/rml/mod_common.rcss @@ -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; +} diff --git a/res/rml/mods.rcss b/res/rml/mods.rcss index acd6d923a3..d6c3f394fb 100644 --- a/res/rml/mods.rcss +++ b/res/rml/mods.rcss @@ -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; +} diff --git a/res/rml/prelaunch.rcss b/res/rml/prelaunch.rcss index 8d98577ad5..f3110a4fec 100644 --- a/res/rml/prelaunch.rcss +++ b/res/rml/prelaunch.rcss @@ -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; diff --git a/res/rml/tabbing.rcss b/res/rml/tabbing.rcss index 862d79a5df..99f87bd8c3 100644 --- a/res/rml/tabbing.rcss +++ b/res/rml/tabbing.rcss @@ -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); diff --git a/res/rml/window.rcss b/res/rml/window.rcss index f2f2574750..cc4a3c8e6d 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -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; diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp index 1749e964d7..693d0384ed 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -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()) { diff --git a/src/dusk/mod_loader.hpp b/src/dusk/mod_loader.hpp index fc419e7367..3d5094ca70 100644 --- a/src/dusk/mod_loader.hpp +++ b/src/dusk/mod_loader.hpp @@ -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 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 update; std::shared_ptr 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& update); [[nodiscard]] OperationResult load_runtime_mod(const std::filesystem::path& path); [[nodiscard]] OperationResult reload_runtime_mod( LoadedMod& mod, const PackageCandidate* replacement = nullptr); diff --git a/src/dusk/mods/catalog.cpp b/src/dusk/mods/catalog.cpp index 4ba118cd5b..2fc2bb4554 100644 --- a/src/dusk/mods/catalog.cpp +++ b/src/dusk/mods/catalog.cpp @@ -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(value); } +std::optional 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::max()) { + throw std::runtime_error{fmt::format("field '{}' is too large", name)}; + } + return static_cast(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 parse_service_imports(const json& object) { + const auto& imports = required_array(object, "service_imports"); + std::vector 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(width), .height = static_cast(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::max()) { - throw std::runtime_error{"field 'mod_abi' is too large"}; - } - detail.modAbi = static_cast(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 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 fetch_updates( + UpdateEnvironment environment, std::vector 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 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>(); + 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 diff --git a/src/dusk/mods/catalog.hpp b/src/dusk/mods/catalog.hpp index 5605cb6e9a..0f16229ec0 100644 --- a/src/dusk/mods/catalog.hpp +++ b/src/dusk/mods/catalog.hpp @@ -1,5 +1,7 @@ #pragma once +#include "updates.hpp" + #include #include @@ -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> updates; + std::string error; + bool retryable = false; + int retryAfter = 0; +}; + +std::string_view platform() noexcept; +borealis::Task fetch_updates( + UpdateEnvironment environment, std::vector targets); + /** Fetches one filtered page from the Dusklight catalog. */ borealis::Task fetch_page(Query query); diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 16c49f8c45..6479985f84 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -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 update) { auto operation = std::make_shared(); 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& 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 coalesced; for (const auto& request : requests) { if (const auto* install = std::get_if(&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 = diff --git a/src/dusk/mods/queue.cpp b/src/dusk/mods/queue.cpp index 4be07cb2a3..c092fb5ad5 100644 --- a/src/dusk/mods/queue.cpp +++ b/src/dusk/mods/queue.cpp @@ -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(&item.request.source); +const Download* url_source(const QueueItem& item) { + return std::get_if(&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(&request.source); + const auto* source = std::get_if(&request.source); const auto* local = std::get_if(&request.source); if (source != nullptr) { if (request.id.empty() || request.version.empty() || source->size == 0 || @@ -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(std::ranges::count_if( - queueItems, [](const QueueItem& item) { return !is_terminal(item.state); })); -} - -std::optional 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{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 diff --git a/src/dusk/mods/queue.hpp b/src/dusk/mods/queue.hpp index 85481d6b4a..20c047d8cd 100644 --- a/src/dusk/mods/queue.hpp +++ b/src/dusk/mods/queue.hpp @@ -1,5 +1,7 @@ #pragma once +#include "updates.hpp" + #include #include #include @@ -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; +using Source = std::variant; struct Icon { std::string url; @@ -57,6 +60,7 @@ struct Request { std::string version; Source source; std::optional icon; + std::optional 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 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 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 diff --git a/src/dusk/mods/services.hpp b/src/dusk/mods/services.hpp new file mode 100644 index 0000000000..57a9157d8e --- /dev/null +++ b/src/dusk/mods/services.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +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 diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 8b7a778789..8c2e5af50b 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -17,14 +18,11 @@ namespace dusk::mods::svc { namespace { +uint64_t s_serviceGeneration = 0; std::unordered_map s_services; std::unordered_set s_unavailableServices; std::vector 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 list_services() { + std::vector 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; }); } diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index ae6476d385..f2ed6f370c 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -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 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); diff --git a/src/dusk/mods/updates.cpp b/src/dusk/mods/updates.cpp new file mode 100644 index 0000000000..038b9b2091 --- /dev/null +++ b/src/dusk/mods/updates.cpp @@ -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 +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#if defined(__APPLE__) +#include +#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 task; + std::vector targets; + size_t nextTarget = 0; + std::vector pending; + std::vector 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 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 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 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> 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 visited; + const std::function 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& 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 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 diff --git a/src/dusk/mods/updates.hpp b/src/dusk/mods/updates.hpp new file mode 100644 index 0000000000..1526616898 --- /dev/null +++ b/src/dusk/mods/updates.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include "services.hpp" + +#include +#include +#include +#include +#include +#include + +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 imports; + bool operator==(const InstalledPackage&) const = default; +}; + +struct UpdateEnvironment { + std::string platform; + uint32_t abi = 0; + bool nativeUpdates = true; + std::vector services; + std::vector mods; + bool operator==(const UpdateEnvironment&) const = default; +}; + +struct UpdateCompatibility { + bool containsNativeCode = false; + std::vector platforms; + std::optional abi; + std::vector imports; + std::vector 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 blockers; + std::optional 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& 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 diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index d43530ad73..de715ee169 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -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(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); diff --git a/src/dusk/settings.h b/src/dusk/settings.h index 0b4ce76838..ebb3f8b539 100644 --- a/src/dusk/settings.h +++ b/src/dusk/settings.h @@ -312,6 +312,7 @@ struct UserSettings { ConfigVar skipPreLaunchUI; ConfigVar wasPresetChosen; ConfigVar checkForUpdates; + ConfigVar checkForModUpdates; ConfigVar cardFileType; ConfigVar enableAdvancedSettings; } backend; diff --git a/src/dusk/ui/clamped_text.cpp b/src/dusk/ui/clamped_text.cpp new file mode 100644 index 0000000000..7e83b9365a --- /dev/null +++ b/src/dusk/ui/clamped_text.cpp @@ -0,0 +1,56 @@ +#include "clamped_text.hpp" + +#include +#include +#include + +#include +#include + +namespace dusk::ui { + +ClampedText::ClampedText(Rml::Element* root, Rml::String text, int maxLines) + : Component{root}, mSource{std::move(text)}, + mText{static_cast(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(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 diff --git a/src/dusk/ui/clamped_text.hpp b/src/dusk/ui/clamped_text.hpp new file mode 100644 index 0000000000..c7518ac204 --- /dev/null +++ b/src/dusk/ui/clamped_text.hpp @@ -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 diff --git a/src/dusk/ui/drop_install_modal.cpp b/src/dusk/ui/drop_install_modal.cpp index 6049db5aeb..ca4f6b6329 100644 --- a/src/dusk/ui/drop_install_modal.cpp +++ b/src/dusk/ui/drop_install_modal.cpp @@ -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 #include @@ -148,10 +148,7 @@ void DropInstallModal::install() { } pop(); if (!firstKey.empty()) { - if (auto* current = top_document()) { - current->cover(); - } - push_document(std::make_unique(std::move(firstKey))); + show_online_mods(std::move(firstKey)); } } diff --git a/src/dusk/ui/icon_button.cpp b/src/dusk/ui/icon_button.cpp index b1fe3c9f04..6f856172be 100644 --- a/src/dusk/ui/icon_button.cpp +++ b/src/dusk/ui/icon_button.cpp @@ -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("aria-label", "") == label) { + return; + } + mRoot->SetAttribute("aria-label", label); + mTooltip.set_label(label); } void IconButton::update() { diff --git a/src/dusk/ui/icon_button.hpp b/src/dusk/ui/icon_button.hpp index 4c54e85495..e8b099a546 100644 --- a/src/dusk/ui/icon_button.hpp +++ b/src/dusk/ui/icon_button.hpp @@ -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; }; diff --git a/src/dusk/ui/menu_bar.cpp b/src/dusk/ui/menu_bar.cpp index fa0fdacddb..614c28be1b 100644 --- a/src/dusk/ui/menu_bar.cpp +++ b/src/dusk/ui/menu_bar.cpp @@ -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( + @@ -42,7 +44,6 @@ const Rml::String kDocumentSource = R"RML( )RML"; - } MenuBar::MenuBar() @@ -83,7 +84,7 @@ void MenuBar::build_tabs() { { mTabBar->add_tab("Achievements", [this] { push(std::make_unique()); }); } - mTabBar->add_tab("Mods", [this] { push(std::make_unique()); }); + mModsButton = &mTabBar->add_tab("Mods", [this] { push(std::make_unique()); }); 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(); } diff --git a/src/dusk/ui/menu_bar.hpp b/src/dusk/ui/menu_bar.hpp index 0d33a3f1f7..711c201a2f 100644 --- a/src/dusk/ui/menu_bar.hpp +++ b/src/dusk/ui/menu_bar.hpp @@ -32,6 +32,7 @@ private: void update_safe_area() noexcept; Rml::Element* mRoot; + Button* mModsButton = nullptr; std::unique_ptr mTabBar; std::unique_ptr