Unravel Engine C++ Reference
Loading...
Searching...
No Matches
version_manager.cpp
Go to the documentation of this file.
1#include "version_manager.h"
3#include "imgui/imgui.h"
4#include "threadpp/future.hpp"
5#include <context/context.hpp>
7
8#include <version/version.h>
9
10
11#include <algorithm>
12#include <cctype>
13#include <cstdint>
14#include <string>
15#include <vector>
16#include <hpp/string_view.hpp>
17#include <hpp/optional.hpp>
18
19#include <logging/logging.h>
20#include <ser20/external/simdjson/simdjson.h>
21// #define CPPHTTPLIB_OPENSSL_SUPPORT 1
22// #include <httplib.h>
23#include <fstream>
26
27namespace unravel
28{
29
30namespace
31{
32fs::path github_rate_limit_cfg = fs::persistent_path() / "unravel" / "github_rate_limit.cfg";
33
35constexpr auto github_check_interval = std::chrono::hours(6);
36
41auto read_last_check_time() -> std::int64_t
42{
43 fs::error_code ec;
44 if(!fs::exists(github_rate_limit_cfg, ec) || ec)
45 {
46 return 0;
47 }
48 std::ifstream file(github_rate_limit_cfg);
49 if(!file.is_open())
50 {
51 return 0;
52 }
53 std::int64_t timestamp = 0;
54 file >> timestamp;
55 if(file.fail())
56 {
57 return 0;
58 }
59 return timestamp;
60}
61
65void write_last_check_time()
66{
67 fs::error_code ec;
68 auto parent = github_rate_limit_cfg.parent_path();
69 if(!fs::exists(parent, ec))
70 {
71 fs::create_directories(parent, ec);
72 }
73
74 asset_writer::atomic_write_file(github_rate_limit_cfg, [](const fs::path& path)
75 {
76 std::ofstream file(path, std::ios::trunc);
77 if(file.is_open())
78 {
79 auto now = std::chrono::system_clock::now();
80 auto epoch_seconds = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
81 file << epoch_seconds;
82 }
83 }, ec);
84}
85
90auto should_check_github() -> bool
91{
92 auto last_check = read_last_check_time();
93 if(last_check == 0)
94 {
95 return true;
96 }
97 auto now = std::chrono::system_clock::now();
98 auto now_seconds = std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
99 auto elapsed = std::chrono::seconds(now_seconds - last_check);
100 return elapsed >= github_check_interval;
101}
102
103// github_releases.cpp
104//
105// Requirements:
106// - cpp-httplib (with OpenSSL enabled) https://github.com/yhirose/cpp-httplib
107// - simdjson https://github.com/simdjson/simdjson
108//
109// Build idea (example):
110// g++ -std=c++20 github_releases.cpp -lssl -lcrypto -o github_releases
111//
112// What this does:
113// 1) Fetches GitHub releases (optionally only newest N) via REST API
114// 2) Lists releases + assets (artifacts)
115// 3) Checks if an update is available compared to a "current version"
116// Version pattern: major.minor.patch-commit_count_since_tag[-gSHA]
117// e.g. 1.0.0-66-gc83fa23; 1.0.0-61; 1.0.1-0; etc.
118
119// #include <httplib.h>
120
121// ----------------------------- Data model -----------------------------
122
123struct GitHubAsset
124{
125 std::string name;
127 std::uint64_t size = 0;
128 std::string sha256; // Optional: only if you encode it somewhere (GitHub doesn't provide sha256 by default)
129};
130
131struct GitHubRelease
132{
133 std::string tag_name; // Often used as "version"
134 std::string name; // Human title
135 bool draft = false;
136 bool prerelease = false;
137 std::string published_at; // ISO string
138 std::vector<GitHubAsset> assets;
139};
140
141// ----------------------------- Version parsing & compare -----------------------------
142//
143// The core parser/comparator lives in <version/version.h>. We re-use those
144// canonical implementations and only keep the asset-name-specific logic
145// (which is a launcher concern, not a general version concern) here.
146
147using EngineVersion = version::engine_version;
148
149// Adapter: hpp::string_view is not implicitly convertible to std::string_view.
150// The parser API lives in <version/version.h> and takes std::string_view, so
151// we convert here at the boundary.
152static auto parse_engine_version(hpp::string_view ver) -> std::optional<EngineVersion>
153{
154 return version::parse(std::string_view(ver.data(), ver.size()));
155}
156
157// Back-compat alias for compare_versions: the shared implementation returns
158// int in exactly the same sign convention (-1 / 0 / +1).
159using version::compare;
160static auto compare_versions(const EngineVersion& a, const EngineVersion& b) -> int
161{
162 return version::compare(a, b);
163}
164
165// Extract version from asset filename.
166// Pattern: "UnravelEngine-{version}-{platform-info}"
167// Example: "UnravelEngine-1.0.0-69-g1ead714-Windows-AMD64.exe" -> "1.0.0-69-g1ead714"
168static auto extract_version_from_asset_name(hpp::string_view asset_name) -> std::optional<EngineVersion>
169{
170 // Look for "UnravelEngine-" prefix
171 constexpr hpp::string_view prefix = "UnravelEngine-";
172 if(asset_name.size() < prefix.size() || asset_name.substr(0, prefix.size()) != prefix)
173 {
174 return std::nullopt;
175 }
176
177 // Get the part after "UnravelEngine-"
178 hpp::string_view version_part = asset_name.substr(prefix.size());
179
180 // Find where the platform part starts by looking for known platform prefixes
181 // Platform names: Windows, Linux, macOS (case-insensitive check for common ones)
182 constexpr const char* platform_prefixes[] = {
183 "-Windows-", "-Linux-", "-macOS-", "-Darwin-", "-Android-"
184 };
185
186 size_t version_end = version_part.size();
187 for(const char* platform : platform_prefixes)
188 {
189 size_t pos = version_part.find(platform);
190 if(pos != hpp::string_view::npos)
191 {
192 // Found a platform prefix, try parsing up to this point
193 hpp::string_view candidate = version_part.substr(0, pos);
194 auto parsed = parse_engine_version(candidate);
195 if(parsed)
196 {
197 version_end = pos;
198 break;
199 }
200 }
201 }
202
203 // Also try case-insensitive search for platform names
204 if(version_end == version_part.size())
205 {
206 // Look for patterns like "-Windows", "-Linux" (capital letter after dash, followed by more dashes/end)
207 for(size_t i = 1; i < version_part.size(); ++i)
208 {
209 if(version_part[i - 1] == '-' && version_part[i] >= 'A' && version_part[i] <= 'Z')
210 {
211 // Found a dash followed by capital letter - might be platform
212 // Check if we can parse a valid version before this point
213 hpp::string_view candidate = version_part.substr(0, i - 1);
214 auto parsed = parse_engine_version(candidate);
215 if(parsed)
216 {
217 version_end = i - 1;
218 break;
219 }
220 }
221 }
222 }
223
224 // Try parsing the version part
225 hpp::string_view version_str = version_part.substr(0, version_end);
226 auto result = parse_engine_version(version_str);
227
228 // If parsing failed, try the entire remaining string (maybe no platform info)
229 if(!result && version_end == version_part.size())
230 {
231 // Already tried the full string, return nullopt
232 return std::nullopt;
233 }
234
235 return result;
236}
237
238// Convenience: returns true if latest > current, nullopt if parsing fails
239static auto is_newer_version_available(hpp::string_view current_version_str, hpp::string_view latest_version_str) -> hpp::optional<bool>
240{
241 auto cur = parse_engine_version(current_version_str);
242 auto lat = parse_engine_version(latest_version_str);
243 if(!cur || !lat)
244 {
245 return std::nullopt;
246 }
247 return compare_versions(*cur, *lat) < 0;
248}
249
250// ----------------------------- GitHub API -----------------------------
251
252struct GitHubClientConfig
253{
254 std::string owner;
255 std::string repo;
256
257 // Optional. Strongly recommended for launchers to avoid rate limits.
258 // If provided, use "Bearer <token>".
259 std::string token;
260
261 // Include drafts? Usually no for public updates.
262 bool include_drafts = false;
263
264 // Include prereleases? Up to you.
266
267 // GitHub REST API host
268 std::string host = "api.github.com";
269
270 // How many releases to request per page (max 100).
271 int per_page = 50;
272};
273
274// Parse JSON into our structs
275static auto parse_release_json(const simdjson::dom::element& r) -> GitHubRelease
276{
277 GitHubRelease rel;
278
279 auto tag_name = r["tag_name"];
280 if(!tag_name.error())
281 {
282 std::string_view tag_sv;
283 if(tag_name.get(tag_sv) == simdjson::SUCCESS)
284 rel.tag_name = std::string(tag_sv);
285 }
286
287 auto name = r["name"];
288 if(!name.error())
289 {
290 std::string_view name_sv;
291 if(name.get(name_sv) == simdjson::SUCCESS)
292 rel.name = std::string(name_sv);
293 }
294
295 auto draft = r["draft"];
296 if(!draft.error())
297 {
298 bool draft_val;
299 if(draft.get(draft_val) == simdjson::SUCCESS)
300 rel.draft = draft_val;
301 }
302
303 auto prerelease = r["prerelease"];
304 if(!prerelease.error())
305 {
306 bool prerelease_val;
307 if(prerelease.get(prerelease_val) == simdjson::SUCCESS)
308 rel.prerelease = prerelease_val;
309 }
310
311 auto published_at = r["published_at"];
312 if(!published_at.error())
313 {
314 std::string_view published_sv;
315 if(published_at.get(published_sv) == simdjson::SUCCESS)
316 rel.published_at = std::string(published_sv);
317 }
318
319 auto assets = r["assets"];
320 if(!assets.error() && assets.type() == simdjson::dom::element_type::ARRAY)
321 {
322 for(const auto& a : assets)
323 {
324 GitHubAsset asset;
325
326 auto asset_name = a["name"];
327 if(!asset_name.error())
328 {
329 std::string_view name_sv;
330 if(asset_name.get(name_sv) == simdjson::SUCCESS)
331 asset.name = std::string(name_sv);
332 }
333
334 auto browser_url = a["browser_download_url"];
335 if(!browser_url.error())
336 {
337 std::string_view url_sv;
338 if(browser_url.get(url_sv) == simdjson::SUCCESS)
339 asset.browser_download_url = std::string(url_sv);
340 }
341
342 auto size = a["size"];
343 if(!size.error())
344 {
345 uint64_t size_val;
346 if(size.get(size_val) == simdjson::SUCCESS)
347 asset.size = size_val;
348 }
349
350 // NOTE: GitHub releases API does NOT provide sha256 by default.
351 // If you want sha256, you can:
352 // - upload a .sha256 file as a separate asset and parse it
353 // - or store hashes in release notes and parse body
354 rel.assets.push_back(std::move(asset));
355 }
356 }
357 return rel;
358}
359
360// Result type for fetch operations
361struct FetchResult
362{
363 std::vector<GitHubRelease> releases;
364 std::string error_message;
365
366 bool has_error() const { return !error_message.empty(); }
367};
368
369// Fetch all releases (paginated), filtered by config flags.
370// Returns newest-first as GitHub provides.
371static auto fetch_github_releases(const GitHubClientConfig& cfg) -> FetchResult
372{
373 // httplib::SSLClient cli(cfg.host);
374 // cli.set_follow_location(true);
375
376 // auto timeout = std::chrono::seconds(10);
377 // cli.set_max_timeout(std::chrono::milliseconds(timeout).count());
378 // httplib::Headers headers = {{"User-Agent", "UnravelLauncher"}, {"Accept", "application/vnd.github+json"}};
379 // if(!cfg.token.empty())
380 // {
381 // headers.emplace("Authorization", "Bearer " + cfg.token);
382 // }
383
384 // FetchResult result;
385 // int page = 1;
386 // simdjson::dom::parser parser;
387
388 // while(true)
389 // {
390 // std::string path = "/repos/" + cfg.owner + "/" + cfg.repo +
391 // "/releases?per_page=" + std::to_string(cfg.per_page) + "&page=" + std::to_string(page);
392
393 // auto res = cli.Get(path.c_str(), headers);
394 // if(!res)
395 // {
396 // result.error_message = "HTTP request failed (no response) for " + path;
397 // return result;
398 // }
399 // if(res->status != 200)
400 // {
401 // result.error_message = "GitHub API error " + std::to_string(res->status) + " for " + path +
402 // " body: " + res->body;
403 // return result;
404 // }
405
406 // auto remaining = res->get_header_value("X-RateLimit-Remaining");
407 // auto reset = res->get_header_value("X-RateLimit-Reset");
408
409 // APPLOG_TRACE("GitHub Rate Limit: Remaining: {}", remaining);
410 // APPLOG_TRACE("GitHub Rate Limit: Reset: {}", reset);
411
412 // simdjson::dom::element j;
413 // auto error = parser.parse(res->body).get(j);
414 // if(error != simdjson::SUCCESS)
415 // {
416 // result.error_message = "Failed to parse JSON for " + path + ": " + simdjson::error_message(error);
417 // return result;
418 // }
419
420 // if(j.type() != simdjson::dom::element_type::ARRAY)
421 // {
422 // result.error_message = "Unexpected JSON (expected array) for " + path;
423 // return result;
424 // }
425
426 // size_t array_size = 0;
427 // for(const auto& r : j)
428 // {
429 // array_size++;
430 // GitHubRelease rel = parse_release_json(r);
431
432 // if(!cfg.include_drafts && rel.draft)
433 // continue;
434 // if(!cfg.include_prereleases && rel.prerelease)
435 // continue;
436
437 // result.releases.push_back(std::move(rel));
438 // }
439
440 // if(array_size == 0)
441 // break;
442
443 // // If less than per_page, no more pages.
444 // if(static_cast<int>(array_size) < cfg.per_page)
445 // break;
446
447 // ++page;
448 // // (Optional) hard cap to prevent runaway
449 // if(page > 50)
450 // break;
451 // }
452 // return result;
453 return {};
454}
455
456// Extract the best version from a release (checks both tag_name and asset names).
457static auto get_best_version_from_release(const GitHubRelease& release) -> std::optional<EngineVersion>
458{
459 // First, try tag_name
460 auto tag_ver = parse_engine_version(release.tag_name);
461
462 // Also check asset names for versions
463 std::optional<EngineVersion> asset_ver;
464 for(const auto& asset : release.assets)
465 {
466 auto extracted = extract_version_from_asset_name(asset.name);
467 if(extracted)
468 {
469 // Use the highest version found in assets
470 if(!asset_ver || compare_versions(*extracted, *asset_ver) > 0)
471 {
472 asset_ver = extracted;
473 }
474 }
475 }
476
477 // Use the highest version between tag and assets
478 if(tag_ver && asset_ver)
479 {
480 return (compare_versions(*tag_ver, *asset_ver) > 0) ? tag_ver : asset_ver;
481 }
482 if(tag_ver)
483 {
484 return tag_ver;
485 }
486 return asset_ver;
487}
488
489// Find "latest" release by comparing tag_name versions and asset versions (your custom scheme).
490// Checks both the release tag_name and versions extracted from asset filenames.
491// Ignores releases whose tag_name doesn't parse (unless assets have parseable versions).
492static auto pick_latest_by_version(const std::vector<GitHubRelease>& releases) -> std::optional<GitHubRelease>
493{
494 std::optional<GitHubRelease> best;
495 std::optional<EngineVersion> best_ver;
496
497 for(const auto& r : releases)
498 {
499 auto release_ver = get_best_version_from_release(r);
500 if(!release_ver)
501 {
502 // Neither tag nor assets have a parseable version, skip this release
503 continue;
504 }
505
506 if(!best || compare_versions(*release_ver, *best_ver) > 0)
507 {
508 best = r;
509 best_ver = release_ver;
510 }
511 }
512 return best;
513}
514
515// Checks if update exists compared to current_version.
516// Returns the chosen latest release if newer, else nullopt.
517// Uses your compare: major/minor/patch then commit_count.
518// Checks both release tag_name and versions extracted from asset filenames.
519static auto check_for_update(const std::vector<GitHubRelease>& releases,
520 hpp::string_view current_version) -> std::optional<GitHubRelease>
521{
522 auto cur = parse_engine_version(current_version);
523 if(!cur)
524 {
525 return std::nullopt;
526 }
527
528 auto latest = pick_latest_by_version(releases);
529 if(!latest)
530 return std::nullopt;
531
532 // Get the best version from the release (considering both tag and assets)
533 auto latest_ver = get_best_version_from_release(*latest);
534 if(!latest_ver)
535 return std::nullopt;
536
537 if(compare_versions(*cur, *latest_ver) < 0)
538 {
539 return latest;
540 }
541 return std::nullopt;
542}
543
544// ----------------------------- Example usage -----------------------------
545
546static void print_releases_and_assets(const std::vector<GitHubRelease>& releases)
547{
548 for(const auto& r : releases)
549 {
550 std::string release_info = "Release: " + r.tag_name;
551 if(!r.name.empty())
552 release_info += " (" + r.name + ")";
553 if(!r.published_at.empty())
554 release_info += " published: " + r.published_at;
555 if(r.prerelease)
556 release_info += " [prerelease]";
557 if(r.draft)
558 release_info += " [draft]";
559 APPLOG_TRACE("{}", release_info);
560
561 for(const auto& a : r.assets)
562 {
563 APPLOG_TRACE(" - {} ({:.2f} MB)", a.name, a.size / (1024.0 * 1024.0));
564 APPLOG_TRACE(" {}", a.browser_download_url);
565 }
566 APPLOG_TRACE("");
567 }
568}
569
570auto check_for_update(bool force_check = false) -> bool
571{
572 if(!should_check_github() && !force_check)
573 {
574 APPLOG_TRACE("Skipping GitHub update check (last check was less than 6 hours ago).");
575 return false;
576 }
577
578 if(force_check)
579 {
580 write_last_check_time();
581 }
582
583 GitHubClientConfig cfg;
584 cfg.owner = "unravel-dev";
585 cfg.repo = "UnravelEngine";
586 // cfg.token = "ghp_..."; // strongly recommended for a launcher
587 cfg.include_drafts = false;
588 cfg.include_prereleases = false;
589 cfg.per_page = 50;
590
591 // Example current version (your scheme)
592 std::string current_version = version::get_full();
593
594 auto fetch_result = fetch_github_releases(cfg);
595 if(fetch_result.has_error())
596 {
597 APPLOG_ERROR("Error fetching releases: {}", fetch_result.error_message);
598 return false;
599 }
600
601
602 // Print everything we got (versions + artifacts)
603 print_releases_and_assets(fetch_result.releases);
604
605 // Update check
606 if(auto update = check_for_update(fetch_result.releases, current_version))
607 {
608 APPLOG_INFO("UPDATE AVAILABLE!");
609 APPLOG_INFO(" Current: {}", current_version);
610 APPLOG_INFO(" Latest: {}", update->tag_name);
611 APPLOG_INFO(" Assets:");
612 for(const auto& a : update->assets)
613 {
614 APPLOG_INFO(" * {}", a.name);
615 }
616 return true;
617 }
618 else
619 {
620 APPLOG_TRACE("No update available. Current: {}", current_version);
621 }
622
623 return false;
624}
625
626} // namespace
628{
629 tpp::async(
630 []()
631 {
632 return should_check_github();
633 })
634 .then(tpp::this_thread::get_id(),
635 [](auto result)
636 {
637 auto has_update = result.get();
638 if(has_update)
639 {
641 // toast.set_title("New version available.");
642 toast.set_show_dismiss_button(true);
644 toast.set_on_dismiss(
645 []()
646 {
647 // Write the last check time to the cfg file
648 write_last_check_time();
649 }
650 );
651 toast.set_draw_callback(
652 [](const ImGuiToast& toast, float opacity, const ImVec4& text_color)
653 {
654 ImGui::TextColored(text_color, "New update available!");
655 ImGui::SameLine();
656 if(ImGui::Button("Install Now"))
657 {
658 ImGui::OpenInShell("https://github.com/unravel-dev/UnravelEngine/releases");
659 }
660 });
662 }
663 });
664 return true;
665}
666
668{
669 return true;
670}
671
673{
674 tpp::async(
675 []()
676 {
677 return check_for_update(true);
678 })
679 .then(tpp::this_thread::get_id(),
680 [](auto result)
681 {
682 auto has_update = result.get();
683 if(has_update)
684 {
686 toast.set_title("New version available.");
688 toast.set_show_dismiss_button(true);
689 toast.set_on_dismiss(
690 []()
691 {
692 // Write the last check time to the cfg file
693 write_last_check_time();
694 }
695 );
696 toast.set_draw_callback(
697 [](const ImGuiToast& toast, float opacity, const ImVec4& text_color)
698 {
699 ImGui::Text("Download from ");
700 ImGui::SameLine();
701 ImGui::TextLinkOpenURL("Releases.", "https://github.com/unravel-dev/UnravelEngine/releases");
702 });
704 }
705 else
706 {
708 toast.set_title("There are no updates available.");
709 toast.set_content("You are using the latest version.");
711
713 }
714 });
715}
716} // namespace unravel
entt::handle b
float elapsed
entt::handle a
NOTIFY_INLINE auto set_show_dismiss_button(const bool &show) -> void
NOTIFY_INLINE auto set_on_dismiss(const std::function< void()> &callback) -> void
NOTIFY_INLINE auto set_horizontal_pos(const ImGuiToastHorizontalPos &pos) -> void
NOTIFY_INLINE auto set_draw_callback(const ImGuiToastDrawCallback &callback) -> void
std::string name
Definition hub.cpp:33
@ ImGuiToastType_Warning
@ ImGuiToastHorizontalPos_Left
#define APPLOG_ERROR(...)
Definition logging.h:20
#define APPLOG_INFO(...)
Definition logging.h:18
#define APPLOG_TRACE(...)
Definition logging.h:17
NOTIFY_INLINE void PushNotification(const ImGuiToast &toast)
Insert a new toast in the list.
void update(dynamic_index_buffer_handle _handle, uint32_t _startIndex, const memory_view *_mem)
Definition graphics.cpp:535
void atomic_write_file(const fs::path &dst, const std::function< void(const fs::path &)> &callback, fs::error_code &ec) noexcept
auto parse(std::string_view text) -> std::optional< engine_version >
Parses a version string. Returns nullopt on malformed input.
Definition version.cpp:129
auto get_full() -> std::string
Definition version.cpp:226
auto compare(const engine_version &a, const engine_version &b) -> int
Definition version.cpp:188
auto init(rtti::context &ctx) -> bool
auto deinit(rtti::context &ctx) -> bool
std::string repo
int per_page
std::string published_at
bool include_drafts
bool include_prereleases
std::string host
bool prerelease
bool draft
std::string owner
std::string token
std::string error_message
std::string tag_name
std::vector< GitHubAsset > assets
std::string sha256
std::vector< GitHubRelease > releases
std::string browser_download_url