Show a missing map's zip size on the join prompt before the download starts

This commit is contained in:
wilu222 committed 2026-10-18 19:17:07 -04:00
1 parent fffd34ca80
commit f737e5b51f
8 files changed
+90 -1

No files matched your search

+70
View File
@@ -3,8 +3,10 @@
#include <winhttp.h>
#include <array>
#include <atomic>
#include <cerrno>
#include <chrono>
#include <condition_variable>
#include <cstdlib>
#include <fstream>
#include <iterator>
#include <mutex>
@@ -189,6 +191,74 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
return result;
}
std::optional<std::uint64_t> 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<DWORD>(-1);
if (!WinHttpCrackUrl(address.c_str(), static_cast<DWORD>(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<DWORD_PTR>(&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<std::string> 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<unsigned> serial{};
+4
View File
@@ -28,4 +28,8 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
// fails, and `result`, when given, says how.
std::optional<std::string> 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<std::uint64_t> content_length(std::wstring_view url, std::uint32_t timeout_seconds,
const wchar_t* agent, Watch* watch = nullptr);
}
@@ -78,6 +78,9 @@ DWORD test_get_temp_path(DWORD capacity, LPWSTR output) {
#undef now_us
namespace dingosdk::https {
std::optional<std::uint64_t> content_length(std::wstring_view, std::uint32_t, const wchar_t*, Watch*) {
return std::nullopt;
}
std::optional<std::string> 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(),
+7
View File
@@ -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;
+1
View File
@@ -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
};
+3 -1
View File
@@ -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<Fact> 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;
+1
View File
@@ -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{};
};
+1
View File
@@ -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;