diff --git a/CMakeLists.txt b/CMakeLists.txt index a48a9e7abf..6956574919 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -69,7 +69,7 @@ message(STATUS "Dusk version set to ${DUSK_WC_DESCRIBE}") message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") project(dusk LANGUAGES C CXX VERSION ${DUSK_VERSION_STRING}) if (APPLE) - enable_language(OBJC) + enable_language(OBJC OBJCXX) endif () if (APPLE AND NOT TVOS AND CMAKE_SYSTEM_NAME STREQUAL tvOS) # ios.toolchain.cmake hack for SDL @@ -109,6 +109,12 @@ add_subdirectory(libs/freeverb) option(DUSK_BUILD_WARNINGS "Enable compiler warnings (off by default)") option(DUSK_SELECTED_OPT "If on, selected parts of the project will be compiled with optimizations on Debug, intending to make the game run at 30 FPS. Note for MSVC: you will need to remove '/RTC1' from your debug flags in CMake.") option(DUSK_MOVIE_SUPPORT "If on, compile against libjpeg-turbo to enable THP file decoding" ON) +if (ANDROID) + set(DUSK_ENABLE_UPDATE_CHECKER_DEFAULT OFF) +else () + set(DUSK_ENABLE_UPDATE_CHECKER_DEFAULT ON) +endif () +option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ${DUSK_ENABLE_UPDATE_CHECKER_DEFAULT}) if(ANDROID) set(DUSK_MOVIE_SUPPORT OFF) @@ -284,7 +290,7 @@ set(DUSK_PRODUCT_NAME "Dusk") set(DUSK_COPYRIGHT "Copyright (C) Twilit Realm contributors") source_group("dolzel" FILES ${DOLZEL_FILES} ${Z2AUDIOLIB_FILES} ${REL_FILES}) -source_group("dusk" FILES ${DUSK_FILES}) +source_group("dusk" FILES ${DUSK_FILES} ${DUSK_HTTP_BACKEND_FILES}) set(GAME_COMPILE_DEFS TARGET_PC WIDESCREEN_SUPPORT=1 AVOID_UB=1 VERSION=0 MTX_USE_PS=1) @@ -314,6 +320,37 @@ if (WIN32) list(APPEND GAME_LIBS Ws2_32) endif () +set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/no_backend.cpp) +if (DUSK_ENABLE_UPDATE_CHECKER) + list(APPEND GAME_COMPILE_DEFS DUSK_ENABLE_UPDATE_CHECKER=1) + if (WIN32) + set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/winhttp.cpp) + list(APPEND GAME_LIBS winhttp) + list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_WINHTTP=1) + message(STATUS "dusk: Enabled update checker (WinHTTP)") + elseif (APPLE) + find_library(FOUNDATION_FRAMEWORK Foundation REQUIRED) + set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/url_session.mm) + set_source_files_properties(src/dusk/http/url_session.mm PROPERTIES COMPILE_FLAGS -fobjc-arc) + list(APPEND GAME_LIBS ${FOUNDATION_FRAMEWORK}) + list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_URLSESSION=1) + message(STATUS "dusk: Enabled update checker (NSURLSession)") + elseif (CMAKE_SYSTEM_NAME STREQUAL Linux) + find_package(CURL QUIET OPTIONAL_COMPONENTS HTTPS SSL) + if (CURL_FOUND AND CURL_HTTPS_FOUND AND CURL_SSL_FOUND) + set(DUSK_HTTP_BACKEND_SOURCE src/dusk/http/curl.cpp) + list(APPEND GAME_LIBS CURL::libcurl) + list(APPEND GAME_COMPILE_DEFS DUSK_HTTP_BACKEND_LIBCURL=1) + message(STATUS "dusk: Enabled update checker (libcurl)") + else () + message(STATUS "dusk: Disabled update checker (libcurl + HTTPS/SSL not found)") + endif () + else () + message(STATUS "dusk: Disabled update checker (unsupported platform)") + endif () +endif () +list(APPEND DUSK_FILES ${DUSK_HTTP_BACKEND_SOURCE}) + if (DUSK_MOVIE_SUPPORT) if (TARGET libjpeg-turbo::turbojpeg-static) list(APPEND GAME_LIBS libjpeg-turbo::turbojpeg-static) diff --git a/files.cmake b/files.cmake index 0b372349eb..53aa7a2636 100644 --- a/files.cmake +++ b/files.cmake @@ -1430,11 +1430,14 @@ set(DUSK_FILES src/dusk/gyro.cpp src/dusk/gamepad_color.cpp src/dusk/autosave.cpp + src/dusk/http/http.hpp src/dusk/io.cpp src/dusk/layout.cpp src/dusk/logging.cpp src/dusk/settings.cpp src/dusk/stubs.cpp + src/dusk/update_check.cpp + src/dusk/update_check.hpp #src/dusk/m_Do_ext_dusk.cpp src/dusk/imgui/ImGuiConfig.hpp src/dusk/imgui/ImGuiConsole.hpp @@ -1516,3 +1519,10 @@ set(DUSK_FILES src/dusk/discord_presence.cpp src/dusk/version.cpp ) + +set(DUSK_HTTP_BACKEND_FILES + src/dusk/http/no_backend.cpp + src/dusk/http/curl.cpp + src/dusk/http/winhttp.cpp + src/dusk/http/url_session.mm +) diff --git a/include/dusk/settings.h b/include/dusk/settings.h index 4a505049c5..78c5540e58 100644 --- a/include/dusk/settings.h +++ b/include/dusk/settings.h @@ -172,6 +172,7 @@ struct UserSettings { ConfigVar showPipelineCompilation; ConfigVar wasPresetChosen; ConfigVar enableCrashReporting; + ConfigVar checkForUpdates; ConfigVar cardFileType; ConfigVar enableAdvancedSettings; } backend; diff --git a/res/rml/prelaunch.rcss b/res/rml/prelaunch.rcss index 16bc7ea4ed..9b83db4660 100644 --- a/res/rml/prelaunch.rcss +++ b/res/rml/prelaunch.rcss @@ -273,20 +273,61 @@ body.mirrored version-info { font-size: 20dp; } -/* TODO: Hidden until an actual update checker is introduced */ .update { display: none; font-size: 16dp; - font-weight: bold; - cursor: pointer; - color: #D8F999; + color: #A6A09B; + align-items: center; + justify-content: flex-end; + gap: 8dp; + font-size: 20dp; } -.detail, -.update span { +.update[state=checking], +.update[state=failed] { + display: block; +} + +.update[state=available] { + display: flex; +} + +#update-download { + display: none; + margin: 0dp; + padding: 0dp; + border-width: 0dp; + background-color: transparent; + color: #D8F999; + cursor: pointer; + text-transform: uppercase; + font-weight: bold; + decorator: horizontal-gradient(#00000000 #00000000); +} + +.update[state=available] #update-download { + display: flex; + align-items: center; + gap: 2dp; +} + +#update-download icon { + display: block; + width: 18dp; + height: 18dp; + font-family: "Material Symbols Rounded"; + font-weight: normal; + decorator: text("" center center); +} + +.detail { color: #A6A09B; } +body.mirrored .update { + justify-content: flex-start; +} + /* Startup animation */ .intro-item { opacity: 0; diff --git a/src/dusk/http/curl.cpp b/src/dusk/http/curl.cpp new file mode 100644 index 0000000000..5fdaf88a64 --- /dev/null +++ b/src/dusk/http/curl.cpp @@ -0,0 +1,206 @@ +#include "http.hpp" + +#include + +#include +#include +#include +#include + +namespace dusk::http { +namespace { + +struct CurlHeaders { + curl_slist* list = nullptr; + + ~CurlHeaders() { + if (list != nullptr) { + curl_slist_free_all(list); + } + } + + bool append(const std::string& header) { + curl_slist* next = curl_slist_append(list, header.c_str()); + if (next == nullptr) { + return false; + } + list = next; + return true; + } +}; + +struct CurlContext { + Response response; + size_t maxBodyBytes = 0; + bool tooLarge = false; +}; + +void initialize_curl() { + curl_global_init(CURL_GLOBAL_DEFAULT); +} + +std::string trim_header_value(std::string_view value) { + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { + value.remove_prefix(1); + } + while (!value.empty() && + (value.back() == '\r' || value.back() == '\n' || value.back() == ' ' || + value.back() == '\t')) { + value.remove_suffix(1); + } + return std::string(value); +} + +size_t write_body(char* ptr, size_t size, size_t nmemb, void* userdata) { + auto* context = static_cast(userdata); + const size_t bytes = size * nmemb; + if (bytes > context->maxBodyBytes || + context->response.body.size() > context->maxBodyBytes - bytes) { + context->tooLarge = true; + return 0; + } + + context->response.body.append(ptr, bytes); + return bytes; +} + +size_t write_header(char* ptr, size_t size, size_t nmemb, void* userdata) { + auto* context = static_cast(userdata); + const std::string_view line(ptr, size * nmemb); + if (line.starts_with("HTTP/")) { + context->response.headers.clear(); + return size * nmemb; + } + + const size_t colon = line.find(':'); + if (colon == std::string_view::npos) { + return size * nmemb; + } + + context->response.headers.push_back({ + .name = std::string(line.substr(0, colon)), + .value = trim_header_value(line.substr(colon + 1)), + }); + return size * nmemb; +} + +Error map_curl_error(CURLcode code, bool tooLarge) { + if (tooLarge) { + return Error::TooLarge; + } + + switch (code) { + case CURLE_OK: + return Error::None; + case CURLE_URL_MALFORMAT: + return Error::InvalidUrl; + case CURLE_UNSUPPORTED_PROTOCOL: + return Error::UnsupportedScheme; + case CURLE_OPERATION_TIMEDOUT: + return Error::Timeout; + default: + return Error::Network; + } +} + +long timeout_ms(std::chrono::milliseconds timeout) { + return std::max(1, timeout.count()); +} + +} // namespace + +bool available() noexcept { + return true; +} + +Backend backend() noexcept { + return Backend::LibCurl; +} + +const char* backend_name() noexcept { + return "libcurl"; +} + +Result get(const Request& request) { + if (request.url.empty()) { + return { + .error = Error::InvalidUrl, + .message = "URL is empty", + }; + } + if (!request.url.starts_with("https://")) { + return { + .error = Error::UnsupportedScheme, + .message = "Only https:// URLs are supported", + }; + } + + static std::once_flag initFlag; + std::call_once(initFlag, initialize_curl); + + CURL* curl = curl_easy_init(); + if (curl == nullptr) { + return { + .error = Error::Network, + .message = "Failed to create libcurl request", + }; + } + + CurlHeaders headers; + for (const Header& header : request.headers) { + if (!headers.append(header.name + ": " + header.value)) { + curl_easy_cleanup(curl); + return { + .error = Error::Network, + .message = "Failed to allocate libcurl headers", + }; + } + } + + CurlContext context{ + .maxBodyBytes = request.maxBodyBytes, + }; + + curl_easy_setopt(curl, CURLOPT_URL, request.url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers.list); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, timeout_ms(request.timeout)); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, timeout_ms(request.timeout)); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_body); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &context); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, write_header); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &context); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); +#if CURL_AT_LEAST_VERSION(7, 85, 0) + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); +#else + curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS); +#endif + + const CURLcode code = curl_easy_perform(curl); + long statusCode = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &statusCode); + curl_easy_cleanup(curl); + + context.response.statusCode = static_cast(statusCode); + if (code == CURLE_OK) { + return { + .response = std::move(context.response), + }; + } + + const Error error = map_curl_error(code, context.tooLarge); + return { + .error = error, + .message = error == Error::TooLarge ? "Response body exceeded the configured limit" + : curl_easy_strerror(code), + .response = std::move(context.response), + }; +} + +} // namespace dusk::http diff --git a/src/dusk/http/http.hpp b/src/dusk/http/http.hpp new file mode 100644 index 0000000000..d62b031fbc --- /dev/null +++ b/src/dusk/http/http.hpp @@ -0,0 +1,59 @@ +#ifndef DUSK_HTTP_HTTP_HPP +#define DUSK_HTTP_HTTP_HPP + +#include +#include +#include +#include + +namespace dusk::http { + +enum class Backend { + None, + WinHttp, + UrlSession, + LibCurl, +}; + +enum class Error { + None, + NoBackend, + InvalidUrl, + UnsupportedScheme, + Timeout, + TooLarge, + Network, +}; + +struct Header { + std::string name; + std::string value; +}; + +struct Request { + std::string url; + std::vector
headers; + std::chrono::milliseconds timeout{10000}; + size_t maxBodyBytes = 1024 * 1024; +}; + +struct Response { + int statusCode = 0; + std::vector
headers; + std::string body; +}; + +struct Result { + Error error = Error::None; + std::string message; + Response response; +}; + +bool available() noexcept; +Backend backend() noexcept; +const char* backend_name() noexcept; +Result get(const Request& request); + +} // namespace dusk::http + +#endif // DUSK_HTTP_HTTP_HPP diff --git a/src/dusk/http/no_backend.cpp b/src/dusk/http/no_backend.cpp new file mode 100644 index 0000000000..4b42afb3c7 --- /dev/null +++ b/src/dusk/http/no_backend.cpp @@ -0,0 +1,24 @@ +#include "http.hpp" + +namespace dusk::http { + +bool available() noexcept { + return false; +} + +Backend backend() noexcept { + return Backend::None; +} + +const char* backend_name() noexcept { + return "none"; +} + +Result get(const Request&) { + return { + .error = Error::NoBackend, + .message = "No HTTP backend is available", + }; +} + +} // namespace dusk::http diff --git a/src/dusk/http/url_session.mm b/src/dusk/http/url_session.mm new file mode 100644 index 0000000000..01dd699a9c --- /dev/null +++ b/src/dusk/http/url_session.mm @@ -0,0 +1,238 @@ +#include "http.hpp" + +#import + +#include +#include +#include + +@interface DuskHttpRequestDelegate : NSObject +@property(nonatomic) dispatch_semaphore_t semaphore; +@property(nonatomic) size_t maxBodyBytes; +@property(nonatomic, strong) NSMutableData* data; +@property(nonatomic, strong) NSURLResponse* response; +@property(nonatomic, strong) NSError* error; +@property(nonatomic) BOOL tooLarge; +- (instancetype)initWithMaxBodyBytes:(size_t)maxBodyBytes; +@end + +@implementation DuskHttpRequestDelegate + +- (instancetype)initWithMaxBodyBytes:(size_t)maxBodyBytes { + self = [super init]; + if (self != nil) { + _semaphore = dispatch_semaphore_create(0); + _maxBodyBytes = maxBodyBytes; + _data = [NSMutableData data]; + } + return self; +} + +- (void)URLSession:(NSURLSession*)session + task:(NSURLSessionTask*)task + willPerformHTTPRedirection:(NSHTTPURLResponse*)response + newRequest:(NSURLRequest*)request + completionHandler:(void (^)(NSURLRequest*))completionHandler { + if ([[request.URL.scheme lowercaseString] isEqualToString:@"https"]) { + completionHandler(request); + } else { + completionHandler(nil); + } +} + +- (void)URLSession:(NSURLSession*)session + dataTask:(NSURLSessionDataTask*)dataTask +didReceiveResponse:(NSURLResponse*)response + completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler { + self.response = response; + completionHandler(NSURLSessionResponseAllow); +} + +- (void)URLSession:(NSURLSession*)session + dataTask:(NSURLSessionDataTask*)dataTask + didReceiveData:(NSData*)data { + if (data.length > self.maxBodyBytes || + self.data.length > self.maxBodyBytes - data.length) { + self.tooLarge = YES; + [dataTask cancel]; + return; + } + [self.data appendData:data]; +} + +- (void)URLSession:(NSURLSession*)session + task:(NSURLSessionTask*)task + didCompleteWithError:(NSError*)error { + if (error != nil && !self.tooLarge) { + self.error = error; + } + dispatch_semaphore_signal(self.semaphore); +} + +@end + +namespace dusk::http { +namespace { + +NSString* to_nsstring(std::string_view value) { + return [[NSString alloc] initWithBytes:value.data() + length:value.size() + encoding:NSUTF8StringEncoding]; +} + +std::string to_string(NSString* value) { + if (value == nil) { + return {}; + } + + const char* utf8 = [value UTF8String]; + return utf8 == nullptr ? std::string() : std::string(utf8); +} + +Error map_nsurl_error(NSError* error) { + if (error == nil || ![error.domain isEqualToString:NSURLErrorDomain]) { + return Error::Network; + } + + switch (error.code) { + case NSURLErrorTimedOut: + return Error::Timeout; + case NSURLErrorBadURL: + case NSURLErrorUnsupportedURL: + return Error::InvalidUrl; + default: + return Error::Network; + } +} + +dispatch_time_t timeout_deadline(std::chrono::milliseconds timeout) { + const auto milliseconds = std::max(1, timeout.count()); + return dispatch_time(DISPATCH_TIME_NOW, + static_cast(milliseconds) * static_cast(NSEC_PER_MSEC)); +} + +} // namespace + +bool available() noexcept { + return true; +} + +Backend backend() noexcept { + return Backend::UrlSession; +} + +const char* backend_name() noexcept { + return "NSURLSession"; +} + +Result get(const Request& request) { + @autoreleasepool { + if (request.url.empty()) { + return { + .error = Error::InvalidUrl, + .message = "URL is empty", + }; + } + if (!request.url.starts_with("https://")) { + return { + .error = Error::UnsupportedScheme, + .message = "Only https:// URLs are supported", + }; + } + + NSString* urlString = to_nsstring(request.url); + if (urlString == nil) { + return { + .error = Error::InvalidUrl, + .message = "URL is not valid UTF-8", + }; + } + + NSURL* url = [NSURL URLWithString:urlString]; + if (url == nil || ![[url.scheme lowercaseString] isEqualToString:@"https"]) { + return { + .error = Error::InvalidUrl, + .message = "Failed to parse URL", + }; + } + + NSMutableURLRequest* urlRequest = [NSMutableURLRequest requestWithURL:url]; + urlRequest.HTTPMethod = @"GET"; + urlRequest.timeoutInterval = request.timeout.count() / 1000.0; + urlRequest.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData; + for (const Header& header : request.headers) { + NSString* name = to_nsstring(header.name); + NSString* value = to_nsstring(header.value); + if (name == nil || value == nil) { + return { + .error = Error::InvalidUrl, + .message = "Request header is not valid UTF-8", + }; + } + [urlRequest setValue:value forHTTPHeaderField:name]; + } + + NSURLSessionConfiguration* configuration = + [NSURLSessionConfiguration ephemeralSessionConfiguration]; + configuration.timeoutIntervalForRequest = request.timeout.count() / 1000.0; + configuration.timeoutIntervalForResource = request.timeout.count() / 1000.0; + + DuskHttpRequestDelegate* delegate = + [[DuskHttpRequestDelegate alloc] initWithMaxBodyBytes:request.maxBodyBytes]; + NSURLSession* session = [NSURLSession sessionWithConfiguration:configuration + delegate:delegate + delegateQueue:nil]; + NSURLSessionDataTask* task = [session dataTaskWithRequest:urlRequest]; + [task resume]; + + if (dispatch_semaphore_wait(delegate.semaphore, timeout_deadline(request.timeout)) != 0) { + [task cancel]; + [session invalidateAndCancel]; + return { + .error = Error::Timeout, + .message = "Request timed out", + }; + } + + [session finishTasksAndInvalidate]; + + Response response; + if ([delegate.response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)delegate.response; + response.statusCode = static_cast(httpResponse.statusCode); + NSDictionary* headers = httpResponse.allHeaderFields; + for (id key in headers) { + id value = headers[key]; + response.headers.push_back({ + .name = to_string([key description]), + .value = to_string([value description]), + }); + } + } + if (delegate.data != nil && delegate.data.length > 0) { + response.body.assign(static_cast(delegate.data.bytes), + static_cast(delegate.data.length)); + } + + if (delegate.tooLarge) { + return { + .error = Error::TooLarge, + .message = "Response body exceeded the configured limit", + .response = std::move(response), + }; + } + if (delegate.error != nil) { + return { + .error = map_nsurl_error(delegate.error), + .message = to_string(delegate.error.localizedDescription), + .response = std::move(response), + }; + } + + return { + .response = std::move(response), + }; + } +} + +} // namespace dusk::http diff --git a/src/dusk/http/winhttp.cpp b/src/dusk/http/winhttp.cpp new file mode 100644 index 0000000000..937579f076 --- /dev/null +++ b/src/dusk/http/winhttp.cpp @@ -0,0 +1,320 @@ +#include "http.hpp" + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include + +#include +#include +#include +#include +#include + +namespace dusk::http { +namespace { + +struct WinHttpHandle { + HINTERNET handle = nullptr; + + WinHttpHandle() = default; + explicit WinHttpHandle(HINTERNET handle) : handle(handle) {} + WinHttpHandle(const WinHttpHandle&) = delete; + WinHttpHandle& operator=(const WinHttpHandle&) = delete; + + ~WinHttpHandle() { + if (handle != nullptr) { + WinHttpCloseHandle(handle); + } + } + + operator HINTERNET() const { return handle; } +}; + +std::wstring utf8_to_wide(std::string_view value) { + if (value.empty()) { + return {}; + } + + const int required = MultiByteToWideChar( + CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast(value.size()), nullptr, 0); + if (required <= 0) { + return {}; + } + + std::wstring result(static_cast(required), L'\0'); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast(value.size()), + result.data(), required); + return result; +} + +std::string wide_to_utf8(std::wstring_view value) { + if (value.empty()) { + return {}; + } + + const int required = WideCharToMultiByte( + CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0, nullptr, nullptr); + if (required <= 0) { + return {}; + } + + std::string result(static_cast(required), '\0'); + WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), result.data(), + required, nullptr, nullptr); + return result; +} + +DWORD timeout_ms(std::chrono::milliseconds timeout) { + const auto count = std::max(1, timeout.count()); + return static_cast( + std::min(count, std::numeric_limits::max())); +} + +Error map_winhttp_error(DWORD error) { + switch (error) { + case ERROR_WINHTTP_TIMEOUT: + return Error::Timeout; + case ERROR_WINHTTP_INVALID_URL: + case ERROR_WINHTTP_UNRECOGNIZED_SCHEME: + return Error::InvalidUrl; + case ERROR_WINHTTP_SECURE_FAILURE: + case ERROR_WINHTTP_CANNOT_CONNECT: + case ERROR_WINHTTP_CONNECTION_ERROR: + default: + return Error::Network; + } +} + +Result fail_from_last_error(const char* message) { + const DWORD error = GetLastError(); + return { + .error = map_winhttp_error(error), + .message = std::string(message) + " (" + std::to_string(error) + ")", + }; +} + +std::string trim_header_value(std::string_view value) { + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { + value.remove_prefix(1); + } + while (!value.empty() && (value.back() == '\r' || value.back() == '\n' || value.back() == ' ' || + value.back() == '\t')) + { + value.remove_suffix(1); + } + return std::string(value); +} + +void parse_headers(std::wstring_view rawHeaders, Response& response) { + size_t start = 0; + bool firstLine = true; + while (start < rawHeaders.size()) { + size_t end = rawHeaders.find(L"\r\n", start); + if (end == std::wstring_view::npos) { + end = rawHeaders.size(); + } + + const std::wstring_view line = rawHeaders.substr(start, end - start); + if (!line.empty() && !firstLine) { + const size_t colon = line.find(L':'); + if (colon != std::wstring_view::npos) { + response.headers.push_back({ + .name = wide_to_utf8(line.substr(0, colon)), + .value = trim_header_value(wide_to_utf8(line.substr(colon + 1))), + }); + } + } + firstLine = false; + + if (end == rawHeaders.size()) { + break; + } + start = end + 2; + } +} + +bool read_status(HINTERNET request, Response& response) { + DWORD statusCode = 0; + DWORD statusCodeSize = sizeof(statusCode); + if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &statusCode, &statusCodeSize, WINHTTP_NO_HEADER_INDEX)) + { + return false; + } + response.statusCode = static_cast(statusCode); + return true; +} + +bool read_headers(HINTERNET request, Response& response) { + DWORD headerBytes = 0; + WinHttpQueryHeaders(request, WINHTTP_QUERY_RAW_HEADERS_CRLF, WINHTTP_HEADER_NAME_BY_INDEX, + WINHTTP_NO_OUTPUT_BUFFER, &headerBytes, WINHTTP_NO_HEADER_INDEX); + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return false; + } + + std::wstring rawHeaders(headerBytes / sizeof(wchar_t), L'\0'); + if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_RAW_HEADERS_CRLF, WINHTTP_HEADER_NAME_BY_INDEX, + rawHeaders.data(), &headerBytes, WINHTTP_NO_HEADER_INDEX)) + { + return false; + } + if (!rawHeaders.empty() && rawHeaders.back() == L'\0') { + rawHeaders.pop_back(); + } + parse_headers(rawHeaders, response); + return true; +} + +} // namespace + +bool available() noexcept { + return true; +} + +Backend backend() noexcept { + return Backend::WinHttp; +} + +const char* backend_name() noexcept { + return "WinHTTP"; +} + +Result get(const Request& request) { + if (request.url.empty()) { + return { + .error = Error::InvalidUrl, + .message = "URL is empty", + }; + } + + std::wstring wideUrl = utf8_to_wide(request.url); + if (wideUrl.empty()) { + return { + .error = Error::InvalidUrl, + .message = "URL is not valid UTF-8", + }; + } + + URL_COMPONENTS components{}; + components.dwStructSize = sizeof(components); + components.dwSchemeLength = static_cast(-1); + components.dwHostNameLength = static_cast(-1); + components.dwUrlPathLength = static_cast(-1); + components.dwExtraInfoLength = static_cast(-1); + if (!WinHttpCrackUrl(wideUrl.c_str(), static_cast(wideUrl.size()), 0, &components)) { + return fail_from_last_error("Failed to parse URL"); + } + if (components.nScheme != INTERNET_SCHEME_HTTPS) { + return { + .error = Error::UnsupportedScheme, + .message = "Only https:// URLs are supported", + }; + } + + const std::wstring host(components.lpszHostName, components.dwHostNameLength); + std::wstring path; + if (components.lpszUrlPath != nullptr && components.dwUrlPathLength > 0) { + path.assign(components.lpszUrlPath, components.dwUrlPathLength); + } + if (components.lpszExtraInfo != nullptr && components.dwExtraInfoLength > 0) { + path.append(components.lpszExtraInfo, components.dwExtraInfoLength); + } + if (path.empty()) { + path = L"/"; + } + + WinHttpHandle session(WinHttpOpen(L"Dusk", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)); + if (session.handle == nullptr) { + return fail_from_last_error("Failed to create WinHTTP session"); + } + + const DWORD timeout = timeout_ms(request.timeout); + WinHttpSetTimeouts(session, timeout, timeout, timeout, timeout); + + WinHttpHandle connection(WinHttpConnect(session, host.c_str(), components.nPort, 0)); + if (connection.handle == nullptr) { + return fail_from_last_error("Failed to connect"); + } + + WinHttpHandle httpRequest(WinHttpOpenRequest(connection, L"GET", path.c_str(), nullptr, + WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)); + if (httpRequest.handle == nullptr) { + return fail_from_last_error("Failed to create request"); + } + + DWORD redirectPolicy = WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP; + WinHttpSetOption( + httpRequest, WINHTTP_OPTION_REDIRECT_POLICY, &redirectPolicy, sizeof(redirectPolicy)); + DWORD maxRedirects = 5; + WinHttpSetOption(httpRequest, WINHTTP_OPTION_MAX_HTTP_AUTOMATIC_REDIRECTS, &maxRedirects, + sizeof(maxRedirects)); + + for (const Header& header : request.headers) { + const std::wstring wideHeader = utf8_to_wide(header.name + ": " + header.value); + if (wideHeader.empty()) { + return { + .error = Error::InvalidUrl, + .message = "Request header is not valid UTF-8", + }; + } + if (!WinHttpAddRequestHeaders(httpRequest, wideHeader.c_str(), + static_cast(wideHeader.size()), WINHTTP_ADDREQ_FLAG_ADD)) + { + return fail_from_last_error("Failed to add request header"); + } + } + + if (!WinHttpSendRequest( + httpRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, WINHTTP_NO_REQUEST_DATA, 0, 0, 0)) + { + return fail_from_last_error("Failed to send request"); + } + if (!WinHttpReceiveResponse(httpRequest, nullptr)) { + return fail_from_last_error("Failed to receive response"); + } + + Response response; + if (!read_status(httpRequest, response)) { + return fail_from_last_error("Failed to read response status"); + } + read_headers(httpRequest, response); + + for (;;) { + DWORD availableBytes = 0; + if (!WinHttpQueryDataAvailable(httpRequest, &availableBytes)) { + return fail_from_last_error("Failed to query response body"); + } + if (availableBytes == 0) { + break; + } + if (availableBytes > request.maxBodyBytes || + response.body.size() > request.maxBodyBytes - availableBytes) + { + return { + .error = Error::TooLarge, + .message = "Response body exceeded the configured limit", + .response = std::move(response), + }; + } + + std::vector buffer(availableBytes); + DWORD bytesRead = 0; + if (!WinHttpReadData(httpRequest, buffer.data(), availableBytes, &bytesRead)) { + return fail_from_last_error("Failed to read response body"); + } + response.body.append(buffer.data(), bytesRead); + } + + return { + .response = std::move(response), + }; +} + +} // namespace dusk::http diff --git a/src/dusk/settings.cpp b/src/dusk/settings.cpp index efaf386bd9..aa31540898 100644 --- a/src/dusk/settings.cpp +++ b/src/dusk/settings.cpp @@ -119,6 +119,7 @@ UserSettings g_userSettings = { .showPipelineCompilation {"backend.showPipelineCompilation", false}, .wasPresetChosen {"backend.wasPresetChosen", false}, .enableCrashReporting {"backend.enableCrashReporting", true}, + .checkForUpdates {"backend.checkForUpdates", true}, .cardFileType {"backend.cardFileType", static_cast(CARD_GCIFOLDER)}, .enableAdvancedSettings {"backend.enableAdvancedSettings", false}, } @@ -222,6 +223,7 @@ void registerSettings() { Register(g_userSettings.backend.showPipelineCompilation); Register(g_userSettings.backend.wasPresetChosen); Register(g_userSettings.backend.enableCrashReporting); + Register(g_userSettings.backend.checkForUpdates); Register(g_userSettings.backend.cardFileType); Register(g_userSettings.backend.enableAdvancedSettings); } diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 42dc9629ac..87f00c3809 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -5,12 +5,15 @@ #include "dusk/iso_validate.hpp" #include "dusk/main.h" #include "dusk/settings.h" +#include "dusk/update_check.hpp" #include "modal.hpp" #include "preset.hpp" #include "settings.hpp" #include "version.h" #include +#include +#include #include #include #include @@ -54,7 +57,13 @@ const Rml::String kDocumentSource = R"RML(
Version
-
Update available! Download
+
+ + +
@@ -114,6 +123,44 @@ struct DiscVerificationTask { std::unique_ptr sDiscVerificationTask; bool sDiscVerificationModalPushed = false; +struct UpdateCheckTask { + UpdateCheckTask() { + worker = std::thread([this] { + try { + result = update_check::check_latest_github_release("TwilitRealm", "dusk"); + } catch (const std::exception& e) { + result = { + .status = update_check::Status::Failed, + .message = fmt::format("Update check failed with exception: {}", e.what()), + }; + } catch (...) { + result = { + .status = update_check::Status::Failed, + .message = "Update check failed with an unknown exception", + }; + } + done.store(true, std::memory_order_release); + }); + } + + ~UpdateCheckTask() { join(); } + + void join() { + if (worker.joinable()) { + worker.join(); + } + } + + [[nodiscard]] bool finished() const { return done.load(std::memory_order_acquire); } + + update_check::Result result; + std::atomic_bool done = false; + std::thread worker; +}; + +std::unique_ptr sUpdateCheckTask; +std::optional sUpdateCheckResult; + bool verification_state_allows_launch(iso::ValidationError validation) noexcept { return validation == iso::ValidationError::Unknown || validation == iso::ValidationError::Success || @@ -185,6 +232,52 @@ std::optional take_finished_disc_verification() { return result; } +void begin_update_check() { + if (!getSettings().backend.checkForUpdates.getValue()) { + return; + } + if (sUpdateCheckTask != nullptr || sUpdateCheckResult.has_value()) { + return; + } + sUpdateCheckTask = std::make_unique(); +} + +std::optional take_finished_update_check() { + if (sUpdateCheckTask == nullptr || !sUpdateCheckTask->finished()) { + return std::nullopt; + } + + sUpdateCheckTask->join(); + auto result = std::move(sUpdateCheckTask->result); + sUpdateCheckTask.reset(); + return result; +} + +std::string update_release_label(const update_check::Release& release) { + std::string_view tagName = release.tagName; + if (!tagName.empty() && tagName.front() == 'v') { + tagName.remove_prefix(1); + } + return std::string(tagName); +} + +void open_update_release() { + if (!sUpdateCheckResult.has_value() || + sUpdateCheckResult->status != update_check::Status::UpdateAvailable) + { + return; + } + + const std::string url = sUpdateCheckResult->latest.htmlUrl; + if (url.empty()) { + PrelaunchLog.warn("Update is available, but the release did not include a download URL"); + return; + } + if (!SDL_OpenURL(url.c_str())) { + PrelaunchLog.warn("Failed to open update URL '{}': {}", url, SDL_GetError()); + } +} + std::string get_error_msg(iso::ValidationError error) { switch (error) { default: @@ -582,6 +675,7 @@ void try_apply_mirrored_layout(Rml::Element* body) { Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementById("root")) { ensure_initialized(); + begin_update_check(); if (auto* menuList = mDocument->GetElementById("menu-list")) { auto& state = prelaunch_state(); @@ -629,6 +723,23 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB mDiscStatus = mDocument->GetElementById("disc-status"); mDiscDetail = mDocument->GetElementById("disc-version"); mVersion = mDocument->GetElementById("version-text"); + mUpdateStatus = mDocument->GetElementById("update-status"); + mUpdateMessage = mDocument->GetElementById("update-message"); + mUpdateDownload = mDocument->GetElementById("update-download"); + mUpdateDownloadLabel = mDocument->GetElementById("update-download-label"); + + if (mUpdateDownload != nullptr) { + listen(mUpdateDownload, Rml::EventId::Click, [](Rml::Event& event) { + open_update_release(); + event.StopPropagation(); + }); + listen(mUpdateDownload, Rml::EventId::Keydown, [](Rml::Event& event) { + if (map_nav_event(event) == NavCommand::Confirm) { + open_update_release(); + event.StopPropagation(); + } + }); + } try_apply_mirrored_layout(mDocument); @@ -767,6 +878,34 @@ void Prelaunch::update() { } mVersion->SetInnerRML(escape(versionStr)); } + if (mUpdateStatus != nullptr && mUpdateMessage != nullptr) { + if (auto result = take_finished_update_check()) { + if (result->status == update_check::Status::Failed) { + PrelaunchLog.error("Failed to check for updates: {}", result->message); + } + sUpdateCheckResult = std::move(*result); + } + + if (sUpdateCheckTask != nullptr) { + mUpdateStatus->SetAttribute("state", "checking"); + mUpdateMessage->SetInnerRML("Checking for updates..."); + } else if (!sUpdateCheckResult.has_value() || + sUpdateCheckResult->status == update_check::Status::UpToDate) + { + mUpdateStatus->RemoveAttribute("state"); + mUpdateMessage->SetInnerRML(""); + } else if (sUpdateCheckResult->status == update_check::Status::UpdateAvailable) { + mUpdateStatus->SetAttribute("state", "available"); + mUpdateMessage->SetInnerRML("Update available!"); + if (mUpdateDownloadLabel != nullptr) { + mUpdateDownloadLabel->SetInnerRML(escape( + fmt::format("Download {}", update_release_label(sUpdateCheckResult->latest)))); + } + } else { + mUpdateStatus->SetAttribute("state", "failed"); + mUpdateMessage->SetInnerRML("Failed to check for updates"); + } + } Document::update(); } diff --git a/src/dusk/ui/prelaunch.hpp b/src/dusk/ui/prelaunch.hpp index 95ba2238f4..0fbb64ded9 100644 --- a/src/dusk/ui/prelaunch.hpp +++ b/src/dusk/ui/prelaunch.hpp @@ -31,6 +31,10 @@ private: Rml::Element* mDiscStatus = nullptr; Rml::Element* mDiscDetail = nullptr; Rml::Element* mVersion = nullptr; + Rml::Element* mUpdateStatus = nullptr; + Rml::Element* mUpdateMessage = nullptr; + Rml::Element* mUpdateDownload = nullptr; + Rml::Element* mUpdateDownloadLabel = nullptr; }; class PrelaunchOptions; diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index 972dc1421b..6f42f555c7 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -917,6 +917,12 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .key = "Show Pipeline Compilation", .helpText = "Show an overlay when shaders are being compiled for your hardware.", }); + config_bool_select(leftPane, rightPane, getSettings().backend.checkForUpdates, + { + .key = "Check for Updates", + .helpText = "Checks GitHub releases for a new Dusk version on startup.

" + "No personal information is transmitted or collected.", + }); config_bool_select(leftPane, rightPane, getSettings().backend.enableAdvancedSettings, { .key = "Enable Advanced Settings", diff --git a/src/dusk/update_check.cpp b/src/dusk/update_check.cpp new file mode 100644 index 0000000000..11c11795af --- /dev/null +++ b/src/dusk/update_check.cpp @@ -0,0 +1,196 @@ +#include "update_check.hpp" + +#include "dusk/http/http.hpp" +#include "fmt/format.h" +#include "nlohmann/json.hpp" +#include "version.h" + +#include +#include +#include + +namespace dusk::update_check { +namespace { + +using json = nlohmann::json; + +constexpr std::string_view GitHubApiVersion = "2026-03-10"; + +struct Version { + int major = 0; + int minor = 0; + int patch = 0; + + friend auto operator<=>(const Version&, const Version&) = default; +}; + +std::string json_string(const json& value, const char* key) { + const auto iter = value.find(key); + if (iter == value.end() || !iter->is_string()) { + return {}; + } + return iter->get(); +} + +std::optional parse_component(std::string_view& value) { + if (value.empty() || value.front() < '0' || value.front() > '9') { + return std::nullopt; + } + + int parsed = 0; + const char* begin = value.data(); + const char* end = value.data() + value.size(); + const auto [ptr, ec] = std::from_chars(begin, end, parsed); + if (ec != std::errc()) { + return std::nullopt; + } + + value.remove_prefix(static_cast(ptr - begin)); + return parsed; +} + +bool consume(std::string_view& value, char expected) { + if (value.empty() || value.front() != expected) { + return false; + } + value.remove_prefix(1); + return true; +} + +std::optional parse_version(std::string_view value) { + if (!value.empty() && value.front() == 'v') { + value.remove_prefix(1); + } + + Version version; + auto major = parse_component(value); + if (!major || !consume(value, '.')) { + return std::nullopt; + } + auto minor = parse_component(value); + if (!minor || !consume(value, '.')) { + return std::nullopt; + } + auto patch = parse_component(value); + if (!patch) { + return std::nullopt; + } + if (!value.empty() && value.front() != '-' && value.front() != '+') { + return std::nullopt; + } + + version.major = *major; + version.minor = *minor; + version.patch = *patch; + return version; +} + +Release parse_release(const json& value) { + Release release{ + .tagName = json_string(value, "tag_name"), + .name = json_string(value, "name"), + .htmlUrl = json_string(value, "html_url"), + .body = json_string(value, "body"), + }; + + const auto assets = value.find("assets"); + if (assets != value.end() && assets->is_array()) { + for (const auto& asset : *assets) { + if (!asset.is_object()) { + continue; + } + release.assets.push_back({ + .name = json_string(asset, "name"), + .browserDownloadUrl = json_string(asset, "browser_download_url"), + .digest = json_string(asset, "digest"), + }); + } + } + + return release; +} + +std::string release_url(std::string_view owner, std::string_view repo) { + return fmt::format("https://api.github.com/repos/{}/{}/releases/latest", owner, repo); +} + +std::string user_agent() { + return fmt::format("Dusk/{}", DUSK_WC_DESCRIBE); +} + +} // namespace + +Result check_latest_github_release(std::string_view owner, std::string_view repo) { + if (!http::available()) { + return { + .status = Status::Disabled, + .message = "No HTTP backend is available", + }; + } + if (owner.empty() || repo.empty()) { + return { + .status = Status::Failed, + .message = "GitHub owner and repo are required", + }; + } + + http::Request request{ + .url = release_url(owner, repo), + .headers = + { + {.name = "User-Agent", .value = user_agent()}, + {.name = "Accept", .value = "application/vnd.github+json"}, + {.name = "X-GitHub-Api-Version", .value = std::string(GitHubApiVersion)}, + }, + }; + + http::Result result = http::get(request); + if (result.error != http::Error::None) { + return { + .status = Status::Failed, + .message = result.message, + }; + } + if (result.response.statusCode != 200) { + return { + .status = Status::Failed, + .message = fmt::format("GitHub returned HTTP {}", result.response.statusCode), + }; + } + + Release latest; + try { + latest = parse_release(json::parse(result.response.body)); + } catch (const std::exception& e) { + return { + .status = Status::Failed, + .message = fmt::format("Failed to parse GitHub release JSON: {}", e.what()), + }; + } + + const std::optional latestVersion = parse_version(latest.tagName); + const std::optional currentVersion = parse_version(DUSK_WC_DESCRIBE); + if (!latestVersion) { + return { + .status = Status::Failed, + .message = fmt::format("Failed to parse release tag '{}'", latest.tagName), + .latest = std::move(latest), + }; + } + if (!currentVersion) { + return { + .status = Status::Failed, + .message = fmt::format("Failed to parse Dusk version '{}'", DUSK_WC_DESCRIBE), + .latest = std::move(latest), + }; + } + + const bool updateAvailable = *latestVersion > *currentVersion; + return { + .status = updateAvailable ? Status::UpdateAvailable : Status::UpToDate, + .message = updateAvailable ? "Update available" : "Dusk is up to date", + .latest = std::move(latest), + }; +} + +} // namespace dusk::update_check diff --git a/src/dusk/update_check.hpp b/src/dusk/update_check.hpp new file mode 100644 index 0000000000..72c66449dc --- /dev/null +++ b/src/dusk/update_check.hpp @@ -0,0 +1,41 @@ +#ifndef DUSK_UPDATE_CHECK_HPP +#define DUSK_UPDATE_CHECK_HPP + +#include +#include +#include + +namespace dusk::update_check { + +enum class Status { + Disabled, + UpToDate, + UpdateAvailable, + Failed, +}; + +struct Asset { + std::string name; + std::string browserDownloadUrl; + std::string digest; +}; + +struct Release { + std::string tagName; + std::string name; + std::string htmlUrl; + std::string body; + std::vector assets; +}; + +struct Result { + Status status = Status::Failed; + std::string message; + Release latest; +}; + +Result check_latest_github_release(std::string_view owner, std::string_view repo); + +} // namespace dusk::update_check + +#endif // DUSK_UPDATE_CHECK_HPP