|
| 1 | +#include "components/update/UpdateChecker.h" |
| 2 | + |
| 3 | +#include <charconv> |
| 4 | +#include <chrono> |
| 5 | +#include <compare> |
| 6 | +#include <cstdint> |
| 7 | +#include <optional> |
| 8 | +#include <string> |
| 9 | +#include <string_view> |
| 10 | +#include <system_error> |
| 11 | +#include <utility> |
| 12 | + |
| 13 | +#include <nlohmann/json.hpp> |
| 14 | +#include <spdlog/spdlog.h> |
| 15 | + |
| 16 | +// The V8-free HTTP engine (WinHTTP; bypasses the game's SOCKS5 detour). Reused |
| 17 | +// here rather than duplicating the WinHTTP plumbing - this is a documented |
| 18 | +// components -> api exception (HttpEngine.h pulls in no V8 / JS headers). |
| 19 | +#include "api/classes/io/HttpEngine.h" |
| 20 | +#include "components/config/Version.h" |
| 21 | +#include "utils/threadutils.h" |
| 22 | +#include "utils/utils.h" |
| 23 | + |
| 24 | +namespace d2bs::framework::update { |
| 25 | + |
| 26 | +namespace { |
| 27 | + |
| 28 | +using json = nlohmann::json; // NOLINT(readability-identifier-naming) - nlohmann's conventional alias spelling |
| 29 | + |
| 30 | +constexpr auto CHECK_INTERVAL = std::chrono::hours{6}; |
| 31 | +// Short settle delay before the first check so it doesn't pile onto the heavy |
| 32 | +// DLL-load / framework-init work. |
| 33 | +constexpr auto INITIAL_DELAY = std::chrono::seconds{30}; |
| 34 | + |
| 35 | +// Hardcoded: the canonical d2bsng releases endpoint. `releases/latest` returns |
| 36 | +// the newest non-draft, non-prerelease release (404 when none exist yet). |
| 37 | +constexpr std::string_view RELEASES_API_URL = |
| 38 | + "https://api.github.com/repos/ResurrectedTrader/d2bsng/releases/latest"; |
| 39 | + |
| 40 | +// A comparable major.minor.patch triple. The defaulted operator<=> gives the |
| 41 | +// natural precedence (major, then minor, then patch). |
| 42 | +struct SemVer { |
| 43 | + uint32_t major = 0; |
| 44 | + uint32_t minor = 0; |
| 45 | + uint32_t patch = 0; |
| 46 | + auto operator<=>(const SemVer&) const = default; |
| 47 | +}; |
| 48 | + |
| 49 | +// Parse the leading "major.minor.patch" of a version string. Tolerates a |
| 50 | +// leading 'v' and ignores any pre-release / build suffix ("-dev", "+meta"), so |
| 51 | +// "v2.1.0" and "2.1.0-dev" both parse. Missing trailing components default to |
| 52 | +// 0. Returns nullopt only when there is no leading numeric component at all. |
| 53 | +std::optional<SemVer> ParseSemVer(std::string_view s) { |
| 54 | + if (!s.empty() && (s.front() == 'v' || s.front() == 'V')) { |
| 55 | + s.remove_prefix(1); |
| 56 | + } |
| 57 | + // Keep only the leading run of digits and dots ("2.1.0-dev" -> "2.1.0"). |
| 58 | + size_t end = 0; |
| 59 | + while (end < s.size() && ((s[end] >= '0' && s[end] <= '9') || s[end] == '.')) { |
| 60 | + ++end; |
| 61 | + } |
| 62 | + s = s.substr(0, end); |
| 63 | + if (s.empty() || s.front() < '0' || s.front() > '9') { |
| 64 | + return std::nullopt; |
| 65 | + } |
| 66 | + |
| 67 | + SemVer out; |
| 68 | + size_t field = 0; |
| 69 | + size_t start = 0; |
| 70 | + while (start <= s.size() && field < 3) { |
| 71 | + const size_t dot = s.find('.', start); |
| 72 | + const size_t len = (dot == std::string_view::npos) ? std::string_view::npos : dot - start; |
| 73 | + const std::string_view part = s.substr(start, len); |
| 74 | + uint32_t value = 0; |
| 75 | + if (!part.empty()) { |
| 76 | + const auto* first = part.data(); |
| 77 | + const auto* last = part.data() + part.size(); |
| 78 | + if (std::from_chars(first, last, value).ec != std::errc{}) { |
| 79 | + return std::nullopt; |
| 80 | + } |
| 81 | + } |
| 82 | + switch (field) { |
| 83 | + case 0: |
| 84 | + out.major = value; |
| 85 | + break; |
| 86 | + case 1: |
| 87 | + out.minor = value; |
| 88 | + break; |
| 89 | + default: |
| 90 | + out.patch = value; |
| 91 | + break; |
| 92 | + } |
| 93 | + ++field; |
| 94 | + if (dot == std::string_view::npos) { |
| 95 | + break; |
| 96 | + } |
| 97 | + start = dot + 1; |
| 98 | + } |
| 99 | + return out; |
| 100 | +} |
| 101 | + |
| 102 | +// Strip a single leading 'v' / 'V' for display (the GitHub tag is "v2.1.0"; |
| 103 | +// the in-game notice reads "2.1.0"). |
| 104 | +std::string StripTagPrefix(const std::string& tag) { |
| 105 | + if (!tag.empty() && (tag.front() == 'v' || tag.front() == 'V')) { |
| 106 | + return tag.substr(1); |
| 107 | + } |
| 108 | + return tag; |
| 109 | +} |
| 110 | + |
| 111 | +} // namespace |
| 112 | + |
| 113 | +UpdateChecker& UpdateChecker::Instance() { |
| 114 | + static UpdateChecker instance; |
| 115 | + return instance; |
| 116 | +} |
| 117 | + |
| 118 | +UpdateChecker::UpdateChecker() { |
| 119 | + logger_ = d2bs::utils::GetLogger("update"); |
| 120 | +} |
| 121 | + |
| 122 | +UpdateChecker::~UpdateChecker() { |
| 123 | + Stop(); |
| 124 | +} |
| 125 | + |
| 126 | +void UpdateChecker::Start() { |
| 127 | + bool expected = false; |
| 128 | + if (!started_.compare_exchange_strong(expected, true)) { |
| 129 | + return; // already running |
| 130 | + } |
| 131 | + thread_ = std::jthread([this](const std::stop_token& stopToken) { Run(stopToken); }); |
| 132 | +} |
| 133 | + |
| 134 | +void UpdateChecker::Stop() { |
| 135 | + if (!started_.exchange(false)) { |
| 136 | + return; // not running |
| 137 | + } |
| 138 | + if (thread_.joinable()) { |
| 139 | + thread_.request_stop(); |
| 140 | + cv_.notify_all(); // wake the interval wait immediately |
| 141 | + thread_.join(); |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +std::string UpdateChecker::Message() const { |
| 146 | + if (!updateAvailable_.load(std::memory_order_acquire)) { |
| 147 | + return {}; |
| 148 | + } |
| 149 | + std::lock_guard lock(mutex_); |
| 150 | + // ASCII only (renders both in-game and in the dev console). No URL by design. |
| 151 | + return "d2bsng " + latestVersion_ + " is available (running " D2BS_VERSION ")"; |
| 152 | +} |
| 153 | + |
| 154 | +void UpdateChecker::Run(const std::stop_token& stopToken) { |
| 155 | + d2bs::thread_utils::SetThreadDescription("d2bs update checker"); |
| 156 | + |
| 157 | + std::unique_lock lock(mutex_); |
| 158 | + // Interruptible settle delay before the first check. wait_for returns the |
| 159 | + // predicate result, so a true return means Stop() fired during the delay. |
| 160 | + if (cv_.wait_for(lock, stopToken, INITIAL_DELAY, [&stopToken] { return stopToken.stop_requested(); })) { |
| 161 | + return; |
| 162 | + } |
| 163 | + |
| 164 | + while (!stopToken.stop_requested()) { |
| 165 | + lock.unlock(); |
| 166 | + CheckOnce(); |
| 167 | + lock.lock(); |
| 168 | + // Sleep the interval; wakes early when Stop() requests it. |
| 169 | + cv_.wait_for(lock, stopToken, CHECK_INTERVAL, [&stopToken] { return stopToken.stop_requested(); }); |
| 170 | + } |
| 171 | +} |
| 172 | + |
| 173 | +bool UpdateChecker::CheckOnce() { |
| 174 | + api::classes::HttpRequest request; |
| 175 | + request.method = "GET"; |
| 176 | + request.url = std::string(RELEASES_API_URL); |
| 177 | + request.headers = { |
| 178 | + // GitHub rejects API requests without a User-Agent. |
| 179 | + {"User-Agent", "d2bsng-update-checker"}, |
| 180 | + {"Accept", "application/vnd.github+json"}, |
| 181 | + {"X-GitHub-Api-Version", "2022-11-28"}, |
| 182 | + }; |
| 183 | + request.timeoutMs = 10000; |
| 184 | + request.totalTimeoutMs = 15000; |
| 185 | + |
| 186 | + api::classes::HttpResponse response; |
| 187 | + const std::string error = api::classes::PerformHttpRequest(request, response); |
| 188 | + if (!error.empty()) { |
| 189 | + logger_->debug("update check: request failed ({})", error); |
| 190 | + return false; |
| 191 | + } |
| 192 | + if (response.status != 200) { |
| 193 | + // 404 = no releases published yet; anything else = transient. Either |
| 194 | + // way there's nothing to flag. |
| 195 | + logger_->debug("update check: HTTP {}", response.status); |
| 196 | + return false; |
| 197 | + } |
| 198 | + |
| 199 | + const json doc = json::parse(response.body.begin(), response.body.end(), /*cb=*/nullptr, /*allow_exceptions=*/false); |
| 200 | + if (!doc.is_object()) { |
| 201 | + logger_->debug("update check: response was not a JSON object"); |
| 202 | + return false; |
| 203 | + } |
| 204 | + const auto it = doc.find("tag_name"); |
| 205 | + if (it == doc.end() || !it->is_string()) { |
| 206 | + logger_->debug("update check: no tag_name in response"); |
| 207 | + return false; |
| 208 | + } |
| 209 | + const auto tag = it->get<std::string>(); |
| 210 | + |
| 211 | + const auto latest = ParseSemVer(tag); |
| 212 | + const auto current = ParseSemVer(D2BS_VERSION); |
| 213 | + if (!latest) { |
| 214 | + logger_->debug("update check: unparseable release tag '{}'", tag); |
| 215 | + return false; |
| 216 | + } |
| 217 | + if (!current) { |
| 218 | + // D2BS_VERSION is a build-baked literal; this should never happen. |
| 219 | + logger_->debug("update check: unparseable running version '{}'", D2BS_VERSION); |
| 220 | + return false; |
| 221 | + } |
| 222 | + |
| 223 | + const bool newer = *latest > *current; |
| 224 | + { |
| 225 | + std::lock_guard lock(mutex_); |
| 226 | + latestVersion_ = StripTagPrefix(tag); |
| 227 | + } |
| 228 | + updateAvailable_.store(newer, std::memory_order_release); |
| 229 | + if (newer) { |
| 230 | + logger_->info("update available: {} (running {})", tag, D2BS_VERSION); |
| 231 | + } else { |
| 232 | + logger_->debug("up to date: latest {} vs running {}", tag, D2BS_VERSION); |
| 233 | + } |
| 234 | + return true; |
| 235 | +} |
| 236 | + |
| 237 | +} // namespace d2bs::framework::update |
0 commit comments