MetaImGUI 1.0.0
ImGui Application Template for C++20
Loading...
Searching...
No Matches
UpdateChecker.cpp
Go to the documentation of this file.
1/*
2 MetaImGUI
3 Copyright (C) 2026 A P Nicholson
4
5 This program is free software: you can redistribute it and/or modify
6 it under the terms of the GNU General Public License as published by
7 the Free Software Foundation, either version 3 of the License, or
8 (at your option) any later version.
9
10 This program is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 GNU General Public License for more details.
14
15 You should have received a copy of the GNU General Public License
16 along with this program. If not, see <https://www.gnu.org/licenses/>.
17*/
18
19#include "UpdateChecker.h"
20
21#include "HttpClient.h"
22#include "Logger.h"
23#include "version.h"
24
25#include <nlohmann/json.hpp>
26
27#include <algorithm>
28#include <cctype>
29#include <sstream>
30#include <string_view>
31
32namespace MetaImGUI {
33
34UpdateChecker::UpdateChecker(std::string repoOwner, std::string repoName)
35 : m_repoOwner(std::move(repoOwner)), m_repoName(std::move(repoName)), m_checking(false) {}
36
38 // C++20: std::jthread requests stop and joins automatically.
39 // Cancel() also requests stop on whatever thread is current.
40 Cancel();
41}
42
43void UpdateChecker::CheckForUpdatesAsync(std::function<void(const UpdateInfo&)> callback) {
44 // Single-source-of-truth gate: only one check may run at a time.
45 // CAS keeps the gate honest without needing a mutex around the boolean.
46 bool expected = false;
47 if (!m_checking.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
48 LOG_INFO("Update Checker: Check already in progress, skipping");
49 return;
50 }
51
52 // Serialise jthread (re)creation: assigning to a running jthread joins it,
53 // and we don't want concurrent CheckForUpdatesAsync calls fighting over the slot.
54 const std::lock_guard<std::mutex> lock(m_threadMutex);
55
56 m_checkThread = std::jthread([this, callback](const std::stop_token& stopToken) {
57 const UpdateInfo info = CheckForUpdatesImpl(stopToken);
58
59 // Release the gate before invoking the callback so a callback that
60 // re-triggers a check (e.g. user retry) doesn't deadlock against itself.
61 m_checking.store(false, std::memory_order_release);
62
63 if (!stopToken.stop_requested() && callback) {
64 try {
65 callback(info);
66 } catch (const std::exception& e) {
67 LOG_ERROR("Update Checker: Callback threw exception: {}", e.what());
68 } catch (...) {
69 LOG_ERROR("Update Checker: Callback threw unknown exception");
70 }
71 }
72 });
73}
74
76 // Synchronous path: still respect the gate so async + sync don't run in parallel.
77 bool expected = false;
78 if (!m_checking.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
79 UpdateInfo info{};
80 info.currentVersion = Version::VERSION;
81 info.status = UpdateCheckStatus::Unknown;
82 return info;
83 }
84
85 UpdateInfo info = CheckForUpdatesImpl(std::stop_token{});
86 m_checking.store(false, std::memory_order_release);
87 return info;
88}
89
91 // jthread tracks its own stop_source; request_stop() is idempotent and thread-safe.
92 m_checkThread.request_stop();
93 LOG_INFO("Update Checker: Cancellation requested");
94}
95
97 return m_checking.load(std::memory_order_acquire);
98}
99
100UpdateInfo UpdateChecker::CheckForUpdatesImpl(const std::stop_token& stopToken) {
101 UpdateInfo info{.updateAvailable = false,
102 .latestVersion = "",
103 .currentVersion = Version::VERSION,
104 .releaseUrl = "",
105 .releaseNotes = "",
106 .downloadUrl = "",
108
109 try {
110 const HttpClient http;
111 const std::string url = "https://api.github.com/repos/" + m_repoOwner + "/" + m_repoName + "/releases/latest";
112 LOG_INFO("Update Checker: Requesting URL: {}", url);
113
114 // Two retries on transient network failure โ€” GitHub occasionally
115 // closes connections under load. Rate-limit and cancellation skip retry.
116 const HttpRequest request{
117 .url = url, .userAgent = "UpdateChecker/1.0", .timeout = std::chrono::seconds{10}, .maxRetries = 2};
118 const HttpResponse response = http.Get(request, stopToken);
119
120 switch (response.status) {
122 LOG_INFO("Update Checker: Check cancelled by user");
123 info.status = UpdateCheckStatus::Cancelled;
124 return info;
126 LOG_WARNING("Update Checker: GitHub API rate limit hit");
127 info.status = UpdateCheckStatus::RateLimited;
128 return info;
130 LOG_ERROR("Update Checker: Network error fetching release info");
132 return info;
133 case HttpStatus::Ok:
134 break;
135 }
136
137 if (response.body.empty()) {
138 LOG_ERROR("Update Checker: Empty response from server");
140 return info;
141 }
142
143 LOG_INFO("Update Checker: Response received ({} bytes)", response.body.size());
144 info = ParseReleaseInfo(response.body);
145 info.currentVersion = Version::VERSION;
146
147 if (info.latestVersion.empty()) {
148 LOG_ERROR("Update Checker: Could not parse latest version from response");
149 info.status = UpdateCheckStatus::ParseError;
150 return info;
151 }
152
153 const int cmp = CompareVersions(info.currentVersion, info.latestVersion);
154 info.updateAvailable = (cmp < 0);
155 info.status = info.updateAvailable ? UpdateCheckStatus::UpdateFound : UpdateCheckStatus::UpToDate;
156
157 if (info.updateAvailable) {
158 LOG_INFO("Update Checker: Update available - {} -> {}", info.currentVersion, info.latestVersion);
159 } else {
160 LOG_INFO("Update Checker: No update available (current: {})", info.currentVersion);
161 }
162 } catch (const std::bad_alloc& e) {
163 LOG_ERROR("Update Checker: Memory allocation failed: {}", e.what());
165 } catch (const std::exception& e) {
166 LOG_ERROR("Update Checker: Check failed: {}", e.what());
168 } catch (...) {
169 LOG_ERROR("Update Checker: Unknown error during update check");
171 }
172
173 return info;
174}
175
176UpdateInfo UpdateChecker::ParseReleaseInfo(const std::string& jsonResponse) {
177 UpdateInfo info;
178
179 if (jsonResponse.empty()) {
180 LOG_ERROR("Update Checker: Empty JSON response");
181 return info;
182 }
183
184 try {
185 auto j = nlohmann::json::parse(jsonResponse);
186
187 if (j.contains("tag_name") && j["tag_name"].is_string()) {
188 std::string tag = j["tag_name"].get<std::string>();
189 info.latestVersion = (!tag.empty() && tag[0] == 'v') ? tag.substr(1) : tag;
190 LOG_INFO("Update Checker: Parsed version: {}", info.latestVersion);
191 } else {
192 LOG_ERROR("Update Checker: No tag_name in response");
193 }
194
195 if (j.contains("html_url") && j["html_url"].is_string()) {
196 info.releaseUrl = j["html_url"].get<std::string>();
197 }
198
199 if (j.contains("body") && j["body"].is_string()) {
200 info.releaseNotes = j["body"].get<std::string>();
201 }
202
203 if (j.contains("assets") && j["assets"].is_array() && !j["assets"].empty()) {
204 auto& firstAsset = j["assets"][0];
205 if (firstAsset.contains("browser_download_url") && firstAsset["browser_download_url"].is_string()) {
206 info.downloadUrl = firstAsset["browser_download_url"].get<std::string>();
207 }
208 }
209
210 } catch (const nlohmann::json::parse_error& e) {
211 LOG_ERROR("Update Checker: JSON parse error: {} at byte {}", e.what(), e.byte);
212 } catch (const nlohmann::json::type_error& e) {
213 LOG_ERROR("Update Checker: JSON type error: {}", e.what());
214 } catch (const std::exception& e) {
215 LOG_ERROR("Update Checker: Unexpected error parsing JSON: {}", e.what());
216 }
217
218 return info;
219}
220
221namespace {
222
223// SemVer 2.0 ordering of pre-release identifiers (see semver.org ยง11):
224// - A pre-release version has lower precedence than the release version
225// (1.2.0-rc1 < 1.2.0).
226// - Identifiers consisting of only digits are compared numerically;
227// alphanumerics are compared lexically; numeric < alphanumeric.
228// - A larger set of identifiers has higher precedence than a smaller set
229// that matches as a prefix.
230struct ParsedVersion {
231 std::vector<int> core; // major.minor.patch (zero-padded to 3)
232 std::vector<std::string> preRelease; // empty == release
233};
234
235bool IsAllDigits(const std::string& s) {
236 if (s.empty()) {
237 return false;
238 }
239 return std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isdigit(c) != 0; });
240}
241
242ParsedVersion ParseSemVer(std::string version) {
243 ParsedVersion parsed;
244
245 if (!version.empty() && (version[0] == 'v' || version[0] == 'V')) {
246 version = version.substr(1);
247 }
248
249 // Strip build metadata ("+...") โ€” it does not participate in precedence.
250 const size_t plus = version.find('+');
251 if (plus != std::string::npos) {
252 version = version.substr(0, plus);
253 }
254
255 std::string corePart = version;
256 std::string preReleasePart;
257 const size_t dash = version.find('-');
258 if (dash != std::string::npos) {
259 corePart = version.substr(0, dash);
260 preReleasePart = version.substr(dash + 1);
261 }
262
263 {
264 std::stringstream ss(corePart);
265 std::string part;
266 while (std::getline(ss, part, '.')) {
267 std::string digits;
268 for (const char c : part) {
269 if (std::isdigit(static_cast<unsigned char>(c)) != 0) {
270 digits += c;
271 } else {
272 break;
273 }
274 }
275 if (!digits.empty()) {
276 parsed.core.push_back(std::stoi(digits));
277 }
278 }
279 }
280 while (parsed.core.size() < 3) {
281 parsed.core.push_back(0);
282 }
283
284 if (!preReleasePart.empty()) {
285 std::stringstream ss(preReleasePart);
286 std::string id;
287 while (std::getline(ss, id, '.')) {
288 parsed.preRelease.push_back(id);
289 }
290 }
291
292 return parsed;
293}
294
295int CompareIdentifiers(const std::string& a, const std::string& b) {
296 const bool aDigits = IsAllDigits(a);
297 const bool bDigits = IsAllDigits(b);
298 if (aDigits && bDigits) {
299 const int ai = std::stoi(a);
300 const int bi = std::stoi(b);
301 if (ai < bi) {
302 return -1;
303 }
304 if (ai > bi) {
305 return 1;
306 }
307 return 0;
308 }
309 // Numeric identifiers always have lower precedence than alphanumerics.
310 if (aDigits && !bDigits) {
311 return -1;
312 }
313 if (!aDigits && bDigits) {
314 return 1;
315 }
316 return a.compare(b);
317}
318
319} // namespace
320
321int UpdateChecker::CompareVersions(const std::string& v1, const std::string& v2) {
322 const ParsedVersion p1 = ParseSemVer(v1);
323 const ParsedVersion p2 = ParseSemVer(v2);
324
325 for (size_t i = 0; i < std::min(p1.core.size(), p2.core.size()); ++i) {
326 if (p1.core[i] < p2.core[i]) {
327 return -1;
328 }
329 if (p1.core[i] > p2.core[i]) {
330 return 1;
331 }
332 }
333
334 // Cores equal โ€” pre-release ranks below release.
335 const bool p1Pre = !p1.preRelease.empty();
336 const bool p2Pre = !p2.preRelease.empty();
337 if (!p1Pre && !p2Pre) {
338 return 0;
339 }
340 if (p1Pre && !p2Pre) {
341 return -1;
342 }
343 if (!p1Pre && p2Pre) {
344 return 1;
345 }
346
347 const size_t n = std::min(p1.preRelease.size(), p2.preRelease.size());
348 for (size_t i = 0; i < n; ++i) {
349 const int c = CompareIdentifiers(p1.preRelease[i], p2.preRelease[i]);
350 if (c != 0) {
351 return c;
352 }
353 }
354 if (p1.preRelease.size() < p2.preRelease.size()) {
355 return -1;
356 }
357 if (p1.preRelease.size() > p2.preRelease.size()) {
358 return 1;
359 }
360 return 0;
361}
362
363} // namespace MetaImGUI
#define LOG_INFO(...)
Definition Logger.h:166
#define LOG_ERROR(...)
Definition Logger.h:168
#define LOG_WARNING(...)
Definition Logger.h:167
std::vector< std::string > preRelease
std::vector< int > core
static int CompareVersions(const std::string &v1, const std::string &v2)
void CheckForUpdatesAsync(std::function< void(const UpdateInfo &)> callback)
UpdateChecker(std::string repoOwner, std::string repoName)
@ NetworkError
curl/transport failure, or non-2xx that isn't rate-limit
@ RateLimited
403 with X-RateLimit-Remaining: 0 (GitHub-style)
@ Cancelled
stop_token::stop_requested() fired during transfer
@ Ok
2xx response, body populated
@ UpdateFound
A newer release is available.
@ NetworkError
Network/HTTP failure.
@ RateLimited
GitHub API rate limit hit (HTTP 403)
@ UpToDate
Current version is the latest.
@ Unknown
No check has run, or result not yet available.
@ Cancelled
Stop requested before the check completed.
@ ParseError
Response did not contain a parseable version.
std::string currentVersion