diff --git a/Engine/Vfs/https_download.cpp b/Engine/Vfs/https_download.cpp index 1c2f4f88..f6dfa0fe 100644 --- a/Engine/Vfs/https_download.cpp +++ b/Engine/Vfs/https_download.cpp @@ -3,8 +3,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -189,6 +191,74 @@ Download get(std::wstring_view url, const std::filesystem::path& destination, return result; } +std::optional content_length(std::wstring_view url, std::uint32_t timeout_seconds, + const wchar_t* agent, Watch* watch) { + const auto started = now_us(); + const auto timeout_deadline = started + std::uint64_t{timeout_seconds} * 1000000; + const auto stop = [&]() -> DWORD { + if (watch && watch->cancel.load()) return ERROR_CANCELLED; + const auto external = watch ? watch->deadline_us.load() : 0; + const auto deadline = external && external < timeout_deadline ? external : timeout_deadline; + return now_us() >= deadline ? ERROR_TIMEOUT : ERROR_SUCCESS; + }; + if (stop() || url.empty() || url.size() >= 4096 || url.find_first_of(L"\r\n\t #") != std::wstring_view::npos) + return std::nullopt; + const std::wstring address(url); + URL_COMPONENTS parts{}; parts.dwStructSize = sizeof(parts); + parts.dwHostNameLength = parts.dwUrlPathLength = parts.dwExtraInfoLength = + parts.dwUserNameLength = parts.dwPasswordLength = static_cast(-1); + if (!WinHttpCrackUrl(address.c_str(), static_cast(address.size()), 0, &parts) || + parts.nScheme != INTERNET_SCHEME_HTTPS || !parts.dwHostNameLength || parts.dwUserNameLength || parts.dwPasswordLength) + return std::nullopt; + const std::wstring host(parts.lpszHostName, parts.dwHostNameLength); + std::wstring resource = parts.dwUrlPathLength ? std::wstring(parts.lpszUrlPath, parts.dwUrlPathLength) : L"/"; + if (parts.dwExtraInfoLength) resource.append(parts.lpszExtraInfo, parts.dwExtraInfoLength); + Internet session{WinHttpOpen(agent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, nullptr, nullptr, WINHTTP_FLAG_ASYNC)}; + if (!session.value || !WinHttpSetTimeouts(session.value, 5000, 5000, 10000, 10000)) return std::nullopt; + Internet connection{WinHttpConnect(session.value, host.c_str(), parts.nPort, 0)}; + if (!connection.value) return std::nullopt; + Internet request{WinHttpOpenRequest(connection.value, L"HEAD", resource.c_str(), nullptr, nullptr, + WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)}; + if (!request.value) return std::nullopt; + AsyncRequest async; + DWORD redirect = WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP; + DWORD logon = WINHTTP_AUTOLOGON_SECURITY_LEVEL_HIGH; + if (!WinHttpSetOption(request.value, WINHTTP_OPTION_REDIRECT_POLICY, &redirect, sizeof(redirect)) || + !WinHttpSetOption(request.value, WINHTTP_OPTION_AUTOLOGON_POLICY, &logon, sizeof(logon))) + return std::nullopt; + DWORD_PTR context = reinterpret_cast(&async); + if (!WinHttpSetOption(request.value, WINHTTP_OPTION_CONTEXT_VALUE, &context, sizeof(context))) return std::nullopt; + if (WinHttpSetStatusCallback(request.value, AsyncRequest::callback, + WINHTTP_CALLBACK_FLAG_SENDREQUEST_COMPLETE | WINHTTP_CALLBACK_FLAG_HEADERS_AVAILABLE | + WINHTTP_CALLBACK_FLAG_REQUEST_ERROR | WINHTTP_CALLBACK_FLAG_HANDLES, 0) == WINHTTP_INVALID_STATUS_CALLBACK) + return std::nullopt; + async.value = std::exchange(request.value, nullptr); + const auto finish = [&](BOOL accepted) -> DWORD { + if (!accepted && GetLastError() != ERROR_IO_PENDING) return GetLastError(); + return async.wait(stop); + }; + if (stop()) return std::nullopt; + async.prepare(WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE); + if (finish(WinHttpSendRequest(async.value, nullptr, 0, nullptr, 0, 0, context))) return std::nullopt; + async.prepare(WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE); + if (stop() || finish(WinHttpReceiveResponse(async.value, nullptr))) return std::nullopt; + DWORD status{}, size = sizeof(status); + if (!WinHttpQueryHeaders(async.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + nullptr, &status, &size, nullptr) || status != 200) + return std::nullopt; + // As text: a number query is 32 bits, and a map can be larger than that. + wchar_t digits[32]{}; + size = sizeof(digits); + if (!WinHttpQueryHeaders(async.value, WINHTTP_QUERY_CONTENT_LENGTH, WINHTTP_HEADER_NAME_BY_INDEX, + digits, &size, WINHTTP_NO_HEADER_INDEX) || !digits[0]) + return std::nullopt; + wchar_t* end{}; + errno = 0; + const auto length = std::wcstoull(digits, &end, 10); + if (end == digits || *end || errno == ERANGE || !length) return std::nullopt; + return length; +} + std::optional get_text(std::wstring_view url, std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent, Download* result, Watch* watch) { static std::atomic serial{}; diff --git a/Engine/Vfs/https_download.h b/Engine/Vfs/https_download.h index aa71675a..e77aef3a 100644 --- a/Engine/Vfs/https_download.h +++ b/Engine/Vfs/https_download.h @@ -28,4 +28,8 @@ Download get(std::wstring_view url, const std::filesystem::path& destination, // fails, and `result`, when given, says how. std::optional get_text(std::wstring_view url, std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent, Download* result = nullptr, Watch* watch = nullptr); +// Content-Length of an HTTPS HEAD, when the server gives one. The body is not read. Nothing +// when the request fails or the length is absent: a caller can go on without a size. +std::optional content_length(std::wstring_view url, std::uint32_t timeout_seconds, + const wchar_t* agent, Watch* watch = nullptr); } diff --git a/Extension/Assets/Test/map_download_timeout_tests.cpp b/Extension/Assets/Test/map_download_timeout_tests.cpp index fd8572f8..c3856e64 100644 --- a/Extension/Assets/Test/map_download_timeout_tests.cpp +++ b/Extension/Assets/Test/map_download_timeout_tests.cpp @@ -78,6 +78,9 @@ DWORD test_get_temp_path(DWORD capacity, LPWSTR output) { #undef now_us namespace dingosdk::https { +std::optional content_length(std::wstring_view, std::uint32_t, const wchar_t*, Watch*) { + return std::nullopt; +} std::optional get_text(std::wstring_view, std::uint64_t, std::uint32_t, const wchar_t*, Download* result, Watch* watch) { fixture::require(watch && watch->deadline_us.load() == map_download::deadline.load(), diff --git a/Extension/Assets/map_download.cpp b/Extension/Assets/map_download.cpp index d6853183..e1db384a 100644 --- a/Extension/Assets/map_download.cpp +++ b/Extension/Assets/map_download.cpp @@ -46,6 +46,7 @@ package::Name wanted; // under mutex std::string version; // under mutex: the one to download std::string newest; // under mutex std::string description; // under mutex: Thunderstore's, or the installed mod's own +std::uint64_t package_bytes{}; // under mutex: the zip's Content-Length bool installed_off{}; // under mutex: here already, switched off std::string last_failed; // under mutex: a package not to offer again this run https::Watch watch; @@ -228,11 +229,15 @@ void check() { // Another version is only worth fetching when it is the host's: any other leaves the two apart still. if (update && choice.version != multiplayer::map_package_version(need_package())) return fail("That version is not on Thunderstore any more. Ask the " + host_word(need_server()) + " to update the map, or install its version yourself."); + // The package page names the version and not its size. The download's Content-Length is + // what the card shows; the body is not fetched until the player says yes. + const auto length = https::content_length(package::download_url(name, choice.version), 8, agent, &watch); { std::lock_guard lock(mutex); version = choice.version; newest = choice.newest; description = choice.description; + package_bytes = length.value_or(0); } // Its icon for the card: small, and not worth holding the question up for long. if (!choice.icon.empty()) @@ -407,6 +412,7 @@ void offer(const multiplayer::MapNeed& asked) { version.clear(); newest.clear(); description.clear(); + package_bytes = 0; installed_off = false; } overlay::set_map_download_icon({}); @@ -471,6 +477,7 @@ View view() { out.author = wanted.owner; out.version = version; out.description = description; + out.bytes = package_bytes; if (out.stage == Stage::applying) { const auto now = live_mods::progress(); out.step = now.done; diff --git a/Extension/Assets/map_download.h b/Extension/Assets/map_download.h index 13f4c1f0..6f60a7a0 100644 --- a/Extension/Assets/map_download.h +++ b/Extension/Assets/map_download.h @@ -42,6 +42,7 @@ struct View { bool installed{}; // asking: the mod is here but switched off, so nothing is downloaded std::string have; // the version installed here, when the host's is another and replaces it bool choice{true}; // asking: the answer a controller has picked (true: download) + std::uint64_t bytes{}; // asking: the zip's Content-Length; 0 when the server did not say std::uint64_t received{}, total{}; // downloading; total 0 when the size is not known std::uint32_t yes_bind{}, no_bind{}; // the vote binds, which answer the card }; diff --git a/Extension/UI/Overlay/map_download_card.cpp b/Extension/UI/Overlay/map_download_card.cpp index 040f4742..05743772 100644 --- a/Extension/UI/Overlay/map_download_card.cpp +++ b/Extension/UI/Overlay/map_download_card.cpp @@ -6,7 +6,7 @@ // The card for a map a multiplayer session needs and this PC does not have // (Extension/Assets/map_download.h), laid out as the mod's page is: its icon, name, author, -// version and description, then what is being asked or done. It asks whether to download it, +// version, size and description, then what is being asked or done. It asks whether to download it, // in the middle of the screen with the controls under it as the game's own menus have them. // Once the player has said yes it becomes a small card at the right edge, with the download, // the install and the apply, so they can skate on while they wait; it goes when the map is in @@ -223,6 +223,8 @@ void draw_asked_map(State& s) { std::vector facts{{"Author", card.author}}; if (!card.version.empty()) facts.push_back({"Version", card.version}); facts.push_back({"From", card.installed ? std::string("Your Mods folder, switched off") : std::string("Thunderstore")}); + // A download or an update. A mod that is here and only switched off has nothing to fetch. + if (asking && !card.installed && card.bytes) facts.push_back({"Size", megabytes(card.bytes)}); // The controls under the card: the pad's own names for its buttons when one is connected. dingosdk::ControllerInput pad; diff --git a/Extension/UI/Overlay/overlay.h b/Extension/UI/Overlay/overlay.h index 56cfe686..1ff050c9 100644 --- a/Extension/UI/Overlay/overlay.h +++ b/Extension/UI/Overlay/overlay.h @@ -346,6 +346,7 @@ struct MapDownloadCard { std::size_t step{}, steps{}; // applying: the merge's steps done of all std::string step_name; bool server{}, installed{}, moved{}, choice{true}; + std::uint64_t bytes{}; // asking: the package's size; 0 when it is not known std::uint64_t received{}, total{}; std::uint32_t yes_bind{}, no_bind{}; }; diff --git a/Runtime/runtime_init.cpp b/Runtime/runtime_init.cpp index 9b2e836c..242770e2 100644 --- a/Runtime/runtime_init.cpp +++ b/Runtime/runtime_init.cpp @@ -233,6 +233,7 @@ extern "C" __declspec(dllexport) BOOL WINAPI DingoSDKDebugInitialize() { card.moved = view.moved; card.installed = view.installed; card.have = view.have; + card.bytes = view.bytes; card.choice = view.choice; card.received = view.received; card.total = view.total;