2026-09-06 21:28:55 -07:00
|
|
|
/*
|
|
|
|
|
streamer-tools OBS Camera Plugin - WinHTTP backend (Windows)
|
|
|
|
|
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
|
|
|
|
|
|
2026-09-07 04:44:16 -07:00
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
2026-09-06 21:28:55 -07:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// WinHTTP rather than libcurl on Windows: it ships with the OS, does TLS
|
|
|
|
|
// through SChannel (so no OpenSSL to build or ship), and needs no package
|
|
|
|
|
// manager on the self-hosted `winvm-builder` runner -- which, per the
|
|
|
|
|
// scaffold README, is a bare VM without even cmake preinstalled.
|
|
|
|
|
|
|
|
|
|
#include "stplugin/http.h"
|
|
|
|
|
|
|
|
|
|
#include <windows.h>
|
|
|
|
|
#include <winhttp.h>
|
|
|
|
|
|
|
|
|
|
#include <cstddef>
|
|
|
|
|
#include <string>
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
|
|
|
|
namespace stplugin {
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
constexpr std::size_t kMaxResponseBytes = 4u * 1024u * 1024u;
|
|
|
|
|
|
|
|
|
|
std::wstring widen(const std::string &s)
|
|
|
|
|
{
|
|
|
|
|
if (s.empty())
|
|
|
|
|
return std::wstring();
|
|
|
|
|
const int needed = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), nullptr, 0);
|
|
|
|
|
if (needed <= 0)
|
|
|
|
|
return std::wstring();
|
|
|
|
|
std::wstring out(static_cast<std::size_t>(needed), L'\0');
|
|
|
|
|
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), &out[0], needed);
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string lastErrorMessage(const char *what)
|
|
|
|
|
{
|
|
|
|
|
return std::string(what) + " failed (GetLastError=" + std::to_string(GetLastError()) + ")";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// RAII for the three WinHTTP handle kinds, which all close the same way.
|
|
|
|
|
class Handle {
|
|
|
|
|
public:
|
|
|
|
|
Handle() = default;
|
|
|
|
|
explicit Handle(HINTERNET h) : h_(h) {}
|
|
|
|
|
~Handle()
|
|
|
|
|
{
|
|
|
|
|
if (h_)
|
|
|
|
|
WinHttpCloseHandle(h_);
|
|
|
|
|
}
|
|
|
|
|
Handle(const Handle &) = delete;
|
|
|
|
|
Handle &operator=(const Handle &) = delete;
|
|
|
|
|
|
|
|
|
|
void reset(HINTERNET h)
|
|
|
|
|
{
|
|
|
|
|
if (h_)
|
|
|
|
|
WinHttpCloseHandle(h_);
|
|
|
|
|
h_ = h;
|
|
|
|
|
}
|
|
|
|
|
HINTERNET get() const { return h_; }
|
|
|
|
|
explicit operator bool() const { return h_ != nullptr; }
|
|
|
|
|
|
|
|
|
|
private:
|
|
|
|
|
HINTERNET h_ = nullptr;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
class WinHttpClient : public HttpClient {
|
|
|
|
|
public:
|
|
|
|
|
HttpResponse send(const HttpRequest &request) override
|
|
|
|
|
{
|
|
|
|
|
HttpResponse response;
|
|
|
|
|
|
|
|
|
|
const std::wstring url = widen(request.url);
|
|
|
|
|
if (url.empty()) {
|
|
|
|
|
response.network_error = "empty or non-UTF-8 URL";
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
URL_COMPONENTS parts{};
|
|
|
|
|
parts.dwStructSize = sizeof(parts);
|
|
|
|
|
wchar_t host[256] = {0};
|
|
|
|
|
wchar_t path[4096] = {0};
|
|
|
|
|
wchar_t extra[4096] = {0};
|
|
|
|
|
parts.lpszHostName = host;
|
|
|
|
|
parts.dwHostNameLength = static_cast<DWORD>(sizeof(host) / sizeof(host[0]));
|
|
|
|
|
parts.lpszUrlPath = path;
|
|
|
|
|
parts.dwUrlPathLength = static_cast<DWORD>(sizeof(path) / sizeof(path[0]));
|
|
|
|
|
parts.lpszExtraInfo = extra;
|
|
|
|
|
parts.dwExtraInfoLength = static_cast<DWORD>(sizeof(extra) / sizeof(extra[0]));
|
|
|
|
|
|
|
|
|
|
if (!WinHttpCrackUrl(url.c_str(), static_cast<DWORD>(url.size()), 0, &parts)) {
|
|
|
|
|
response.network_error = lastErrorMessage("WinHttpCrackUrl");
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
if (parts.nScheme != INTERNET_SCHEME_HTTP && parts.nScheme != INTERNET_SCHEME_HTTPS) {
|
|
|
|
|
response.network_error = "unsupported URL scheme";
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Handle session(WinHttpOpen(L"streamer-tools-obs-plugin/1.0", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY,
|
|
|
|
|
WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0));
|
|
|
|
|
if (!session) {
|
|
|
|
|
response.network_error = lastErrorMessage("WinHttpOpen");
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DWORD timeout = static_cast<DWORD>(request.timeout_ms);
|
|
|
|
|
WinHttpSetTimeouts(session.get(), static_cast<int>(timeout), static_cast<int>(timeout),
|
|
|
|
|
static_cast<int>(timeout), static_cast<int>(timeout));
|
|
|
|
|
|
2026-09-09 18:30:02 -07:00
|
|
|
// WinHttpSetTimeouts' receive parameter maps to
|
|
|
|
|
// WINHTTP_OPTION_RECEIVE_TIMEOUT, which Microsoft documents as a
|
|
|
|
|
// PER-PACKET Winsock-layer read timeout ("applies to fetching each
|
|
|
|
|
// packet of data off the socket"), not a deadline on the response.
|
|
|
|
|
// The wait for the response HEADERS is a *separate* option,
|
|
|
|
|
// WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT ("to wait to receive all
|
|
|
|
|
// response headers to a request"), which WinHttpSetTimeouts does not
|
|
|
|
|
// touch and which defaults to 90 SECONDS. Without this call a server
|
|
|
|
|
// that accepts, reads the request and then stalls can hold this
|
|
|
|
|
// thread for a minute and a half regardless of request.timeout_ms --
|
|
|
|
|
// exactly the "blocking an OBS thread indefinitely" failure
|
|
|
|
|
// testPlatformBackendTimeout exists to prevent, and the likely
|
|
|
|
|
// mechanism behind that test's intermittent Windows failures.
|
|
|
|
|
//
|
|
|
|
|
// Caveat, also documented: this timeout "is checked only when data is
|
|
|
|
|
// received from the socket", so it bounds the wait but does not
|
|
|
|
|
// guarantee a hard deadline. A guaranteed deadline needs a watchdog
|
|
|
|
|
// thread calling WinHttpCloseHandle; not done here.
|
|
|
|
|
//
|
|
|
|
|
// Guarded because the constant postdates some Windows SDK headers; a
|
|
|
|
|
// toolchain without it keeps the previous (90s default) behaviour
|
|
|
|
|
// rather than failing to build.
|
|
|
|
|
#ifdef WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT
|
|
|
|
|
DWORD response_timeout = timeout;
|
|
|
|
|
// Return value deliberately unchecked: a rejected option leaves the
|
|
|
|
|
// documented default in place, which is degraded but still correct
|
|
|
|
|
// behaviour, and there is no logging sink in this layer to report it
|
|
|
|
|
// to. The timeout probe in test_api_client.cpp is what would catch a
|
|
|
|
|
// regression here.
|
|
|
|
|
WinHttpSetOption(session.get(), WINHTTP_OPTION_RECEIVE_RESPONSE_TIMEOUT, &response_timeout,
|
|
|
|
|
sizeof(response_timeout));
|
|
|
|
|
#endif
|
|
|
|
|
|
2026-09-06 21:28:55 -07:00
|
|
|
Handle connect(WinHttpConnect(session.get(), host, parts.nPort, 0));
|
|
|
|
|
if (!connect) {
|
|
|
|
|
response.network_error = lastErrorMessage("WinHttpConnect");
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::wstring target(path);
|
|
|
|
|
target += extra;
|
|
|
|
|
|
|
|
|
|
const DWORD flags = (parts.nScheme == INTERNET_SCHEME_HTTPS) ? WINHTTP_FLAG_SECURE : 0u;
|
|
|
|
|
Handle req(WinHttpOpenRequest(connect.get(), widen(request.method).c_str(), target.c_str(), nullptr,
|
|
|
|
|
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags));
|
|
|
|
|
if (!req) {
|
|
|
|
|
response.network_error = lastErrorMessage("WinHttpOpenRequest");
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::wstring headers;
|
|
|
|
|
if (!request.content_type.empty())
|
|
|
|
|
headers = L"Content-Type: " + widen(request.content_type) + L"\r\n";
|
|
|
|
|
|
|
|
|
|
const LPCWSTR header_ptr = headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS : headers.c_str();
|
|
|
|
|
const DWORD header_len = headers.empty() ? 0u : static_cast<DWORD>(headers.size());
|
|
|
|
|
|
|
|
|
|
void *body_ptr = request.body.empty() ? WINHTTP_NO_REQUEST_DATA
|
|
|
|
|
: const_cast<char *>(request.body.data());
|
|
|
|
|
const DWORD body_len = static_cast<DWORD>(request.body.size());
|
|
|
|
|
|
|
|
|
|
if (!WinHttpSendRequest(req.get(), header_ptr, header_len, body_ptr, body_len, body_len, 0)) {
|
|
|
|
|
response.network_error = lastErrorMessage("WinHttpSendRequest");
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
if (!WinHttpReceiveResponse(req.get(), nullptr)) {
|
|
|
|
|
response.network_error = lastErrorMessage("WinHttpReceiveResponse");
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DWORD status = 0;
|
|
|
|
|
DWORD status_size = sizeof(status);
|
|
|
|
|
if (!WinHttpQueryHeaders(req.get(), WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
|
|
|
|
|
WINHTTP_HEADER_NAME_BY_INDEX, &status, &status_size, WINHTTP_NO_HEADER_INDEX)) {
|
|
|
|
|
response.network_error = lastErrorMessage("WinHttpQueryHeaders");
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
response.status = static_cast<long>(status);
|
|
|
|
|
|
|
|
|
|
std::string body;
|
|
|
|
|
for (;;) {
|
|
|
|
|
DWORD available = 0;
|
|
|
|
|
if (!WinHttpQueryDataAvailable(req.get(), &available)) {
|
|
|
|
|
response.network_error = lastErrorMessage("WinHttpQueryDataAvailable");
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
if (available == 0)
|
|
|
|
|
break;
|
|
|
|
|
if (body.size() + available > kMaxResponseBytes) {
|
|
|
|
|
response.network_error = "response body exceeded 4 MiB";
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
std::vector<char> chunk(available);
|
|
|
|
|
DWORD read = 0;
|
|
|
|
|
if (!WinHttpReadData(req.get(), chunk.data(), available, &read)) {
|
|
|
|
|
response.network_error = lastErrorMessage("WinHttpReadData");
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
if (read == 0)
|
|
|
|
|
break;
|
|
|
|
|
body.append(chunk.data(), read);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
response.body = std::move(body);
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
HttpClient *createPlatformHttpClient()
|
|
|
|
|
{
|
|
|
|
|
return new WinHttpClient();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace stplugin
|