UI: Add update checks (#715)

This commit is contained in:
Luke Street
2026-05-08 08:52:36 -06:00
committed by GitHub
parent 673ca7f686
commit fc533dbdc7
15 changed files with 1333 additions and 9 deletions
+39 -2
View File
@@ -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)
+10
View File
@@ -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
)
+1
View File
@@ -172,6 +172,7 @@ struct UserSettings {
ConfigVar<bool> showPipelineCompilation;
ConfigVar<bool> wasPresetChosen;
ConfigVar<bool> enableCrashReporting;
ConfigVar<bool> checkForUpdates;
ConfigVar<int> cardFileType;
ConfigVar<bool> enableAdvancedSettings;
} backend;
+47 -6
View File
@@ -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("&#xe5c8;" center center);
}
.detail {
color: #A6A09B;
}
body.mirrored .update {
justify-content: flex-start;
}
/* Startup animation */
.intro-item {
opacity: 0;
+206
View File
@@ -0,0 +1,206 @@
#include "http.hpp"
#include <curl/curl.h>
#include <algorithm>
#include <mutex>
#include <string_view>
#include <utility>
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<CurlContext*>(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<CurlContext*>(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<std::chrono::milliseconds::rep>(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<int>(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
+59
View File
@@ -0,0 +1,59 @@
#ifndef DUSK_HTTP_HTTP_HPP
#define DUSK_HTTP_HTTP_HPP
#include <chrono>
#include <cstddef>
#include <string>
#include <vector>
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<Header> headers;
std::chrono::milliseconds timeout{10000};
size_t maxBodyBytes = 1024 * 1024;
};
struct Response {
int statusCode = 0;
std::vector<Header> 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
+24
View File
@@ -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
+238
View File
@@ -0,0 +1,238 @@
#include "http.hpp"
#import <Foundation/Foundation.h>
#include <algorithm>
#include <string_view>
#include <utility>
@interface DuskHttpRequestDelegate : NSObject <NSURLSessionDataDelegate, NSURLSessionTaskDelegate>
@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<std::chrono::milliseconds::rep>(1, timeout.count());
return dispatch_time(DISPATCH_TIME_NOW,
static_cast<int64_t>(milliseconds) * static_cast<int64_t>(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<int>(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<const char*>(delegate.data.bytes),
static_cast<size_t>(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
+320
View File
@@ -0,0 +1,320 @@
#include "http.hpp"
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#include <winhttp.h>
#include <algorithm>
#include <limits>
#include <string_view>
#include <utility>
#include <vector>
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<int>(value.size()), nullptr, 0);
if (required <= 0) {
return {};
}
std::wstring result(static_cast<size_t>(required), L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), static_cast<int>(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<int>(value.size()), nullptr, 0, nullptr, nullptr);
if (required <= 0) {
return {};
}
std::string result(static_cast<size_t>(required), '\0');
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(),
required, nullptr, nullptr);
return result;
}
DWORD timeout_ms(std::chrono::milliseconds timeout) {
const auto count = std::max<std::chrono::milliseconds::rep>(1, timeout.count());
return static_cast<DWORD>(
std::min<std::chrono::milliseconds::rep>(count, std::numeric_limits<int>::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<int>(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<DWORD>(-1);
components.dwHostNameLength = static_cast<DWORD>(-1);
components.dwUrlPathLength = static_cast<DWORD>(-1);
components.dwExtraInfoLength = static_cast<DWORD>(-1);
if (!WinHttpCrackUrl(wideUrl.c_str(), static_cast<DWORD>(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<DWORD>(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<char> 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
+2
View File
@@ -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<int>(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);
}
+140 -1
View File
@@ -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 <SDL3/SDL_dialog.h>
#include <SDL3/SDL_error.h>
#include <SDL3/SDL_misc.h>
#include <aurora/lib/logging.hpp>
#include <aurora/lib/window.hpp>
#include <fmt/format.h>
@@ -54,7 +57,13 @@ const Rml::String kDocumentSource = R"RML(
</disc-info>
<version-info class="intro-item delay-5">
<div class="version">Version <span id="version-text"></span></div>
<div class="update"><span>Update available!</span> Download</div>
<div id="update-status" class="update">
<span id="update-message"></span>
<button id="update-download">
<span id="update-download-label"></span>
&nbsp;<icon />
</button>
</div>
</version-info>
</content>
</body>
@@ -114,6 +123,44 @@ struct DiscVerificationTask {
std::unique_ptr<DiscVerificationTask> 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<UpdateCheckTask> sUpdateCheckTask;
std::optional<update_check::Result> 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<DiscVerificationResult> 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<UpdateCheckTask>();
}
std::optional<update_check::Result> 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();
}
+4
View File
@@ -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;
+6
View File
@@ -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.<br/><br/>"
"No personal information is transmitted or collected.",
});
config_bool_select(leftPane, rightPane, getSettings().backend.enableAdvancedSettings,
{
.key = "Enable Advanced Settings",
+196
View File
@@ -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 <charconv>
#include <optional>
#include <utility>
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::string>();
}
std::optional<int> 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<size_t>(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<Version> 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<Version> latestVersion = parse_version(latest.tagName);
const std::optional<Version> 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
+41
View File
@@ -0,0 +1,41 @@
#ifndef DUSK_UPDATE_CHECK_HPP
#define DUSK_UPDATE_CHECK_HPP
#include <string>
#include <string_view>
#include <vector>
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<Asset> 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